├── .github ├── pull_request_template.md └── workflows │ ├── backend-deploy.yml │ └── frontend-deploy.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .yarn └── sdks │ ├── eslint │ ├── bin │ │ └── eslint.js │ ├── lib │ │ ├── api.js │ │ └── unsupported-api.js │ └── package.json │ ├── integrations.yml │ ├── prettier │ ├── bin │ │ └── prettier.cjs │ ├── index.cjs │ └── package.json │ └── typescript │ ├── bin │ ├── tsc │ └── tsserver │ ├── lib │ ├── tsc.js │ ├── tsserver.js │ ├── tsserverlibrary.js │ └── typescript.js │ └── package.json ├── README.md ├── package.json ├── packages ├── backend │ ├── .dockerignore │ ├── .eslintrc.js │ ├── .gitignore │ ├── .prettierrc │ ├── Dockerfile │ ├── README.md │ ├── git-challenge-quiz.csv │ ├── nest-cli.json │ ├── package.json │ ├── src │ │ ├── ai │ │ │ ├── ai.controller.spec.ts │ │ │ ├── ai.controller.ts │ │ │ ├── ai.module.ts │ │ │ ├── ai.service.spec.ts │ │ │ ├── ai.service.ts │ │ │ └── dto │ │ │ │ └── ai.dto.ts │ │ ├── app.controller.spec.ts │ │ ├── app.controller.ts │ │ ├── app.module.ts │ │ ├── app.service.ts │ │ ├── command │ │ │ ├── command.module.ts │ │ │ ├── command.service.spec.ts │ │ │ └── command.service.ts │ │ ├── common │ │ │ ├── command.guard.ts │ │ │ ├── execution-time.interceptor.ts │ │ │ ├── logging.interceptor.ts │ │ │ └── util.ts │ │ ├── configs │ │ │ └── typeorm.config.ts │ │ ├── containers │ │ │ ├── containers.module.ts │ │ │ ├── containers.service.spec.ts │ │ │ └── containers.service.ts │ │ ├── main.ts │ │ ├── quiz-wizard │ │ │ ├── magic.ts │ │ │ ├── quiz-wizard.module.ts │ │ │ └── quiz-wizard.service.ts │ │ ├── quizzes │ │ │ ├── dto │ │ │ │ ├── command-request.dto.ts │ │ │ │ ├── command-response.dto.ts │ │ │ │ ├── graph.dto.ts │ │ │ │ ├── quiz.dto.ts │ │ │ │ ├── quizzes.dto.ts │ │ │ │ ├── shared.dto.ts │ │ │ │ └── submit.dto.ts │ │ │ ├── entity │ │ │ │ ├── category.entity.ts │ │ │ │ ├── keyword.entity.ts │ │ │ │ └── quiz.entity.ts │ │ │ ├── quiz.guard.ts │ │ │ ├── quizzes.controller.spec.ts │ │ │ ├── quizzes.controller.ts │ │ │ ├── quizzes.module.ts │ │ │ ├── quizzes.service.spec.ts │ │ │ └── quizzes.service.ts │ │ └── session │ │ │ ├── dto │ │ │ └── solved.dto.ts │ │ │ ├── schema │ │ │ └── session.schema.ts │ │ │ ├── session-save.intercepter.ts │ │ │ ├── session.controller.spec.ts │ │ │ ├── session.controller.ts │ │ │ ├── session.decorator.ts │ │ │ ├── session.module.ts │ │ │ ├── session.service.spec.ts │ │ │ └── session.service.ts │ ├── test │ │ ├── api.e2e-spec.ts │ │ ├── app.e2e-spec.ts │ │ └── jest-e2e.json │ ├── tsconfig.build.json │ └── tsconfig.json └── frontend │ ├── .dockerignore │ ├── .eslintrc.json │ ├── .gitignore │ ├── .prettierrc │ ├── .storybook │ ├── main.ts │ └── preview.ts │ ├── Dockerfile │ ├── README.md │ ├── jest.config.mjs │ ├── next.config.js │ ├── package.json │ ├── public │ ├── dark-logo.svg │ ├── favicon │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── browserconfig.xml │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ ├── mstile-144x144.png │ │ ├── mstile-150x150.png │ │ ├── mstile-310x150.png │ │ ├── mstile-310x310.png │ │ ├── mstile-70x70.png │ │ ├── safari-pinned-tab.svg │ │ └── site.webmanifest │ ├── folder.svg │ ├── github.svg │ ├── light-logo.svg │ ├── mockServiceWorker.js │ ├── next.svg │ └── vercel.svg │ ├── src │ ├── apis │ │ ├── base.ts │ │ ├── quiz.ts │ │ └── session.ts │ ├── components │ │ ├── editor │ │ │ ├── Editor.css.ts │ │ │ ├── Editor.tsx │ │ │ ├── EditorInfo.tsx │ │ │ ├── __tests__ │ │ │ │ └── editor.spec.tsx │ │ │ ├── index.ts │ │ │ └── useTextareaCursor.ts │ │ ├── graph │ │ │ ├── Graph.tsx │ │ │ ├── data.ts │ │ │ ├── fillColor.ts │ │ │ ├── index.ts │ │ │ ├── parsing.ts │ │ │ └── renderTooltip.ts │ │ ├── landing │ │ │ ├── Book │ │ │ │ ├── Book.css.ts │ │ │ │ ├── Book.tsx │ │ │ │ └── data.ts │ │ │ ├── Landing.css.ts │ │ │ ├── Landing.tsx │ │ │ └── ServiceInfo │ │ │ │ ├── ServiceInfo.css.ts │ │ │ │ └── ServiceInfo.tsx │ │ ├── quiz │ │ │ ├── CommandAccordion │ │ │ │ ├── CommandAccordion.css.ts │ │ │ │ ├── CommandAccordion.tsx │ │ │ │ └── index.ts │ │ │ ├── QuizAnswerModal │ │ │ │ ├── QuizAnswerModal.css.ts │ │ │ │ ├── QuizAnswerModal.tsx │ │ │ │ └── index.ts │ │ │ ├── QuizContent │ │ │ │ ├── QuizContent.css.ts │ │ │ │ ├── QuizContent.tsx │ │ │ │ └── index.ts │ │ │ ├── QuizGuide │ │ │ │ ├── QuizGuide.css.ts │ │ │ │ ├── QuizGuide.tsx │ │ │ │ └── index.ts │ │ │ ├── QuizLocation │ │ │ │ ├── QuizLocation.css.ts │ │ │ │ ├── QuizLocation.tsx │ │ │ │ └── index.ts │ │ │ ├── SolvedModal │ │ │ │ ├── SolvedModal.css.ts │ │ │ │ ├── SolvedModal.tsx │ │ │ │ ├── index.ts │ │ │ │ └── useSolvedModal.ts │ │ │ └── index.ts │ │ └── terminal │ │ │ ├── CommandInput.tsx │ │ │ ├── Prompt.css.ts │ │ │ ├── Prompt.tsx │ │ │ ├── Terminal.css.ts │ │ │ ├── Terminal.tsx │ │ │ ├── TerminalContent.tsx │ │ │ └── index.ts │ ├── constants │ │ ├── event.ts │ │ └── path.ts │ ├── contexts │ │ └── UserQuizStatusContext │ │ │ ├── UserQuizStatusContext.tsx │ │ │ ├── index.ts │ │ │ └── type.ts │ ├── design-system │ │ ├── components │ │ │ └── common │ │ │ │ ├── Accordion │ │ │ │ ├── Accordion.css.ts │ │ │ │ ├── Accordion.tsx │ │ │ │ ├── AccordionContextProvider.tsx │ │ │ │ ├── AccordionDetails.tsx │ │ │ │ ├── AccordionSummary.tsx │ │ │ │ ├── ChevronIcon │ │ │ │ │ ├── ChevronIcon.css.ts │ │ │ │ │ ├── ChevronIcon.tsx │ │ │ │ │ └── index.ts │ │ │ │ └── index.ts │ │ │ │ ├── Badge │ │ │ │ ├── Badge.css.ts │ │ │ │ ├── Badge.tsx │ │ │ │ └── index.ts │ │ │ │ ├── Button │ │ │ │ ├── Button.css.ts │ │ │ │ ├── Button.stories.tsx │ │ │ │ ├── Button.tsx │ │ │ │ └── index.ts │ │ │ │ ├── CodeBlock │ │ │ │ ├── CodeBlock.css.ts │ │ │ │ ├── CodeBlock.tsx │ │ │ │ └── index.ts │ │ │ │ ├── Footer │ │ │ │ ├── Footer.css.ts │ │ │ │ ├── Footer.tsx │ │ │ │ └── index.ts │ │ │ │ ├── Header │ │ │ │ ├── Header.css.ts │ │ │ │ ├── Header.tsx │ │ │ │ └── index.ts │ │ │ │ ├── IconButton │ │ │ │ ├── IconButton.css.ts │ │ │ │ ├── IconButton.tsx │ │ │ │ └── index.ts │ │ │ │ ├── Info │ │ │ │ ├── Info.css.ts │ │ │ │ ├── Info.tsx │ │ │ │ └── index.ts │ │ │ │ ├── Layout │ │ │ │ ├── Layout.tsx │ │ │ │ └── index.ts │ │ │ │ ├── LinkButton │ │ │ │ ├── LinkButton.tsx │ │ │ │ └── index.ts │ │ │ │ ├── Modal │ │ │ │ ├── Modal.css.ts │ │ │ │ ├── Modal.tsx │ │ │ │ └── index.ts │ │ │ │ ├── SideBar │ │ │ │ ├── GitHelpAccordian.tsx │ │ │ │ ├── SideBar.css.ts │ │ │ │ ├── SideBar.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── nav.ts │ │ │ │ ├── Theme │ │ │ │ ├── ThemeContext.ts │ │ │ │ ├── ThemeSelect.css.ts │ │ │ │ ├── ThemeSelect.tsx │ │ │ │ └── ThemeWrapper.tsx │ │ │ │ ├── Toast │ │ │ │ ├── Toast.css.ts │ │ │ │ ├── ToastContainer.tsx │ │ │ │ ├── ToastIcon.css.ts │ │ │ │ ├── ToastIcon.tsx │ │ │ │ ├── index.ts │ │ │ │ └── toast.ts │ │ │ │ └── index.ts │ │ ├── styles │ │ │ ├── global.css │ │ │ └── reset.css │ │ └── tokens │ │ │ ├── color.ts │ │ │ ├── layout.css.ts │ │ │ ├── typography.ts │ │ │ └── utils.css.ts │ ├── hooks │ │ ├── useModal.ts │ │ ├── useMount.ts │ │ ├── useResizableSplitView.ts │ │ ├── useScroll │ │ │ ├── type.ts │ │ │ ├── useScrollClipPath.ts │ │ │ └── useScrollFadeIn.ts │ │ └── useTheme.ts │ ├── mocks │ │ ├── apis │ │ │ ├── data │ │ │ │ └── quizContentData.ts │ │ │ └── quizHandlers.ts │ │ ├── browser.ts │ │ ├── handlers.ts │ │ ├── index.ts │ │ └── server.ts │ ├── pages │ │ ├── _app.page.tsx │ │ ├── _document.page.tsx │ │ ├── api │ │ │ └── hello.ts │ │ ├── index.page.tsx │ │ ├── quizzes │ │ │ ├── [id].page.tsx │ │ │ └── quiz.css.ts │ │ └── share │ │ │ ├── [slug].page.tsx │ │ │ └── slug.css.ts │ ├── reducers │ │ └── terminalReducer │ │ │ ├── index.ts │ │ │ ├── terminalReducer.ts │ │ │ ├── type.ts │ │ │ └── util.ts │ ├── types │ │ ├── quiz.ts │ │ ├── terminalType.ts │ │ └── user.ts │ └── utils │ │ ├── __tests__ │ │ ├── classnames.spec.ts │ │ ├── mapper.spec.ts │ │ └── typeGuard.spec.ts │ │ ├── classList.ts │ │ ├── classnames.ts │ │ ├── event.ts │ │ ├── mapper.ts │ │ ├── refObject.ts │ │ ├── typeGuard.ts │ │ └── types.ts │ └── tsconfig.json ├── tsconfig.json └── yarn.lock /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | close # 2 | 3 | ## ✅ 작업 내용 4 | 5 | ## 📸 스크린샷(FE만) 6 | 7 | ## 📌 이슈 사항 8 | 9 | ## 🟢 완료 조건 10 | 11 | ## ✍ 궁금한 점 12 | -------------------------------------------------------------------------------- /.github/workflows/frontend-deploy.yml: -------------------------------------------------------------------------------- 1 | name: "frontend-docker-build" 2 | 3 | on: 4 | push: 5 | branches: [ "dev-fe" ] 6 | 7 | jobs: 8 | build: 9 | name: Build and Test 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | # 노드 버전 설정 및 의존성 설치 16 | - name: Set up Node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: '18.17.1' 20 | 21 | - name: Corepack Enable 22 | run: corepack enable 23 | 24 | - name: Install Dependencies 25 | run: yarn install 26 | 27 | - name: Build 28 | run: | 29 | cd packages/frontend 30 | yarn build 31 | 32 | docker: 33 | name: Deploy Docker Image 34 | runs-on: ubuntu-latest 35 | needs: build 36 | 37 | steps: 38 | - uses: actions/checkout@v3 39 | 40 | - name: Login to Docker Hub 41 | uses: docker/login-action@v2 42 | with: 43 | username: ${{ secrets.DOCKERHUB_USERNAME }} 44 | password: ${{ secrets.DOCKERHUB_TOKEN }} 45 | 46 | - name: Set up Docker Buildx 47 | uses: docker/setup-buildx-action@v2 48 | 49 | - name: Build and Push 50 | uses: docker/build-push-action@v3 51 | with: 52 | context: . 53 | file: ./packages/frontend/Dockerfile 54 | push: true 55 | tags: ${{ secrets.DOCKERHUB_USERNAME }}/git-challenge-frontend:0.1 56 | build-args: | 57 | NEXT_PUBLIC_BASE_URL=${{ secrets.NEXT_PUBLIC_BASE_URL }} 58 | 59 | deploy: 60 | name: Deploy Frontend 61 | runs-on: ubuntu-latest 62 | needs: docker 63 | steps: 64 | - name: SSH and Deploy 65 | uses: appleboy/ssh-action@master 66 | with: 67 | host: ${{ secrets.BACKEND_SSH_HOST }} 68 | username: ${{ secrets.BACKEND_SSH_USERNAME }} 69 | password: ${{ secrets.BACKEND_SSH_PASSWORD }} 70 | port: ${{ secrets.BACKEND_SSH_PORT }} 71 | script: | 72 | docker pull ${{ secrets.DOCKERHUB_USERNAME }}/git-challenge-frontend:0.1 73 | docker rm -f frontend || true 74 | docker run -d --name frontend -p 3000:3000 \ 75 | ${{ secrets.DOCKERHUB_USERNAME }}/git-challenge-frontend:0.1 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # misc 7 | .DS_Store 8 | *.pem 9 | .idea 10 | .obsidian 11 | .vscode 12 | 13 | .pnp.* 14 | .yarn/* 15 | !.yarn/patches 16 | !.yarn/plugins 17 | !.yarn/releases 18 | !.yarn/sdks 19 | !.yarn/versions 20 | 21 | .eslintcache 22 | 23 | .env 24 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | COMMIT_MESSAGE_FILE_PATH=$1 4 | MESSAGE=$(cat "$COMMIT_MESSAGE_FILE_PATH") 5 | 6 | # 커밋 메시지가 있을 때만 이슈 넘버를 추가한다. 7 | if [[ $(head -1 "$COMMIT_MESSAGE_FILE_PATH") == '' ]]; then 8 | exit 0 9 | fi 10 | 11 | ISSUE_NUMBER=$(git branch | grep '\*' | sed 's/* //' | sed 's/^.*\(#[0-9][0-9]*\).*$/\1/') 12 | 13 | # 이슈 넘버가 없으면 종료한다. 14 | if ! [[ ${ISSUE_NUMBER} =~ ^#[0-9][0-9]*$ ]]; then 15 | exit 0 16 | fi 17 | 18 | # 커밋 메시지에 이슈 넘버가 없을 때만 이슈 넘버를 추가한다. 19 | if [[ ${MESSAGE} =~ \[${ISSUE_NUMBER}\] ]]; then 20 | exit 0 21 | fi 22 | 23 | printf "%s\n\n[%s]" "${MESSAGE}" "${ISSUE_NUMBER}" > ${COMMIT_MESSAGE_FILE_PATH} 24 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 현재 브랜치의 이름을 가져오기 4 | current_branch=$(git rev-parse --abbrev-ref HEAD) 5 | 6 | if echo "$current_branch" | grep -q "fe"; then 7 | . "$(dirname -- "$0")/_/husky.sh" 8 | yarn workspace frontend run lint-staged 9 | fi 10 | 11 | if echo "$current_branch" | grep -q "be"; then 12 | . "$(dirname -- "$0")/_/husky.sh" 13 | yarn workspace backend run lint-staged 14 | fi -------------------------------------------------------------------------------- /.yarn/sdks/eslint/bin/eslint.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require eslint/bin/eslint.js 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real eslint/bin/eslint.js your application uses 20 | module.exports = absRequire(`eslint/bin/eslint.js`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/eslint/lib/api.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require eslint 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real eslint your application uses 20 | module.exports = absRequire(`eslint`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/eslint/lib/unsupported-api.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require eslint/use-at-your-own-risk 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real eslint/use-at-your-own-risk your application uses 20 | module.exports = absRequire(`eslint/use-at-your-own-risk`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/eslint/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint", 3 | "version": "8.53.0-sdk", 4 | "main": "./lib/api.js", 5 | "type": "commonjs", 6 | "bin": { 7 | "eslint": "./bin/eslint.js" 8 | }, 9 | "exports": { 10 | "./package.json": "./package.json", 11 | ".": "./lib/api.js", 12 | "./use-at-your-own-risk": "./lib/unsupported-api.js" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.yarn/sdks/integrations.yml: -------------------------------------------------------------------------------- 1 | # This file is automatically generated by @yarnpkg/sdks. 2 | # Manual changes might be lost! 3 | 4 | integrations: 5 | - vscode 6 | -------------------------------------------------------------------------------- /.yarn/sdks/prettier/bin/prettier.cjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require prettier/bin/prettier.cjs 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real prettier/bin/prettier.cjs your application uses 20 | module.exports = absRequire(`prettier/bin/prettier.cjs`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/prettier/index.cjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require prettier 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real prettier your application uses 20 | module.exports = absRequire(`prettier`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/prettier/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prettier", 3 | "version": "3.1.0-sdk", 4 | "main": "./index.cjs", 5 | "type": "commonjs", 6 | "bin": "./bin/prettier.cjs" 7 | } 8 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/bin/tsc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/bin/tsc 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/bin/tsc your application uses 20 | module.exports = absRequire(`typescript/bin/tsc`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/bin/tsserver: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/bin/tsserver 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/bin/tsserver your application uses 20 | module.exports = absRequire(`typescript/bin/tsserver`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/tsc.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/lib/tsc.js 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/lib/tsc.js your application uses 20 | module.exports = absRequire(`typescript/lib/tsc.js`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/typescript.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript your application uses 20 | module.exports = absRequire(`typescript`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript", 3 | "version": "5.0.0-beta-sdk", 4 | "main": "./lib/typescript.js", 5 | "type": "commonjs", 6 | "bin": { 7 | "tsc": "./bin/tsc", 8 | "tsserver": "./bin/tsserver" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageManager": "yarn@4.0.2", 3 | "devDependencies": { 4 | "eslint": "^8.53.0", 5 | "husky": "^8.0.0", 6 | "pinst": "^3.0.0", 7 | "prettier": "^3.1.0", 8 | "typescript": "5.0.0-beta" 9 | }, 10 | "scripts": { 11 | "postinstall": "husky install", 12 | "prepack": "pinst --disable", 13 | "postpack": "pinst --enable" 14 | }, 15 | "workspaces": [ 16 | "packages/*" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /packages/backend/.dockerignore: -------------------------------------------------------------------------------- 1 | # 버전 관리 시스템 2 | .git 3 | .gitignore 4 | 5 | # 노드 모듈 6 | node_modules 7 | 8 | # 로그 파일 9 | npm-debug.log 10 | yarn-error.log 11 | 12 | # 빌드 디렉토리 13 | dist 14 | build 15 | 16 | # 개발 도구 설정 17 | .editorconfig 18 | *.env 19 | *.env.local 20 | *.env.development.local 21 | *.env.test.local 22 | *.env.production.local 23 | 24 | # OS 관련 파일 25 | .DS_Store 26 | Thumbs.db 27 | -------------------------------------------------------------------------------- /packages/backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /packages/backend/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | .env 38 | 39 | *.sqlite -------------------------------------------------------------------------------- /packages/backend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /packages/backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18.17.1 2 | 3 | WORKDIR /app 4 | 5 | COPY . . 6 | 7 | RUN corepack enable 8 | RUN yarn install 9 | 10 | EXPOSE 8080 11 | 12 | CMD ["sh", "-c", "cd packages/backend && yarn run start"] 13 | -------------------------------------------------------------------------------- /packages/backend/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/backend/src/ai/ai.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AiController } from './ai.controller'; 3 | 4 | describe('AiController', () => { 5 | let controller: AiController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [AiController], 10 | }).compile(); 11 | 12 | controller = module.get(AiController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /packages/backend/src/ai/ai.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Post } from '@nestjs/common'; 2 | import { AiRequestDto, AiResponseDto } from './dto/ai.dto'; 3 | import { AiService } from './ai.service'; 4 | import { ApiOperation, ApiResponse } from '@nestjs/swagger'; 5 | 6 | @Controller('api/v1/ai') 7 | export class AiController { 8 | constructor(private readonly aiService: AiService) {} 9 | @Post() 10 | @ApiOperation({ summary: 'AI 답변을 받아옵니다.' }) 11 | @ApiResponse({ 12 | status: 200, 13 | description: 'AI 답변을 받아옵니다.', 14 | type: AiResponseDto, 15 | }) 16 | async ai(@Body() aiDto: AiRequestDto): Promise { 17 | return await this.aiService.getApiResponse(aiDto.message); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/backend/src/ai/ai.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AiController } from './ai.controller'; 3 | import { AiService } from './ai.service'; 4 | 5 | @Module({ 6 | controllers: [AiController], 7 | providers: [AiService], 8 | }) 9 | export class AiModule {} 10 | -------------------------------------------------------------------------------- /packages/backend/src/ai/ai.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AiService } from './ai.service'; 3 | 4 | describe('AiService', () => { 5 | let service: AiService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [AiService], 10 | }).compile(); 11 | 12 | service = module.get(AiService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /packages/backend/src/ai/ai.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import axios from 'axios'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import { AiResponseDto } from './dto/ai.dto'; 5 | import { Logger } from 'winston'; 6 | import { preview } from '../common/util'; 7 | 8 | @Injectable() 9 | export class AiService { 10 | private readonly headers = { 11 | 'Content-Type': 'application/json', 12 | Accept: 'application/json', 13 | }; 14 | private readonly instance = axios.create({ 15 | baseURL: 16 | 'https://clovastudio.stream.ntruss.com/testapp/v1/chat-completions/HCX-002', 17 | timeout: 50000, 18 | headers: this.headers, 19 | }); 20 | 21 | constructor( 22 | private configService: ConfigService, 23 | @Inject('winston') private readonly logger: Logger, 24 | ) { 25 | this.instance.interceptors.request.use((config) => { 26 | config.headers['X-NCP-CLOVASTUDIO-API-KEY'] = this.configService.get( 27 | 'X_NCP_CLOVASTUDIO_API_KEY', 28 | ); 29 | config.headers['X-NCP-APIGW-API-KEY'] = this.configService.get( 30 | 'X_NCP_APIGW_API_KEY', 31 | ); 32 | config.headers['X-NCP-CLOVASTUDIO-REQUEST-ID'] = this.configService.get( 33 | 'X_NCP_CLOVASTUDIO_REQUEST_ID', 34 | ); 35 | return config; 36 | }); 37 | } 38 | async getApiResponse(message: string): Promise { 39 | const response = await this.instance.post('/', { 40 | messages: [ 41 | { 42 | role: 'system', 43 | content: 44 | '- Git 전문가입니다.\\n- Git에 대한 질문만 대답합니다.\\n- Git 사용이 낯선 사람들에게 질문을 받습니다.\\n- Git 설치는 이미 마쳤습니다.\\n- 설명은 이해하기 쉽게 명료하고 간단하게 명령어 위주로 대답합니다.\\n- 질문한 것만 대답합니다.\\n- Git 명령어로만 해답을 제시합니다.\\n- 예를 들어 설명하지 않는다.', 45 | }, 46 | { 47 | role: 'user', 48 | content: message, 49 | }, 50 | ], 51 | topP: 0.8, 52 | topK: 0, 53 | maxTokens: 512, 54 | temperature: 0.3, 55 | repeatPenalty: 5.0, 56 | stopBefore: [], 57 | includeAiFilters: true, 58 | }); 59 | 60 | this.logger.log( 61 | 'info', 62 | `AI response: ${preview(response.data.result.message.content)}`, 63 | ); 64 | 65 | return { message: response.data.result.message.content }; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /packages/backend/src/ai/dto/ai.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class AiRequestDto { 4 | @ApiProperty({ 5 | description: '질문할 내용', 6 | example: 'git이 뭐야?', 7 | }) 8 | message: string; 9 | } 10 | 11 | export class AiResponseDto { 12 | @ApiProperty({ 13 | description: '답변 내용', 14 | example: 15 | 'Git은 분산 버전 관리 시스템(Distributed Version Control System)으로, 소스 코드의 버전을 관리하고 협업을 지원하는 도구입니다. Git은 다음과 같은 특징을 가지고 있습니다.\\n\\n1. **분산 저장소**: Git은 중앙 집중식 저장소가 아닌 분산 저장소를 사용합니다. 각 사용자는 자신의 컴퓨터에 저장소를 가지고 있으며, 이를 로컬 저장소라고 합니다.\\n2. **빠른 속도**: Git은 빠른 속도로 파일을 처리할 수 있습니다. 이는 Git이 데이터를 압축하여 저장하고, 해시 함수를 이용하여 파일을 빠르게 검색하기 때문입니다.\\n3. **버전 관리**: Git은 소스 코드의 버전을 관리합니다. 사용자는 파일을 수정하고 커밋(commit)하면, 해당 파일의 이전 버전과 이후 버전을 모두 저장할 수 있습니다.\\n4. **협업 지원**: Git은 협업을 지원합니다. 사용자는 다른 사용자와 함께 작업을 할 수 있으며, 서로의 작업 내용을 공유할 수 있습니다.\\n5. **명령어 기반**: Git은 명령어 기반으로 동작합니다. 사용자는 Git 명령어를 입력하여 저장소를 관리하고, 파일을 수정할 수 있습니다.\\n\\nGit은 다양한 프로그래밍 언어와 운영체제에서 사용할 수 있으며, 많은 개발자들이 Git을 이용하여 소스 코드를 관리하고 있습니다.', 16 | }) 17 | message: string; 18 | } 19 | -------------------------------------------------------------------------------- /packages/backend/src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | 5 | describe('AppController', () => { 6 | let appController: AppController; 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | providers: [AppService], 12 | }).compile(); 13 | 14 | appController = app.get(AppController); 15 | }); 16 | 17 | describe('root', () => { 18 | it('should return "Hello World!"', () => { 19 | expect(appController.getHello()).toBe('Hello World!'); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /packages/backend/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | 4 | @Controller() 5 | export class AppController { 6 | constructor(private readonly appService: AppService) {} 7 | 8 | // @Get() 9 | // getHello(): string { 10 | // return this.appService.getHello(); 11 | // } 12 | } 13 | -------------------------------------------------------------------------------- /packages/backend/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { ConfigModule } from '@nestjs/config'; 6 | import { WinstonModule } from 'nest-winston'; 7 | import * as winston from 'winston'; 8 | import DailyRotateFile from 'winston-daily-rotate-file'; 9 | import { format } from 'winston'; 10 | import { typeOrmConfig } from './configs/typeorm.config'; 11 | import { QuizzesModule } from './quizzes/quizzes.module'; 12 | import { LoggingInterceptor } from './common/logging.interceptor'; 13 | import { QuizWizardModule } from './quiz-wizard/quiz-wizard.module'; 14 | import { AiModule } from './ai/ai.module'; 15 | import { CommandModule } from './command/command.module'; 16 | 17 | @Module({ 18 | imports: [ 19 | TypeOrmModule.forRoot(typeOrmConfig), 20 | QuizzesModule, 21 | ConfigModule.forRoot({ 22 | isGlobal: true, 23 | }), 24 | WinstonModule.forRoot({ 25 | transports: [ 26 | new winston.transports.Console(), 27 | new DailyRotateFile({ 28 | filename: 'logs/backend-application-%DATE%.log', 29 | datePattern: 'YYYY-MM-DD', 30 | zippedArchive: true, 31 | maxSize: '20m', 32 | maxFiles: '14d', 33 | }), 34 | ], 35 | format: format.combine( 36 | format.timestamp({ 37 | format: 'YYYY-MM-DD HH:mm:ss', 38 | }), 39 | format.printf( 40 | (info) => `${info.timestamp} ${info.level}: ${info.message}`, 41 | ), 42 | ), 43 | }), 44 | QuizWizardModule, 45 | AiModule, 46 | CommandModule, 47 | ], 48 | controllers: [AppController], 49 | providers: [ 50 | AppService, 51 | { 52 | provide: 'APP_INTERCEPTOR', 53 | useClass: LoggingInterceptor, 54 | }, 55 | ], 56 | }) 57 | export class AppModule {} 58 | -------------------------------------------------------------------------------- /packages/backend/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | getHello(): string { 6 | return 'Hello World!'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/backend/src/command/command.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { CommandService } from './command.service'; 3 | 4 | @Module({ 5 | providers: [CommandService], 6 | exports: [CommandService], 7 | }) 8 | export class CommandModule {} 9 | -------------------------------------------------------------------------------- /packages/backend/src/command/command.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { CommandService } from './command.service'; 3 | 4 | describe('CommandService', () => { 5 | let service: CommandService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [CommandService], 10 | }).compile(); 11 | 12 | service = module.get(CommandService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /packages/backend/src/command/command.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import axios from 'axios'; 4 | import { Logger } from 'winston'; 5 | import { preview, processCarriageReturns } from '../common/util'; 6 | 7 | @Injectable() 8 | export class CommandService { 9 | private readonly host: string; 10 | private readonly instance; 11 | constructor( 12 | private readonly configService: ConfigService, 13 | @Inject('winston') private readonly logger: Logger, 14 | ) { 15 | this.host = this.configService.get('CONTAINER_SERVER_HOST'); 16 | this.instance = axios.create({ 17 | baseURL: this.host, 18 | timeout: 10000, 19 | }); 20 | } 21 | 22 | async executeCommand( 23 | ...commands: string[] 24 | ): Promise<{ stdoutData: string; stderrData: string }> { 25 | try { 26 | const command = commands.join('; '); 27 | this.logger.log('info', `command: ${preview(command, 40)}`); 28 | const response = await this.instance.post('/', { command }); 29 | return { 30 | stdoutData: processCarriageReturns(response.data.stdoutData), 31 | stderrData: processCarriageReturns(response.data.stderrData), 32 | }; 33 | } catch (error) { 34 | this.logger.log('info', error); 35 | } 36 | } 37 | 38 | async executeCron( 39 | ...commands: string[] 40 | ): Promise<{ stdoutData: string; stderrData: string }> { 41 | try { 42 | const command = commands.join('; '); 43 | this.logger.log('info', `command: ${preview(command, 40)}`); 44 | const response = await this.instance.post('/cron', { command }); 45 | return response.data; 46 | } catch (error) { 47 | this.logger.log('info', error); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/backend/src/common/command.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | ForbiddenException, 5 | Injectable, 6 | } from '@nestjs/common'; 7 | 8 | @Injectable() 9 | export class CommandGuard implements CanActivate { 10 | canActivate(context: ExecutionContext): boolean { 11 | const request = context.switchToHttp().getRequest(); 12 | const mode = request.body['mode']; 13 | const message = request.body['message']; 14 | if ( 15 | !( 16 | typeof mode === 'string' && 17 | typeof message === 'string' && 18 | (mode === 'editor' || 19 | (mode === 'command' && 20 | message.startsWith('git') && 21 | !this.isMessageIncluded(message, [ 22 | ';', 23 | '>', 24 | '|', 25 | '<', 26 | '&', 27 | '$', 28 | '(', 29 | ')', 30 | '{', 31 | '}', 32 | ]))) 33 | ) 34 | ) { 35 | throw new ForbiddenException('금지된 명령입니다'); 36 | } 37 | return true; 38 | } 39 | private isMessageIncluded(message: string, keywords: string[]): boolean { 40 | return keywords.some((keyword) => message.includes(keyword)); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/backend/src/common/execution-time.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | NestInterceptor, 4 | ExecutionContext, 5 | CallHandler, 6 | } from '@nestjs/common'; 7 | import { Observable } from 'rxjs'; 8 | import { tap } from 'rxjs/operators'; 9 | import { performance } from 'perf_hooks'; 10 | 11 | @Injectable() 12 | export class ExecutionTimeInterceptor implements NestInterceptor { 13 | intercept(context: ExecutionContext, next: CallHandler): Observable { 14 | const start = performance.now(); 15 | const methodName = context.getHandler().name; // 현재 실행 중인 함수의 이름을 얻습니다. 16 | const className = context.getClass().name; // 현재 실행 중인 클래스의 이름을 얻습니다. 17 | 18 | return next.handle().pipe( 19 | tap(() => { 20 | const duration = performance.now() - start; 21 | console.log( 22 | `${className}.${methodName} 실행 시간: ${duration.toFixed(2)} 밀리초`, 23 | ); 24 | }), 25 | ); 26 | } 27 | } 28 | 29 | // 커스텀 데코레이터 정의 30 | export function MeasureExecutionTime() { 31 | return function ( 32 | target: any, 33 | propertyKey: string, 34 | descriptor: PropertyDescriptor, 35 | ) { 36 | const originalMethod = descriptor.value; 37 | 38 | descriptor.value = async function (...args: any[]) { 39 | const start = performance.now(); 40 | const result = await originalMethod.apply(this, args); 41 | const duration = performance.now() - start; 42 | console.log( 43 | `${propertyKey} 함수 실행 시간: ${duration.toFixed(2)} 밀리초`, 44 | ); 45 | return result; 46 | }; 47 | 48 | return descriptor; 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /packages/backend/src/common/logging.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | NestInterceptor, 4 | ExecutionContext, 5 | CallHandler, 6 | Inject, 7 | } from '@nestjs/common'; 8 | import { Observable } from 'rxjs'; 9 | import { Logger } from 'winston'; 10 | 11 | @Injectable() 12 | export class LoggingInterceptor implements NestInterceptor { 13 | constructor(@Inject('winston') private readonly logger: Logger) {} 14 | 15 | intercept(context: ExecutionContext, next: CallHandler): Observable { 16 | const request = context.switchToHttp().getRequest(); 17 | const method = request.method; 18 | const url = request.url; 19 | const sessionId = request.cookies?.sessionId || "(it's new session)"; 20 | 21 | this.logger.log( 22 | 'info', 23 | `Request ${method} ${url} from session: ${sessionId}`, 24 | ); 25 | 26 | return next.handle(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/backend/src/common/util.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'crypto'; 2 | import dotenv from 'dotenv'; 3 | 4 | dotenv.config(); 5 | 6 | export function preview(message: string, length?: number): string { 7 | return message.length > 15 8 | ? message.slice(0, length ? length : 20) + '...' 9 | : message; 10 | } 11 | 12 | export function processCarriageReturns(data: string) { 13 | return data 14 | .split('\n') 15 | .map((line) => { 16 | const carriageReturnIndex = line.lastIndexOf('\r'); 17 | return carriageReturnIndex !== -1 18 | ? line.substring(carriageReturnIndex + 1) 19 | : line; 20 | }) 21 | .join('\n'); 22 | } 23 | 24 | const algorithm = 'aes-256-cbc'; 25 | const secretKey = process.env.SECRET_KEY; 26 | const initializeVector = crypto.randomBytes(16); 27 | 28 | export function encryptObject(obj: any): string { 29 | const cipher = crypto.createCipheriv( 30 | algorithm, 31 | Buffer.from(secretKey), 32 | initializeVector, 33 | ); 34 | let encrypted = cipher.update(JSON.stringify(obj)); 35 | encrypted = Buffer.concat([encrypted, cipher.final()]); 36 | return `${initializeVector.toString('hex')}:${encrypted.toString('hex')}`; 37 | } 38 | 39 | export function decryptObject(encrypted: string): any { 40 | const [iv, encryptedText] = encrypted 41 | .split(':') 42 | .map((part) => Buffer.from(part, 'hex')); 43 | const decipher = crypto.createDecipheriv( 44 | algorithm, 45 | Buffer.from(secretKey), 46 | iv, 47 | ); 48 | let decrypted = decipher.update(encryptedText); 49 | decrypted = Buffer.concat([decrypted, decipher.final()]); 50 | return JSON.parse(decrypted.toString()); 51 | } 52 | 53 | export function isStringArray(obj: unknown): obj is string[] { 54 | return ( 55 | Array.isArray(obj) && obj.every((element) => typeof element === 'string') 56 | ); 57 | } 58 | 59 | export function graphParser(graph: string) { 60 | const lines = graph.split('\n'); 61 | const graphParsed: object[] = []; 62 | if (!graph) { 63 | return graphParsed; 64 | } 65 | for (let i = 0; i < lines.length; i += 4) { 66 | const id = lines[i]; 67 | const parentId = lines[i + 1] || ''; 68 | const message = lines[i + 2] || ''; 69 | const refs = lines[i + 3] || ''; 70 | graphParsed.push({ id, parentId, message, refs }); 71 | } 72 | return graphParsed; 73 | } 74 | -------------------------------------------------------------------------------- /packages/backend/src/configs/typeorm.config.ts: -------------------------------------------------------------------------------- 1 | import { TypeOrmModuleOptions } from '@nestjs/typeorm'; 2 | 3 | export const typeOrmConfig: TypeOrmModuleOptions = { 4 | type: 'sqlite', 5 | database: 'db.sqlite', 6 | entities: [__dirname + '/../**/entity/*.entity.{js,ts}'], 7 | synchronize: true, 8 | }; 9 | -------------------------------------------------------------------------------- /packages/backend/src/containers/containers.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ContainersService } from './containers.service'; 3 | import { CommandModule } from '../command/command.module'; 4 | 5 | @Module({ 6 | imports: [CommandModule], 7 | providers: [ContainersService], 8 | exports: [ContainersService], 9 | }) 10 | export class ContainersModule {} 11 | -------------------------------------------------------------------------------- /packages/backend/src/containers/containers.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ContainersService } from './containers.service'; 3 | 4 | describe('ContainersService', () => { 5 | let service: ContainersService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [ContainersService], 10 | }).compile(); 11 | 12 | service = module.get(ContainersService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /packages/backend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; 3 | import { AppModule } from './app.module'; 4 | import cookieParser from 'cookie-parser'; 5 | import fs from 'fs'; 6 | 7 | async function bootstrap() { 8 | const dbPath = 'db.sqlite'; 9 | 10 | // DB 파일이 존재하면 삭제 11 | if (fs.existsSync(dbPath)) { 12 | fs.unlinkSync(dbPath); 13 | } 14 | 15 | const app = await NestFactory.create(AppModule); 16 | 17 | app.use(cookieParser()); 18 | 19 | const config = new DocumentBuilder() 20 | .setTitle("Merge Masters' Git Challenge API") 21 | .setDescription('Git Challenge의 API 설명서입니다! 파이팅!') 22 | .setVersion('1.0') 23 | .addTag('quizzes') 24 | .build(); 25 | 26 | const document = SwaggerModule.createDocument(app, config); 27 | SwaggerModule.setup('api', app, document); 28 | 29 | await app.listen(8080); 30 | } 31 | bootstrap(); 32 | -------------------------------------------------------------------------------- /packages/backend/src/quiz-wizard/quiz-wizard.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { QuizWizardService } from './quiz-wizard.service'; 3 | import { Magic } from './magic'; 4 | import { CommandModule } from '../command/command.module'; 5 | 6 | @Module({ 7 | imports: [CommandModule], 8 | providers: [QuizWizardService, Magic], 9 | exports: [QuizWizardService], 10 | }) 11 | export class QuizWizardModule {} 12 | -------------------------------------------------------------------------------- /packages/backend/src/quizzes/dto/command-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export const MODE = { 4 | COMMAND: 'command', 5 | EDITOR: 'editor', 6 | } as const; 7 | 8 | type ModeType = (typeof MODE)[keyof typeof MODE]; 9 | 10 | export class CommandRequestDto { 11 | @ApiProperty({ 12 | description: 13 | '실행할 명령 모드. 예: "command" (명령 실행), "editor" (에디터 명령)', 14 | example: 'command', 15 | enum: Object.values(MODE), 16 | }) 17 | mode: ModeType; 18 | 19 | @ApiProperty({ 20 | description: 21 | '실행할 명령문 or 에디터 작성 본문. 예: "git status (명령 실행), "feat: tmp.js 파일 제거" (에디터 명령)', 22 | example: 'git status', 23 | }) 24 | message: string; 25 | } 26 | -------------------------------------------------------------------------------- /packages/backend/src/quizzes/dto/command-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export const RESULT = { 4 | SUCCESS: 'success', 5 | FAIL: 'fail', 6 | EDITOR: 'editor', 7 | } as const; 8 | 9 | type ResultType = (typeof RESULT)[keyof typeof RESULT]; 10 | 11 | export class CommandResponseDto { 12 | @ApiProperty({ 13 | description: '실행한 stdout/stderr 결과', 14 | example: '* main\n', 15 | }) 16 | message: string; 17 | 18 | @ApiProperty({ 19 | description: `실행 결과 요약(stdout => "success", stderr => "fail", 에디터 사용 => "editor")`, 20 | example: 'success', 21 | }) 22 | result: ResultType; 23 | 24 | @ApiProperty({ 25 | description: 'git 그래프 상황', 26 | example: 27 | '[\n' + 28 | ' {\n' + 29 | ' "id": "0b6bb091c739e7aec2cb724378d50e486a914768",\n' + 30 | ' "parentId": "",\n' + 31 | ' "message": "docs: plan.md",\n' + 32 | ' "refs": "HEAD -> main"\n' + 33 | ' }\n' + 34 | ']\n', 35 | }) 36 | graph?: object[]; 37 | 38 | @ApiProperty({ 39 | description: '현재 브랜치(reference)위치', 40 | example: 'main', 41 | }) 42 | ref: string; 43 | } 44 | export class ForbiddenResponseDto { 45 | @ApiProperty({ 46 | description: '금지된 명령이거나, editor를 연속으로 사용했을때', 47 | example: '금지된 명령입니다', 48 | }) 49 | message: string; 50 | 51 | @ApiProperty({ 52 | description: `Forbidden`, 53 | example: 'Forbidden', 54 | }) 55 | error: string; 56 | 57 | @ApiProperty({ 58 | description: `statusCode`, 59 | example: 403, 60 | }) 61 | statusCode: number; 62 | } 63 | -------------------------------------------------------------------------------- /packages/backend/src/quizzes/dto/graph.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class GraphDto { 4 | @ApiProperty({ 5 | description: 'git 그래프 상황', 6 | example: 7 | '[\n' + 8 | ' {\n' + 9 | ' "id": "0b6bb091c739e7aec2cb724378d50e486a914768",\n' + 10 | ' "parentId": "",\n' + 11 | ' "message": "docs: plan.md",\n' + 12 | ' "refs": "HEAD -> main"\n' + 13 | ' }\n' + 14 | ']\n', 15 | }) 16 | graph: object[]; 17 | 18 | @ApiProperty({ 19 | description: '현재 브랜치(reference)위치', 20 | example: 'main', 21 | }) 22 | ref: string; 23 | } 24 | -------------------------------------------------------------------------------- /packages/backend/src/quizzes/dto/quiz.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsInt, IsString, IsArray } from 'class-validator'; 3 | 4 | export class QuizDto { 5 | @ApiProperty({ description: '문제 ID', example: 3 }) 6 | @IsInt() 7 | readonly id: number; 8 | 9 | @IsString() 10 | @ApiProperty({ description: '문제 제목', example: 'git add & git status' }) 11 | readonly title: string; 12 | 13 | @IsString() 14 | @ApiProperty({ 15 | description: '문제 내용', 16 | example: 17 | '현재 변경된 파일 중에서 `achitecture.md` 파일을 제외하고 staging 해주세요.', 18 | }) 19 | readonly description: string; 20 | 21 | @IsArray() 22 | @IsString({ each: true }) 23 | @ApiProperty({ description: '문제 핵심 키워드', example: ['add', 'status'] }) 24 | readonly keywords: string[]; 25 | 26 | @IsString() 27 | @ApiProperty({ description: '문제 카테고리', example: 'Git Start' }) 28 | readonly category: string; 29 | 30 | @IsString() 31 | @ApiProperty({ 32 | description: '모범 답안', 33 | example: ['`git` status', '`git` add README.md docs/plan.md'], 34 | }) 35 | readonly answer: string[]; 36 | } 37 | -------------------------------------------------------------------------------- /packages/backend/src/quizzes/dto/quizzes.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsArray, IsInt, IsString, ValidateNested } from 'class-validator'; 3 | import { Type } from 'class-transformer'; 4 | 5 | export class QuizDto { 6 | @IsInt() 7 | id: number; 8 | 9 | @IsString() 10 | title: string; 11 | } 12 | 13 | export class CategoryQuizzesDto { 14 | @IsInt() 15 | id: number; 16 | 17 | @IsString() 18 | category: string; 19 | 20 | @IsArray() 21 | @ValidateNested({ each: true }) 22 | @Type(() => QuizDto) 23 | quizzes: QuizDto[]; 24 | } 25 | 26 | export class QuizzesDto { 27 | @IsArray() 28 | @ValidateNested({ each: true }) 29 | @Type(() => CategoryQuizzesDto) 30 | @ApiProperty({ 31 | description: '문제 제목 리스트', 32 | example: [ 33 | { 34 | id: 1, 35 | category: 'Git Start', 36 | quizzes: [ 37 | { id: 1, title: 'git init' }, 38 | { id: 2, title: 'git config' }, 39 | { id: 3, title: 'git add & git status' }, 40 | ], 41 | }, 42 | { 43 | id: 2, 44 | category: 'Git Advanced', 45 | quizzes: [{ id: 4, title: 'git commit --amend' }], 46 | }, 47 | ], 48 | }) 49 | categories: CategoryQuizzesDto[]; 50 | } 51 | 52 | export class NotFoundResponseDto { 53 | @ApiProperty({ 54 | description: '없는 문제를 조회했을때', 55 | example: 'Quiz 1212 not found', 56 | }) 57 | message: string; 58 | 59 | @ApiProperty({ 60 | description: `Not Found`, 61 | example: 'Not Found', 62 | }) 63 | error: string; 64 | 65 | @ApiProperty({ 66 | description: `statusCode`, 67 | example: 404, 68 | }) 69 | statusCode: number; 70 | } 71 | -------------------------------------------------------------------------------- /packages/backend/src/quizzes/dto/shared.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsArray } from 'class-validator'; 3 | import { QuizDto } from './quiz.dto'; 4 | 5 | export class Decrypted { 6 | readonly id: string; 7 | readonly commands: string[]; 8 | } 9 | 10 | export function isDecrypted(obj: unknown): obj is Decrypted { 11 | const isObject = (val: unknown): val is { [key: string]: unknown } => 12 | typeof val === 'object' && val !== null; 13 | 14 | if (!isObject(obj)) { 15 | return false; 16 | } 17 | 18 | return ( 19 | typeof obj.id === 'string' && 20 | Array.isArray(obj.commands) && 21 | obj.commands.every((cmd) => typeof cmd === 'string') 22 | ); 23 | } 24 | 25 | export class SharedDto { 26 | @ApiProperty({ 27 | description: '공유받은 답안', 28 | example: '["git status", "git add docs/plan.md"]', 29 | }) 30 | @IsArray() 31 | readonly answer: string[]; 32 | 33 | @ApiProperty({ 34 | description: '공유받은 답안의 문제 상황', 35 | example: { 36 | id: 3, 37 | title: 'git add & git status', 38 | description: 39 | '현재 변경된 파일 중에서 `achitecture.md` 파일을 제외하고 staging 해주세요.', 40 | keywords: ['add', 'status'], 41 | category: 'Git Start', 42 | }, 43 | }) 44 | readonly quiz: QuizDto; 45 | 46 | constructor(answer: string[], quiz: QuizDto) { 47 | this.answer = answer; 48 | this.quiz = quiz; 49 | } 50 | } 51 | 52 | export class BadRequestResponseDto { 53 | @ApiProperty({ 54 | description: 55 | '제공된 암호화된 문자열이 유효하지 않거나, 복호화에 실패했습니다.', 56 | example: '공유된 문제가 올바르지 않습니다.', 57 | }) 58 | message: string; 59 | 60 | @ApiProperty({ 61 | description: `Bad Request`, 62 | example: 'Bad Request', 63 | }) 64 | error?: string; 65 | 66 | @ApiProperty({ 67 | description: `statusCode`, 68 | example: 400, 69 | }) 70 | statusCode: number; 71 | 72 | constructor(message: string, error?: string) { 73 | this.message = message; 74 | this.statusCode = 400; 75 | this.error = error; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /packages/backend/src/quizzes/dto/submit.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsBoolean, IsString } from 'class-validator'; 3 | 4 | export class Success { 5 | @ApiProperty({ example: true }) 6 | @IsBoolean() 7 | solved = true; 8 | 9 | @ApiProperty({ 10 | description: '인코딩된 문제 풀이 과정과 문제 번호', 11 | example: '6251ee88d6e378b5d6b862447d151dab:aa88c19acf3da6(좀 더 길어요)', 12 | }) 13 | @IsString() 14 | slug: string; 15 | 16 | constructor(slug: string) { 17 | this.slug = slug; 18 | } 19 | } 20 | 21 | export class Fail { 22 | @ApiProperty({ example: false }) 23 | solved = false; 24 | } 25 | 26 | export type SubmitDto = Success | Fail; 27 | -------------------------------------------------------------------------------- /packages/backend/src/quizzes/entity/category.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm'; 2 | import { Quiz } from './quiz.entity'; 3 | 4 | @Entity() 5 | export class Category { 6 | @PrimaryGeneratedColumn() 7 | id: number; 8 | 9 | @Column() 10 | name: string; 11 | 12 | @OneToMany(() => Quiz, (quiz) => quiz.category) 13 | quizzes: Quiz[]; 14 | } 15 | -------------------------------------------------------------------------------- /packages/backend/src/quizzes/entity/keyword.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | Column, 4 | Entity, 5 | ManyToMany, 6 | PrimaryGeneratedColumn, 7 | } from 'typeorm'; 8 | import { Quiz } from './quiz.entity'; 9 | 10 | @Entity() 11 | export class Keyword extends BaseEntity { 12 | @PrimaryGeneratedColumn() 13 | id: number; 14 | 15 | @Column() 16 | keyword: string; 17 | 18 | @ManyToMany(() => Quiz, (quiz) => quiz.keywords) 19 | quizzes: Quiz[]; 20 | } 21 | -------------------------------------------------------------------------------- /packages/backend/src/quizzes/entity/quiz.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | PrimaryGeneratedColumn, 4 | Column, 5 | BaseEntity, 6 | JoinTable, 7 | ManyToMany, 8 | ManyToOne, 9 | } from 'typeorm'; 10 | import { Category } from './category.entity'; 11 | import { Keyword } from './keyword.entity'; 12 | 13 | @Entity() 14 | export class Quiz extends BaseEntity { 15 | @PrimaryGeneratedColumn() 16 | id: number; 17 | 18 | @Column() 19 | title: string; 20 | 21 | @Column() 22 | description: string; 23 | 24 | @ManyToOne(() => Category, (category) => category.quizzes) 25 | category: Category; 26 | 27 | @ManyToMany(() => Keyword, (keyword) => keyword.quizzes) 28 | @JoinTable() 29 | keywords: Keyword[]; 30 | 31 | @Column() 32 | answer: string; 33 | 34 | @Column() 35 | graph: string; 36 | 37 | @Column() 38 | ref: string; 39 | } 40 | -------------------------------------------------------------------------------- /packages/backend/src/quizzes/quiz.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | Injectable, 5 | NotFoundException, 6 | } from '@nestjs/common'; 7 | import { QuizzesService } from './quizzes.service'; 8 | 9 | @Injectable() 10 | export class QuizGuard implements CanActivate { 11 | constructor(private readonly quizService: QuizzesService) {} 12 | async canActivate(context: ExecutionContext): Promise { 13 | const request = context.switchToHttp().getRequest(); 14 | const quizId = request.params.id; 15 | if (!(await this.quizService.isQuizExist(quizId))) { 16 | throw new NotFoundException(`Quiz ${quizId} not found`); 17 | } 18 | return true; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/backend/src/quizzes/quizzes.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { QuizzesController } from './quizzes.controller'; 3 | 4 | describe('QuizzesController', () => { 5 | let controller: QuizzesController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [QuizzesController], 10 | }).compile(); 11 | 12 | controller = module.get(QuizzesController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /packages/backend/src/quizzes/quizzes.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { QuizzesController } from './quizzes.controller'; 3 | import { QuizzesService } from './quizzes.service'; 4 | import { Quiz } from './entity/quiz.entity'; 5 | import { TypeOrmModule } from '@nestjs/typeorm'; 6 | import { Category } from './entity/category.entity'; 7 | import { ContainersModule } from '../containers/containers.module'; 8 | import { SessionModule } from '../session/session.module'; 9 | import { Keyword } from './entity/keyword.entity'; 10 | import { QuizWizardModule } from '../quiz-wizard/quiz-wizard.module'; 11 | 12 | @Module({ 13 | imports: [ 14 | TypeOrmModule.forFeature([Quiz, Category, Keyword]), 15 | ContainersModule, 16 | SessionModule, 17 | QuizWizardModule, 18 | ], 19 | controllers: [QuizzesController], 20 | providers: [QuizzesService], 21 | }) 22 | export class QuizzesModule {} 23 | -------------------------------------------------------------------------------- /packages/backend/src/quizzes/quizzes.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { QuizzesService } from './quizzes.service'; 3 | 4 | describe('QuizzesService', () => { 5 | let service: QuizzesService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [QuizzesService], 10 | }).compile(); 11 | 12 | service = module.get(QuizzesService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /packages/backend/src/session/dto/solved.dto.ts: -------------------------------------------------------------------------------- 1 | const QUIZ_COUNT = 19; 2 | export class SolvedDto { 3 | [key: number]: boolean; 4 | 5 | constructor() { 6 | for (let i = 1; i <= QUIZ_COUNT; i++) { 7 | this[i] = false; 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/backend/src/session/schema/session.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { Document } from 'mongoose'; 3 | 4 | const Action = { 5 | Command: 'command', 6 | Editor: 'editor', 7 | } as const; 8 | 9 | export type ActionType = (typeof Action)[keyof typeof Action]; 10 | 11 | @Schema({ timestamps: true }) 12 | export class Session extends Document { 13 | @Prop() 14 | deletedAt: Date | null; 15 | 16 | @Prop({ 17 | required: true, 18 | type: Map, 19 | of: { 20 | status: { type: String, required: true }, 21 | logs: { 22 | type: [ 23 | { 24 | mode: { type: String, enum: Object.values(Action), required: true }, 25 | message: { type: String, required: true }, 26 | }, 27 | ], 28 | required: true, 29 | }, 30 | containerId: { type: String, default: '' }, 31 | graph: { type: String, default: '' }, 32 | ref: { type: String, default: '' }, 33 | }, 34 | }) 35 | problems: Map< 36 | number, 37 | { 38 | status: string; 39 | logs: { 40 | mode: ActionType; 41 | message: string; 42 | }[]; 43 | containerId: string; 44 | graph: string; 45 | ref: string; 46 | } 47 | >; 48 | } 49 | 50 | export const SessionSchema = SchemaFactory.createForClass(Session); 51 | -------------------------------------------------------------------------------- /packages/backend/src/session/session-save.intercepter.ts: -------------------------------------------------------------------------------- 1 | import { tap } from 'rxjs/operators'; 2 | import { Observable } from 'rxjs'; 3 | import { 4 | CallHandler, 5 | ExecutionContext, 6 | Injectable, 7 | NestInterceptor, 8 | } from '@nestjs/common'; 9 | import { SessionService } from './session.service'; 10 | import { Response } from 'express'; 11 | 12 | @Injectable() 13 | export class SessionUpdateInterceptor implements NestInterceptor { 14 | constructor(private sessionService: SessionService) {} 15 | 16 | intercept(context: ExecutionContext, next: CallHandler): Observable { 17 | const request = context.switchToHttp().getRequest(); 18 | const response: Response = context.switchToHttp().getResponse(); 19 | let sessionId = request.cookies.sessionId; // 세션 ID 추출 20 | 21 | return next.handle().pipe( 22 | tap(() => { 23 | sessionId = 24 | this.extractSessionId(response.getHeader('Set-Cookie')) || sessionId; // 세션 ID가 없으면 쿠키에서 추출 25 | // 세션 업데이트 로직 26 | this.sessionService.saveSession(sessionId); 27 | }), 28 | ); 29 | } 30 | 31 | private extractSessionId(cookieStr) { 32 | const sessionIdMatch = /sessionId=([^;]+)/.exec(cookieStr); 33 | return sessionIdMatch ? sessionIdMatch[1] : null; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/backend/src/session/session.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { SessionController } from './session.controller'; 3 | 4 | describe('SessionController', () => { 5 | let controller: SessionController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [SessionController], 10 | }).compile(); 11 | 12 | controller = module.get(SessionController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /packages/backend/src/session/session.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Delete, Get, Res, UseInterceptors } from '@nestjs/common'; 2 | import { SessionService } from './session.service'; 3 | import { SessionId } from './session.decorator'; 4 | import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; 5 | import { Response } from 'express'; 6 | import { SessionUpdateInterceptor } from './session-save.intercepter'; 7 | import { SolvedDto } from './dto/solved.dto'; 8 | 9 | @ApiTags('session') 10 | @Controller('api/v1/session') 11 | @UseInterceptors(SessionUpdateInterceptor) 12 | export class SessionController { 13 | constructor(private readonly sessionService: SessionService) {} 14 | 15 | @Delete() 16 | @ApiOperation({ summary: '세션을 삭제합니다.' }) 17 | @ApiResponse({ 18 | status: 200, 19 | description: '세션을 삭제합니다.', 20 | }) 21 | async deleteSession( 22 | @SessionId() sessionId: string, 23 | @Res() response: Response, 24 | ) { 25 | if (!sessionId) { 26 | response.end(); 27 | return; 28 | } 29 | 30 | response.clearCookie('sessionId'); 31 | this.sessionService.deleteSession(sessionId); 32 | response.end(); 33 | return; 34 | } 35 | 36 | @Get('/solved') 37 | @ApiOperation({ summary: '해결한 문제들을 알려줍니다' }) 38 | @ApiResponse({ 39 | status: 200, 40 | description: '해결한 문제들을 알려줍니다', 41 | type: SolvedDto, 42 | }) 43 | async getSolvedProblems(@SessionId() sessionId: string): Promise { 44 | if (!sessionId) { 45 | return new SolvedDto(); 46 | } 47 | 48 | return await this.sessionService.getSolvedProblems(sessionId); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/backend/src/session/session.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | 3 | export const SessionId = createParamDecorator( 4 | (data: unknown, ctx: ExecutionContext) => { 5 | const request = ctx.switchToHttp().getRequest(); 6 | return request.cookies['sessionId']; 7 | }, 8 | ); 9 | -------------------------------------------------------------------------------- /packages/backend/src/session/session.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { SessionService } from './session.service'; 3 | import { MongooseModule } from '@nestjs/mongoose'; 4 | import { ConfigModule, ConfigService } from '@nestjs/config'; 5 | import { Session, SessionSchema } from './schema/session.schema'; 6 | import { SessionController } from './session.controller'; 7 | 8 | @Module({ 9 | imports: [ 10 | MongooseModule.forRootAsync({ 11 | imports: [ConfigModule], 12 | useFactory: async (configService: ConfigService) => ({ 13 | uri: configService.get('MONGODB_HOST'), 14 | }), 15 | inject: [ConfigService], 16 | }), 17 | MongooseModule.forFeature([{ name: Session.name, schema: SessionSchema }]), 18 | ], 19 | providers: [SessionService], 20 | exports: [SessionService], 21 | controllers: [SessionController], 22 | }) 23 | export class SessionModule {} 24 | -------------------------------------------------------------------------------- /packages/backend/src/session/session.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { SessionService } from './session.service'; 3 | 4 | describe('SessionService', () => { 5 | let service: SessionService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [SessionService], 10 | }).compile(); 11 | 12 | service = module.get(SessionService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /packages/backend/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | }, 9 | "testTimeout": 20000 10 | } 11 | -------------------------------------------------------------------------------- /packages/backend/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | }, 21 | "extends": "../../tsconfig.json" 22 | } 23 | -------------------------------------------------------------------------------- /packages/frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | # 버전 관리 시스템 2 | .git 3 | .gitignore 4 | 5 | # 노드 모듈 6 | node_modules 7 | 8 | # 로그 파일 9 | npm-debug.log 10 | yarn-error.log 11 | 12 | # 빌드 디렉토리 13 | dist 14 | build 15 | 16 | # 개발 도구 설정 17 | .editorconfig 18 | *.env 19 | *.env.local 20 | *.env.development.local 21 | *.env.test.local 22 | *.env.production.local 23 | 24 | # OS 관련 파일 25 | .DS_Store 26 | Thumbs.db -------------------------------------------------------------------------------- /packages/frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "plugin:@typescript-eslint/recommended", 4 | "airbnb", 5 | "airbnb-typescript", 6 | "next/core-web-vitals", 7 | "plugin:storybook/recommended", 8 | "prettier", 9 | "plugin:import/recommended", 10 | "plugin:import/typescript" 11 | ], 12 | "rules": { 13 | "sort-imports": ["error", { "ignoreDeclarationSort": true }], 14 | "import/order": [ 15 | "error", 16 | { 17 | "newlines-between": "always", 18 | "alphabetize": { 19 | "order": "asc", 20 | "caseInsensitive": true 21 | }, 22 | "groups": [ 23 | "builtin", 24 | "external", 25 | "internal", 26 | "parent", 27 | "sibling", 28 | "index" 29 | ] 30 | } 31 | ], 32 | "react/require-default-props": "off", 33 | "@typescript-eslint/no-use-before-define": "off", 34 | "react/jsx-props-no-spreading": "off", 35 | "import/prefer-default-export": "off", 36 | "import/no-extraneous-dependencies": ["error", { "devDependencies": true }] 37 | }, 38 | "overrides": [ 39 | { 40 | "files": [ 41 | "**/__tests__/**/*.[jt]s?(x)", 42 | "**/?(*.)+(spec|test).[jt]s?(x)" 43 | ], 44 | "extends": ["plugin:testing-library/react"] 45 | } 46 | ], 47 | "settings": { 48 | "import/external-module-folders": [".yarn"] 49 | }, 50 | "parser": "@typescript-eslint/parser", 51 | "parserOptions": { 52 | "project": "./tsconfig.json" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # env files 29 | .env* 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /packages/frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSameLine": false, 4 | "bracketSpacing": true, 5 | "semi": true, 6 | "singleQuote": false, 7 | "jsxSingleQuote": false, 8 | "quoteProps": "as-needed", 9 | "trailingComma": "all", 10 | "singleAttributePerLine": false, 11 | "htmlWhitespaceSensitivity": "css", 12 | "vueIndentScriptAndStyle": false, 13 | "proseWrap": "preserve", 14 | "insertPragma": false, 15 | "printWidth": 80, 16 | "requirePragma": false, 17 | "tabWidth": 2, 18 | "useTabs": false, 19 | "embeddedLanguageFormatting": "auto" 20 | } 21 | -------------------------------------------------------------------------------- /packages/frontend/.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from "@storybook/nextjs"; 2 | import { VanillaExtractPlugin } from "@vanilla-extract/webpack-plugin"; 3 | import MiniCssExtractPlugin from "mini-css-extract-plugin"; 4 | import { join, dirname } from "path"; 5 | 6 | /** 7 | * This function is used to resolve the absolute path of a package. 8 | * It is needed in projects that use Yarn PnP or are set up within a monorepo. 9 | */ 10 | function getAbsolutePath(value: string): any { 11 | return dirname(require.resolve(join(value, "package.json"))); 12 | } 13 | const config: StorybookConfig = { 14 | stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], 15 | addons: [ 16 | getAbsolutePath("@storybook/addon-links"), 17 | getAbsolutePath("@storybook/addon-essentials"), 18 | getAbsolutePath("@storybook/addon-onboarding"), 19 | getAbsolutePath("@storybook/addon-interactions"), 20 | ], 21 | framework: { 22 | name: getAbsolutePath("@storybook/nextjs"), 23 | options: {}, 24 | }, 25 | docs: { 26 | autodocs: "tag", 27 | }, 28 | // https://stackblitz.com/edit/sb-vanilla-extract-webpack?file=.storybook%2Fmain.ts 29 | webpackFinal(config) { 30 | // Add Vanilla-Extract and MiniCssExtract Plugins 31 | config.plugins?.push( 32 | new VanillaExtractPlugin(), 33 | new MiniCssExtractPlugin(), 34 | ); 35 | 36 | // Exclude vanilla extract files from regular css processing 37 | config.module?.rules?.forEach((rule) => { 38 | if ( 39 | typeof rule !== "string" && 40 | rule && 41 | rule.test instanceof RegExp && 42 | rule.test.test("test.css") 43 | ) { 44 | rule.exclude = /\.vanilla\.css$/i; 45 | } 46 | }); 47 | 48 | config.module?.rules?.push({ 49 | test: /\.vanilla\.css$/i, // Targets only CSS files generated by vanilla-extract 50 | use: [ 51 | MiniCssExtractPlugin.loader, 52 | { 53 | loader: require.resolve("css-loader"), 54 | options: { 55 | url: false, // Required as image imports should be handled via JS/TS import statements 56 | }, 57 | }, 58 | ], 59 | }); 60 | 61 | return config; 62 | }, 63 | }; 64 | export default config; 65 | -------------------------------------------------------------------------------- /packages/frontend/.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from "@storybook/react"; 2 | 3 | import { withThemeByDataAttribute } from "@storybook/addon-styling"; 4 | 5 | import "../src/design-system/styles/global.css"; 6 | import "../src/design-system/styles/reset.css"; 7 | 8 | const preview: Preview = { 9 | parameters: { 10 | actions: { argTypesRegex: "^on[A-Z].*" }, 11 | controls: { 12 | matchers: { 13 | color: /(background|color)$/i, 14 | date: /Date$/i, 15 | }, 16 | }, 17 | }, 18 | // https://storybook.js.org/docs/react/essentials/toolbars-and-globals#global-types-and-the-toolbar-annotation 19 | globalTypes: { 20 | theme: { 21 | defaultValue: "light", 22 | toolbar: { 23 | title: "Theme", 24 | icon: "circlehollow", 25 | items: ["light", "dark"], 26 | dynamicTitle: true, 27 | }, 28 | }, 29 | }, 30 | }; 31 | 32 | // Not-using-React-or-JSX?-No-problem! https://storybook.js.org/blog/styling-addon-configure-styles-and-themes-in-storybook 33 | export const decorators = [ 34 | withThemeByDataAttribute({ 35 | themes: { 36 | light: "light", 37 | dark: "dark", 38 | }, 39 | defaultTheme: "light", 40 | attributeName: "data-theme", 41 | }), 42 | ]; 43 | 44 | export default preview; 45 | -------------------------------------------------------------------------------- /packages/frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18.17.1 2 | 3 | ARG NEXT_PUBLIC_BASE_URL 4 | ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL 5 | 6 | WORKDIR /app 7 | 8 | COPY . . 9 | 10 | RUN corepack enable 11 | 12 | WORKDIR /app/packages/frontend 13 | 14 | RUN yarn install 15 | RUN yarn build 16 | 17 | WORKDIR /app 18 | 19 | EXPOSE 3000 20 | 21 | CMD ["sh", "-c", "cd packages/frontend && yarn run start"] -------------------------------------------------------------------------------- /packages/frontend/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 20 | 21 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 22 | 23 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 24 | 25 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 26 | 27 | ## Learn More 28 | 29 | To learn more about Next.js, take a look at the following resources: 30 | 31 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 32 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 33 | 34 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 35 | 36 | ## Deploy on Vercel 37 | 38 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 39 | 40 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 41 | -------------------------------------------------------------------------------- /packages/frontend/jest.config.mjs: -------------------------------------------------------------------------------- 1 | import nextJest from "next/jest.js"; 2 | 3 | const createJestConfig = nextJest({ 4 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment 5 | dir: "./", 6 | }); 7 | 8 | const customJestConfig = { 9 | testEnvironment: "jsdom", 10 | }; 11 | 12 | export default async function config() { 13 | const styleFileRegex = "^.+\\.(css|sass|scss)$"; 14 | const nextJestConfig = await createJestConfig(customJestConfig)(); 15 | 16 | const defaultMapper = nextJestConfig.moduleNameMapper[styleFileRegex]; // Next.js 기본 설정 삭제 17 | delete nextJestConfig.moduleNameMapper[styleFileRegex]; 18 | 19 | return { 20 | ...nextJestConfig, 21 | moduleNameMapper: { 22 | "design-system/styles/.+\\.css$": defaultMapper, 23 | ...nextJestConfig.moduleNameMapper, 24 | }, 25 | transform: { 26 | "\\.css\\.ts$": "@vanilla-extract/jest-transform", // Jest transform 설정 27 | ...nextJestConfig.transform, 28 | }, 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /packages/frontend/next.config.js: -------------------------------------------------------------------------------- 1 | const { createVanillaExtractPlugin } = require("@vanilla-extract/next-plugin"); 2 | 3 | const withVanillaExtract = createVanillaExtractPlugin(); 4 | 5 | /** @type {import('next').NextConfig} */ 6 | const nextConfig = { 7 | reactStrictMode: false, // react-toastify toast 두 번 렌더링되는 문제 8 | pageExtensions: ["page.tsx", "page.ts", "page.jsx", "page.js"], 9 | async rewrites() { 10 | return [ 11 | { 12 | source: "/api/v1/:path*", 13 | destination: `https://git-challenge.com/api/v1/:path*`, 14 | }, 15 | ]; 16 | }, 17 | }; 18 | 19 | module.exports = withVanillaExtract(nextConfig); 20 | -------------------------------------------------------------------------------- /packages/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "test": "jest", 11 | "storybook": "storybook dev -p 6006", 12 | "build-storybook": "storybook build" 13 | }, 14 | "lint-staged": { 15 | "*.{ts,tsx}": [ 16 | "prettier --write", 17 | "eslint --cache --fix" 18 | ] 19 | }, 20 | "dependencies": { 21 | "@storybook/react": "^7.5.3", 22 | "@vanilla-extract/css": "^1.14.0", 23 | "axios": "^1.6.2", 24 | "d3": "^7.8.5", 25 | "next": "14.0.2", 26 | "react": "^18", 27 | "react-dom": "^18", 28 | "react-icons": "^4.12.0", 29 | "react-toastify": "^9.1.3" 30 | }, 31 | "devDependencies": { 32 | "@storybook/addon-essentials": "^7.5.3", 33 | "@storybook/addon-interactions": "^7.5.3", 34 | "@storybook/addon-links": "^7.5.3", 35 | "@storybook/addon-onboarding": "^1.0.8", 36 | "@storybook/addon-styling": "^1.3.7", 37 | "@storybook/blocks": "^7.5.3", 38 | "@storybook/nextjs": "^7.5.3", 39 | "@storybook/testing-library": "^0.2.2", 40 | "@testing-library/dom": "^9.3.3", 41 | "@testing-library/jest-dom": "^6.1.4", 42 | "@testing-library/react": "^14.1.0", 43 | "@testing-library/user-event": "^14.5.1", 44 | "@types/d3": "^7", 45 | "@types/jest": "^29.5.8", 46 | "@types/node": "^20", 47 | "@types/react": "^18", 48 | "@types/react-dom": "^18", 49 | "@typescript-eslint/eslint-plugin": "^6.11.0", 50 | "@typescript-eslint/parser": "^6.11.0", 51 | "@vanilla-extract/jest-transform": "^1.1.1", 52 | "@vanilla-extract/next-plugin": "^2.3.2", 53 | "@vanilla-extract/webpack-plugin": "^2.3.1", 54 | "css-loader": "^6.8.1", 55 | "eslint": "^8", 56 | "eslint-config-airbnb": "^19.0.4", 57 | "eslint-config-airbnb-typescript": "^17.1.0", 58 | "eslint-config-next": "14.0.2", 59 | "eslint-config-prettier": "^9.0.0", 60 | "eslint-plugin-import": "^2.29.0", 61 | "eslint-plugin-jsx-a11y": "^6.8.0", 62 | "eslint-plugin-react": "^7.33.2", 63 | "eslint-plugin-react-hooks": "^4.6.0", 64 | "eslint-plugin-storybook": "^0.6.15", 65 | "eslint-plugin-testing-library": "^6.2.0", 66 | "jest": "^29.7.0", 67 | "jest-environment-jsdom": "^29.7.0", 68 | "lint-staged": "^15.1.0", 69 | "mini-css-extract-plugin": "^2.7.6", 70 | "msw": "1.3.2", 71 | "prettier": "^3.1.0", 72 | "storybook": "^7.5.3", 73 | "typescript": "5.0.0-beta", 74 | "webpack": "^5.89.0" 75 | }, 76 | "msw": { 77 | "workerDirectory": "public" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /packages/frontend/public/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/web01-GitChallenge/3b3e7cdf89f0c4fea0d390838b5c1d761144e15c/packages/frontend/public/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /packages/frontend/public/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/web01-GitChallenge/3b3e7cdf89f0c4fea0d390838b5c1d761144e15c/packages/frontend/public/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /packages/frontend/public/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/web01-GitChallenge/3b3e7cdf89f0c4fea0d390838b5c1d761144e15c/packages/frontend/public/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /packages/frontend/public/favicon/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /packages/frontend/public/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/web01-GitChallenge/3b3e7cdf89f0c4fea0d390838b5c1d761144e15c/packages/frontend/public/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /packages/frontend/public/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/web01-GitChallenge/3b3e7cdf89f0c4fea0d390838b5c1d761144e15c/packages/frontend/public/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /packages/frontend/public/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/web01-GitChallenge/3b3e7cdf89f0c4fea0d390838b5c1d761144e15c/packages/frontend/public/favicon/favicon.ico -------------------------------------------------------------------------------- /packages/frontend/public/favicon/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/web01-GitChallenge/3b3e7cdf89f0c4fea0d390838b5c1d761144e15c/packages/frontend/public/favicon/mstile-144x144.png -------------------------------------------------------------------------------- /packages/frontend/public/favicon/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/web01-GitChallenge/3b3e7cdf89f0c4fea0d390838b5c1d761144e15c/packages/frontend/public/favicon/mstile-150x150.png -------------------------------------------------------------------------------- /packages/frontend/public/favicon/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/web01-GitChallenge/3b3e7cdf89f0c4fea0d390838b5c1d761144e15c/packages/frontend/public/favicon/mstile-310x150.png -------------------------------------------------------------------------------- /packages/frontend/public/favicon/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/web01-GitChallenge/3b3e7cdf89f0c4fea0d390838b5c1d761144e15c/packages/frontend/public/favicon/mstile-310x310.png -------------------------------------------------------------------------------- /packages/frontend/public/favicon/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/web01-GitChallenge/3b3e7cdf89f0c4fea0d390838b5c1d761144e15c/packages/frontend/public/favicon/mstile-70x70.png -------------------------------------------------------------------------------- /packages/frontend/public/favicon/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /packages/frontend/public/favicon/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /packages/frontend/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/frontend/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/base.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | const apiVersion = "/api/v1"; 4 | 5 | const instance = axios.create({ 6 | baseURL: apiVersion, 7 | }); 8 | 9 | export { instance }; 10 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/quiz.ts: -------------------------------------------------------------------------------- 1 | import { API_PATH } from "../constants/path"; 2 | import { 3 | Categories, 4 | Command, 5 | Quiz, 6 | QuizGitGraph, 7 | QuizSolve, 8 | SharedQuiz, 9 | } from "../types/quiz"; 10 | 11 | import { instance } from "./base"; 12 | 13 | export const quizAPI = { 14 | postCommand: async ({ id, mode, message }: PostCommandRequest) => { 15 | const { data } = await instance.post( 16 | `${API_PATH.QUIZZES}/${id}/command`, 17 | { mode, message }, 18 | ); 19 | return data; 20 | }, 21 | getQuiz: async (id: number) => { 22 | const { data } = await instance.get(`${API_PATH.QUIZZES}/${id}`); 23 | return data; 24 | }, 25 | getCategories: async () => { 26 | const { data } = await instance.get(API_PATH.QUIZZES); 27 | return data; 28 | }, 29 | submit: async (id: number) => { 30 | const { data } = await instance.post( 31 | `${API_PATH.QUIZZES}/${id}/submit`, 32 | ); 33 | return data; 34 | }, 35 | resetQuizById: async (id: number) => { 36 | const { data } = await instance.delete(`${API_PATH.QUIZZES}/${id}/command`); 37 | return data; 38 | }, 39 | getSharedAnswer: async (slug: string) => { 40 | const { data } = await instance.get( 41 | `${API_PATH.QUIZZES}/shared?answer=${slug}`, 42 | ); 43 | return data; 44 | }, 45 | getGraph: async (id: number) => { 46 | const { data } = await instance.get( 47 | `${API_PATH.QUIZZES}/${id}/graph`, 48 | ); 49 | return data; 50 | }, 51 | }; 52 | 53 | type PostCommandRequest = { 54 | id: number; 55 | mode: "command" | "editor"; 56 | message: string; 57 | }; 58 | 59 | export type GetSharedAnswerResponse = { 60 | answer: string[]; 61 | quiz: SharedQuiz; 62 | }; 63 | 64 | type GetQuizGraphResponse = { 65 | graph: QuizGitGraph; 66 | ref: string; 67 | }; 68 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/session.ts: -------------------------------------------------------------------------------- 1 | import { API_PATH } from "../constants/path"; 2 | import { UserQuizStatus } from "../types/user"; 3 | 4 | import { instance } from "./base"; 5 | 6 | export const sessionAPI = { 7 | getUserQuizStatus: async () => { 8 | const { data } = await instance.get( 9 | `${API_PATH.SESSION}/solved`, 10 | ); 11 | return data; 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /packages/frontend/src/components/editor/Editor.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from "@vanilla-extract/css"; 2 | 3 | import color from "../../design-system/tokens/color"; 4 | import typography from "../../design-system/tokens/typography"; 5 | import { 6 | border, 7 | borderRadius, 8 | flexColumn, 9 | flexJustifyCenter, 10 | middleLayer, 11 | widthFull, 12 | } from "../../design-system/tokens/utils.css"; 13 | 14 | const editorPadding = 10; 15 | 16 | export const container = style([ 17 | middleLayer, 18 | border.all, 19 | { 20 | display: "flex", 21 | flexDirection: "column", 22 | width: "100%", 23 | flex: 1, 24 | position: "relative", 25 | minHeight: 160, 26 | overflowY: "auto", 27 | }, 28 | ]); 29 | 30 | export const textarea = style([ 31 | typography.$semantic.code, 32 | { 33 | resize: "none", 34 | flex: 1, 35 | height: "100%", 36 | padding: editorPadding, 37 | border: "none", 38 | outline: "none", 39 | backgroundColor: color.$semantic.bgDefault, 40 | color: color.$scale.grey900, 41 | }, 42 | ]); 43 | 44 | export const input = style([ 45 | widthFull, 46 | typography.$semantic.code, 47 | { 48 | position: "absolute", 49 | bottom: "0", 50 | padding: `0px ${editorPadding}px`, 51 | border: "none", 52 | backgroundColor: color.$semantic.bgDefault, 53 | color: color.$scale.grey900, 54 | outline: "none", 55 | 56 | selectors: { 57 | "&.error": { 58 | color: color.$semantic.danger, 59 | }, 60 | }, 61 | }, 62 | ]); 63 | 64 | export const notice = style([ 65 | flexColumn, 66 | flexJustifyCenter, 67 | widthFull, 68 | typography.$semantic.caption2Regular, 69 | border.all, 70 | borderRadius, 71 | { 72 | marginTop: 14, 73 | gap: 5, 74 | padding: 14, 75 | whiteSpace: "break-spaces", 76 | lineHeight: "165%", 77 | }, 78 | ]); 79 | -------------------------------------------------------------------------------- /packages/frontend/src/components/editor/EditorInfo.tsx: -------------------------------------------------------------------------------- 1 | import { Info } from "../../design-system/components/common"; 2 | 3 | import * as styles from "./Editor.css"; 4 | 5 | export default function EditorInfo() { 6 | return ( 7 |
8 | {NOTICE.map((line) => ( 9 | {line} 10 | ))} 11 |
12 | ); 13 | } 14 | 15 | const NOTICE = [ 16 | '텍스트를 작성하거나 수정하려면 "i"를 눌러 입력 모드로 전환해 주세요.', 17 | '입력 모드에서 입력한 텍스트를 저장하거나 종료하려면 "ESC" > ":"를 순서대로 누르고 아래 안내사항을 따라주세요.', 18 | '수정을 완료한 뒤 저장하지 않고 종료하려면 "q" > "Enter"를 순서대로 눌러주세요.', 19 | '수정을 완료한 뒤 저장하고 종료하려면 "wq" > "Enter"를 순서대로 눌러주세요.', 20 | '종료/저장 전 다시 입력 모드로 전환하고 싶다면 "ESC" > "i"를 순서대로 눌러주세요.', 21 | ]; 22 | -------------------------------------------------------------------------------- /packages/frontend/src/components/editor/index.ts: -------------------------------------------------------------------------------- 1 | export { Editor } from "./Editor"; 2 | -------------------------------------------------------------------------------- /packages/frontend/src/components/editor/useTextareaCursor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FocusEventHandler, 3 | RefObject, 4 | useLayoutEffect, 5 | useRef, 6 | useState, 7 | } from "react"; 8 | 9 | export function useTextareaCursor(textareaRef: RefObject) { 10 | const [cursorLast, setCursorLast] = useState(false); 11 | const cursorRef = useRef({ selectionStart: 0, selectionEnd: 0 }); 12 | 13 | const storeCursor = (selectionStart: number, selectionEnd: number) => { 14 | cursorRef.current = { selectionStart, selectionEnd }; 15 | }; 16 | 17 | const restoreCursorRef = useRef(() => { 18 | const { selectionStart, selectionEnd } = cursorRef.current; 19 | textareaRef.current?.setSelectionRange(selectionStart, selectionEnd); 20 | }); 21 | 22 | const storeCursorBeforeJumpToLast = ( 23 | selectionStart: number, 24 | selectionEnd: number, 25 | ) => { 26 | storeCursor(selectionStart, selectionEnd); 27 | setCursorLast(true); 28 | }; 29 | 30 | const handleTextareaBlur: FocusEventHandler = ({ 31 | target: { selectionStart, selectionEnd }, 32 | }) => { 33 | storeCursor(selectionStart, selectionEnd); 34 | }; 35 | 36 | const handleTextareaFocus: FocusEventHandler = () => { 37 | restoreCursorRef.current(); 38 | }; 39 | 40 | useLayoutEffect(() => { 41 | if (cursorLast) { 42 | // 마지막으로 이동한 커서 위치 이전으로 복원 43 | restoreCursorRef.current(); 44 | setCursorLast(false); 45 | } 46 | }, [cursorLast, restoreCursorRef]); 47 | 48 | return { 49 | storeCursor, 50 | restoreCursorRef, 51 | storeCursorBeforeJumpToLast, 52 | handleTextareaBlur, 53 | handleTextareaFocus, 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /packages/frontend/src/components/graph/fillColor.ts: -------------------------------------------------------------------------------- 1 | import { InitialDataProps } from "./parsing"; 2 | 3 | const color = [ 4 | "#8dd3c7", 5 | "#ffffb3", 6 | "#bebada", 7 | "#fb8072", 8 | "#80b1d3", 9 | "#fdb462", 10 | "#b3de69", 11 | "#fccde5", 12 | "#d9d9d9", 13 | "#bc80bd", 14 | "#ccebc5", 15 | "#ffed6f", 16 | ]; 17 | 18 | type D3NodeType = d3.HierarchyNode; 19 | export default function fillColor(node: d3.HierarchyNode) { 20 | dfs(node, 0); 21 | } 22 | 23 | function dfs(node: D3NodeType, colorIndex: number) { 24 | const { data, children } = node; 25 | const alreadyFilled = data.color; 26 | 27 | if (alreadyFilled) { 28 | return; 29 | } 30 | 31 | data.color = color[colorIndex]; 32 | const leafNode = !children; 33 | 34 | if (leafNode) { 35 | return; 36 | } 37 | 38 | const [firstChild, ...restChildren] = children; 39 | dfs(firstChild, colorIndex); 40 | restChildren.forEach((child, offset) => dfs(child, colorIndex + offset + 1)); 41 | } 42 | -------------------------------------------------------------------------------- /packages/frontend/src/components/graph/index.ts: -------------------------------------------------------------------------------- 1 | export { Graph } from "./Graph"; 2 | -------------------------------------------------------------------------------- /packages/frontend/src/components/graph/parsing.ts: -------------------------------------------------------------------------------- 1 | export interface InitialDataProps { 2 | id: string; 3 | parentId: string; 4 | message: string; 5 | refs: string; 6 | color?: string; 7 | } 8 | 9 | export type AdditionalLinksType = Pick[]; 10 | 11 | export function parsingMultipleParents(initialData: InitialDataProps[]) { 12 | const additionalLinks: AdditionalLinksType = []; 13 | const parsedData = initialData.map((data) => { 14 | const [first, ...rest] = data.parentId.split(" "); 15 | rest.forEach((v) => { 16 | additionalLinks.push({ id: data.id, parentId: v }); 17 | }); 18 | 19 | return { 20 | ...data, 21 | parentId: first, 22 | }; 23 | }); 24 | 25 | return { parsedData, additionalLinks }; 26 | } 27 | -------------------------------------------------------------------------------- /packages/frontend/src/components/landing/Book/Book.css.ts: -------------------------------------------------------------------------------- 1 | import { globalStyle, style } from "@vanilla-extract/css"; 2 | 3 | import color from "../../../design-system/tokens/color"; 4 | import typography from "../../../design-system/tokens/typography"; 5 | import { 6 | border, 7 | flexAlignCenter, 8 | flexColumn, 9 | } from "../../../design-system/tokens/utils.css"; 10 | 11 | export const container = style([ 12 | flexColumn, 13 | { 14 | marginBottom: 30, 15 | whiteSpace: "break-spaces", 16 | }, 17 | ]); 18 | export const h1 = style([ 19 | typography.$semantic.h1, 20 | border.bottom, 21 | { 22 | paddingBottom: 17, 23 | marginBottom: 17, 24 | }, 25 | ]); 26 | 27 | export const description = style([ 28 | typography.$semantic.body1Regular, 29 | { 30 | color: color.$scale.grey800, 31 | marginBottom: 30, 32 | }, 33 | ]); 34 | 35 | export const h2 = style([typography.$semantic.title2Bold]); 36 | 37 | export const li = style([typography.$semantic.body2Regular]); 38 | 39 | globalStyle(`${container} > ul`, { 40 | display: "flex", 41 | flexDirection: "column", 42 | gap: 5, 43 | listStyleType: "disc", 44 | marginBlockStart: 9, 45 | paddingInlineStart: 20, 46 | marginBottom: 29, 47 | }); 48 | 49 | export const linkWrapper = style([ 50 | flexAlignCenter, 51 | { 52 | color: color.$scale.grey600, 53 | textDecoration: "underline", 54 | gap: 5, 55 | marginBottom: 29, 56 | selectors: { 57 | "&:hover": { 58 | fontWeight: 500, 59 | }, 60 | }, 61 | }, 62 | ]); 63 | 64 | globalStyle(`${container} code`, { 65 | borderRadius: 4, 66 | paddingLeft: 4, 67 | paddingRight: 4, 68 | color: color.$scale.coral500, 69 | backgroundColor: color.$scale.grey100, 70 | }); 71 | -------------------------------------------------------------------------------- /packages/frontend/src/components/landing/Book/Book.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | import Image from "next/image"; 4 | import Link from "next/link"; 5 | 6 | import useScrollFadeIn from "../../../hooks/useScroll/useScrollFadeIn"; 7 | import { toCodeTag } from "../../../utils/mapper"; 8 | 9 | import * as styles from "./Book.css"; 10 | import { data } from "./data"; 11 | 12 | export default function Book() { 13 | const thresholds = [0.1, 0.5, 0.5, 0.1]; 14 | 15 | return ( 16 |
17 | {data.map((item, index) => ( 18 |
23 |

{item.title}

24 | {item.description && ( 25 |
{item.description}
26 | )} 27 | {item.subItems && 28 | item.subItems.map((list) => ( 29 | <> 30 |

34 |
    35 | {list.listItems.map((listItem) => ( 36 |
  • 43 | ))} 44 |
45 | 46 | ))} 47 | {item.link && ( 48 |
49 | {item.link.label} 50 | {item.link.image} 56 |
57 | )} 58 |

59 | ))} 60 |
61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /packages/frontend/src/components/landing/Landing.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from "@vanilla-extract/css"; 2 | 3 | import color from "../../design-system/tokens/color"; 4 | import { 5 | border, 6 | flexAlignCenter, 7 | flexColumn, 8 | widthFull, 9 | } from "../../design-system/tokens/utils.css"; 10 | 11 | export const container = style([ 12 | widthFull, 13 | border.verticalSide, 14 | flexColumn, 15 | flexAlignCenter, 16 | ]); 17 | export const sectionWrapper = style([ 18 | { 19 | width: "80%", 20 | color: color.$scale.grey800, 21 | paddingBottom: 60, 22 | }, 23 | ]); 24 | -------------------------------------------------------------------------------- /packages/frontend/src/components/landing/Landing.tsx: -------------------------------------------------------------------------------- 1 | import Book from "./Book/Book"; 2 | import * as styles from "./Landing.css"; 3 | import ServiceInfo from "./ServiceInfo/ServiceInfo"; 4 | 5 | export default function Landing() { 6 | return ( 7 |
8 |
9 | 10 | 11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /packages/frontend/src/components/landing/ServiceInfo/ServiceInfo.css.ts: -------------------------------------------------------------------------------- 1 | import { globalStyle, style } from "@vanilla-extract/css"; 2 | 3 | import color from "../../../design-system/tokens/color"; 4 | import typography from "../../../design-system/tokens/typography"; 5 | import { 6 | border, 7 | flexAlignCenter, 8 | flexColumn, 9 | widthFull, 10 | } from "../../../design-system/tokens/utils.css"; 11 | 12 | export const container = style([ 13 | widthFull, 14 | border.verticalSide, 15 | flexColumn, 16 | flexAlignCenter, 17 | ]); 18 | 19 | export const serviceInfoContainer = style({ 20 | whiteSpace: "break-spaces", 21 | marginBottom: 60, 22 | }); 23 | 24 | export const landingTitle = style([ 25 | { 26 | fontSize: 40, 27 | fontWeight: 700, 28 | margin: "48px 0px 40px 0px", 29 | position: "relative", 30 | lineHeight: "150%", 31 | }, 32 | ]); 33 | 34 | globalStyle(`${landingTitle} > span`, { 35 | color: color.$scale.coral700, 36 | }); 37 | 38 | export const folderImg = style({ 39 | position: "absolute", 40 | bottom: 7, 41 | marginLeft: 7, 42 | }); 43 | 44 | export const serviceInfo = style([ 45 | border.all, 46 | typography.$semantic.body2Regular, 47 | flexColumn, 48 | { 49 | borderRadius: 8, 50 | padding: "35px 33px", 51 | }, 52 | ]); 53 | 54 | export const problemPageButton = style({ 55 | height: 50, 56 | marginTop: 22, 57 | }); 58 | 59 | export const issueLink = style([ 60 | flexAlignCenter, 61 | { 62 | padding: "0px 4px", 63 | gap: 3, 64 | selectors: { 65 | "&:hover": { 66 | borderRadius: 8, 67 | backgroundColor: color.$semantic.bgAlt, 68 | }, 69 | }, 70 | }, 71 | ]); 72 | -------------------------------------------------------------------------------- /packages/frontend/src/components/landing/ServiceInfo/ServiceInfo.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | 4 | import { BROWSWER_PATH } from "../../../constants/path"; 5 | import { LinkButton } from "../../../design-system/components/common"; 6 | import { flex } from "../../../design-system/tokens/utils.css"; 7 | import useScrollClipPath from "../../../hooks/useScroll/useScrollClipPath"; 8 | import useScrollFadeIn from "../../../hooks/useScroll/useScrollFadeIn"; 9 | 10 | import * as styles from "./ServiceInfo.css"; 11 | 12 | export default function ServiceInfo() { 13 | const headingAnimation = useScrollFadeIn("up"); 14 | const ImgAnimation = useScrollClipPath("right"); 15 | 16 | return ( 17 |
18 |

19 | Git 20 | {`이 너무 어렵게만\n느껴진다면?`} 21 | folder-icon 28 |

29 |
30 | {[ 31 | "안녕하세요! 저희는 팀 MergeMasters입니다. (만든 이들 : 박용준, 박정제, 박유현, 윤채현)", 32 | "Git Challenge는 Git에 대한 문제를 실제 상황처럼 구현된 환경에서 학습할 수 있는 서비스입니다.", 33 | "실제 프로젝트나 시나리오에서 발생할 수 있는 다양한 상황들을 경험하고 실전에서 해결할 수 있도록 도움을 드리는 것을 목표로 하고 있습니다.", 34 | ].join("\n")} 35 |

36 | 다른 문의 및 개선요청 사항이 있다면 37 | 41 | github-image 47 | 깃허브 이슈란 48 | 49 | 또는 dbscogus4467@naver.com 으로 문의주세요 🙋🏻‍♀️ 50 |

51 | 56 | 바로 문제 풀어보러 가기 💻 57 | 58 |
59 |
60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /packages/frontend/src/components/quiz/CommandAccordion/CommandAccordion.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from "@vanilla-extract/css"; 2 | 3 | import { flex } from "../../../design-system/tokens/utils.css"; 4 | 5 | const badgeGroupLayout = style([ 6 | flex, 7 | { 8 | marginTop: "6px", 9 | gap: 10, 10 | }, 11 | ]); 12 | 13 | export default badgeGroupLayout; 14 | -------------------------------------------------------------------------------- /packages/frontend/src/components/quiz/CommandAccordion/CommandAccordion.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | import { GIT_BOOK_URL } from "../../../constants/path"; 4 | import { 5 | Accordion, 6 | Badge, 7 | badgeVariantList, 8 | } from "../../../design-system/components/common"; 9 | 10 | import badgeGroupLayout from "./CommandAccordion.css"; 11 | 12 | interface CommandAccordionProps { 13 | width?: number | string; 14 | items: string[]; 15 | } 16 | 17 | export default function CommandAccordion({ 18 | width = "100%", 19 | items, 20 | }: CommandAccordionProps) { 21 | return ( 22 | 23 | 24 | 25 | {({ open }) => <>핵심명령어 {open ? "숨기기" : "보기"}} 26 | 27 |
28 | {items.map((item, index) => ( 29 | 30 | 31 | {item} 32 | 33 | 34 | ))} 35 |
36 |
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /packages/frontend/src/components/quiz/CommandAccordion/index.ts: -------------------------------------------------------------------------------- 1 | import CommandAccordion from "./CommandAccordion"; 2 | 3 | export default CommandAccordion; 4 | -------------------------------------------------------------------------------- /packages/frontend/src/components/quiz/QuizAnswerModal/QuizAnswerModal.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from "@vanilla-extract/css"; 2 | 3 | import color from "../../../design-system/tokens/color"; 4 | import typography from "../../../design-system/tokens/typography"; 5 | 6 | export const h3 = style([ 7 | typography.$semantic.h3, 8 | { color: color.$scale.grey700, marginBottom: 20 }, 9 | ]); 10 | -------------------------------------------------------------------------------- /packages/frontend/src/components/quiz/QuizAnswerModal/QuizAnswerModal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | CodeBlock, 3 | Info, 4 | Modal, 5 | } from "../../../design-system/components/common"; 6 | import typography from "../../../design-system/tokens/typography"; 7 | 8 | import * as styles from "./QuizAnswerModal.css"; 9 | 10 | interface QuizModalProps { 11 | answer: string[]; 12 | closeModal: () => void; 13 | } 14 | 15 | export default function QuizAnswerModal({ 16 | answer, 17 | closeModal, 18 | }: QuizModalProps) { 19 | return ( 20 | 21 |

모범 답안

22 | 23 | 24 | 본 답안은 모범 답안으로 제공되었으며, 상황에 따라 다양한 해결책이 있을 25 | 수 있습니다. 26 | 27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /packages/frontend/src/components/quiz/QuizAnswerModal/index.ts: -------------------------------------------------------------------------------- 1 | import QuizAnswerModal from "./QuizAnswerModal"; 2 | 3 | export default QuizAnswerModal; 4 | -------------------------------------------------------------------------------- /packages/frontend/src/components/quiz/QuizContent/QuizContent.css.ts: -------------------------------------------------------------------------------- 1 | import { globalStyle, style } from "@vanilla-extract/css"; 2 | 3 | import color from "../../../design-system/tokens/color"; 4 | import typography from "../../../design-system/tokens/typography"; 5 | 6 | export const strong = style([ 7 | typography.$semantic.title3Bold, 8 | { color: color.$scale.grey800 }, 9 | ]); 10 | 11 | export const description = style([ 12 | typography.$semantic.body2Regular, 13 | { 14 | marginTop: 10, 15 | maxHeight: 190, 16 | padding: "0 8px 4px 0", 17 | color: color.$scale.grey700, 18 | overflowY: "auto", 19 | whiteSpace: "break-spaces", 20 | }, 21 | ]); 22 | 23 | globalStyle(`${description} code`, { 24 | borderRadius: 4, 25 | paddingLeft: 4, 26 | paddingRight: 4, 27 | color: color.$scale.coral500, 28 | backgroundColor: color.$scale.grey100, 29 | }); 30 | -------------------------------------------------------------------------------- /packages/frontend/src/components/quiz/QuizContent/QuizContent.tsx: -------------------------------------------------------------------------------- 1 | import { toCodeTag } from "../../../utils/mapper"; 2 | import QuizLocation from "../QuizLocation"; 3 | 4 | import * as styles from "./QuizContent.css"; 5 | 6 | interface QuizContentProps { 7 | title: string; 8 | description: string; 9 | category: string; 10 | } 11 | 12 | export default function QuizContent({ 13 | title, 14 | description, 15 | category, 16 | }: QuizContentProps) { 17 | return ( 18 |
19 | 20 | 문제 21 |

25 |

26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /packages/frontend/src/components/quiz/QuizContent/index.ts: -------------------------------------------------------------------------------- 1 | import QuizContent from "./QuizContent"; 2 | 3 | export default QuizContent; 4 | -------------------------------------------------------------------------------- /packages/frontend/src/components/quiz/QuizGuide/QuizGuide.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from "@vanilla-extract/css"; 2 | 3 | import color from "../../../design-system/tokens/color"; 4 | import { baseLayer, flexColumn } from "../../../design-system/tokens/utils.css"; 5 | 6 | const containerPadding = 23; 7 | 8 | export const quizContentContainer = style([ 9 | flexColumn, 10 | baseLayer, 11 | { 12 | position: "relative", 13 | width: "50%", 14 | height: "400px", 15 | borderLeft: `1px solid ${color.$semantic.border}`, 16 | padding: containerPadding, 17 | gap: 12, 18 | }, 19 | ]); 20 | 21 | export const checkAnswerButton = style({ 22 | position: "absolute", 23 | right: containerPadding, 24 | bottom: containerPadding, 25 | }); 26 | -------------------------------------------------------------------------------- /packages/frontend/src/components/quiz/QuizGuide/QuizGuide.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "../../../design-system/components/common"; 2 | import useModal from "../../../hooks/useModal"; 3 | import { Quiz } from "../../../types/quiz"; 4 | import CommandAccordion from "../CommandAccordion"; 5 | import QuizAnswerModal from "../QuizAnswerModal"; 6 | import QuizContent from "../QuizContent"; 7 | 8 | import * as styles from "./QuizGuide.css"; 9 | 10 | export function QuizGuide({ quiz }: { quiz: Quiz }) { 11 | const { modalOpen, openModal, closeModal } = useModal(); 12 | 13 | return ( 14 | <> 15 | {modalOpen && ( 16 | 17 | )} 18 |
19 | 24 | 25 | 32 |
33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /packages/frontend/src/components/quiz/QuizGuide/index.ts: -------------------------------------------------------------------------------- 1 | export { QuizGuide } from "./QuizGuide"; 2 | -------------------------------------------------------------------------------- /packages/frontend/src/components/quiz/QuizLocation/QuizLocation.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from "@vanilla-extract/css"; 2 | 3 | import color from "../../../design-system/tokens/color"; 4 | import typography from "../../../design-system/tokens/typography"; 5 | import * as utils from "../../../design-system/tokens/utils.css"; 6 | 7 | export const list = style([ 8 | typography.$semantic.caption1Regular, 9 | utils.flex, 10 | { 11 | justifyContent: "flex-start", 12 | alignItems: "center", 13 | marginBottom: 7, 14 | color: color.$scale.grey700, 15 | }, 16 | ]); 17 | 18 | export const icon = style({ 19 | padding: "0 4px", 20 | }); 21 | -------------------------------------------------------------------------------- /packages/frontend/src/components/quiz/QuizLocation/QuizLocation.tsx: -------------------------------------------------------------------------------- 1 | import { BsChevronRight } from "react-icons/bs"; 2 | 3 | import { flexAlignCenter } from "../../../design-system/tokens/utils.css"; 4 | 5 | import { icon as iconStyle, list as listStyle } from "./QuizLocation.css"; 6 | 7 | interface QuizLocationProps { 8 | items: string[]; 9 | } 10 | 11 | export default function QuizLocation({ items }: QuizLocationProps) { 12 | const { length } = items; 13 | 14 | return ( 15 |
    16 | {items.map((item, index) => ( 17 |
  1. 18 | {item} 19 | {!isLast(index, length) && } 20 |
  2. 21 | ))} 22 |
23 | ); 24 | } 25 | 26 | function ChevronRight() { 27 | return ( 28 | 29 | 30 | 31 | ); 32 | } 33 | 34 | function isLast(index: number, length: number) { 35 | return index === length - 1; 36 | } 37 | -------------------------------------------------------------------------------- /packages/frontend/src/components/quiz/QuizLocation/index.ts: -------------------------------------------------------------------------------- 1 | import QuizLocation from "./QuizLocation"; 2 | 3 | export default QuizLocation; 4 | -------------------------------------------------------------------------------- /packages/frontend/src/components/quiz/SolvedModal/SolvedModal.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from "@vanilla-extract/css"; 2 | 3 | import color from "../../../design-system/tokens/color"; 4 | import typography from "../../../design-system/tokens/typography"; 5 | import { 6 | border, 7 | borderRadius, 8 | flexAlignCenter, 9 | flexCenter, 10 | } from "../../../design-system/tokens/utils.css"; 11 | 12 | export const container = style({ 13 | width: 336, 14 | }); 15 | 16 | export const title = style([ 17 | typography.$semantic.h1, 18 | { 19 | marginBottom: 33, 20 | textAlign: "center", 21 | color: color.$scale.grey700, 22 | }, 23 | ]); 24 | 25 | export const strong = style([ 26 | typography.$semantic.title4Regular, 27 | { 28 | display: "block", 29 | marginBottom: 10, 30 | color: color.$scale.grey700, 31 | }, 32 | ]); 33 | 34 | export const linkContainer = style([ 35 | flexAlignCenter, 36 | { 37 | marginBottom: 30, 38 | color: color.$scale.grey600, 39 | }, 40 | ]); 41 | 42 | export const linkInput = style([ 43 | border.all, 44 | borderRadius, 45 | typography.$semantic.body2Regular, 46 | { 47 | flex: "1 0", 48 | borderTopRightRadius: 0, 49 | borderBottomRightRadius: 0, 50 | padding: "9px 13px", 51 | color: "inherit", 52 | backgroundColor: color.$semantic.bgDefault, 53 | outline: "none", 54 | }, 55 | ]); 56 | 57 | export const linkCopyButton = style([ 58 | border.all, 59 | borderRadius, 60 | typography.$semantic.body2Regular, 61 | { 62 | position: "relative", 63 | borderLeft: "none", 64 | borderTopLeftRadius: 0, 65 | borderBottomLeftRadius: 0, 66 | padding: "9px 19px", 67 | color: "inherit", 68 | backgroundColor: color.$semantic.bgAlt, 69 | 70 | selectors: { 71 | "&.visible::after": { 72 | display: "inline-block", 73 | }, 74 | }, 75 | 76 | "::after": { 77 | content: "✅", 78 | display: "none", 79 | position: "absolute", 80 | top: "50%", 81 | left: "50%", 82 | transform: "translate(-50%, -50%)", 83 | }, 84 | }, 85 | ]); 86 | 87 | export const buttonGroup = style([flexCenter, { gap: 7 }]); 88 | 89 | export const linkCopyButtonText = style({ 90 | selectors: { 91 | [`${linkCopyButton}.visible &`]: { 92 | visibility: "hidden", 93 | }, 94 | }, 95 | }); 96 | -------------------------------------------------------------------------------- /packages/frontend/src/components/quiz/SolvedModal/SolvedModal.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from "react"; 2 | 3 | import { 4 | Button, 5 | LinkButton, 6 | Modal, 7 | toast, 8 | } from "../../../design-system/components/common"; 9 | import { createClassManipulator } from "../../../utils/classList"; 10 | 11 | import * as styles from "./SolvedModal.css"; 12 | 13 | const VISIBLE = "visible"; 14 | 15 | const COPY_SUCCESS_DURATION = 1000; 16 | 17 | interface SolvedModalProps { 18 | link: string; 19 | lastQuiz: boolean; 20 | onClose: () => void; 21 | onNextQuiz: () => void; 22 | } 23 | 24 | export function SolvedModal({ 25 | link, 26 | lastQuiz, 27 | onClose, 28 | onNextQuiz, 29 | }: SolvedModalProps) { 30 | const copyButtonRef = useRef(null); 31 | const manipulateVisibleClass = createClassManipulator(copyButtonRef, VISIBLE); 32 | 33 | const handleCopy = async () => { 34 | try { 35 | await navigator.clipboard.writeText(link); 36 | manipulateVisibleClass("add"); 37 | 38 | setTimeout(() => { 39 | manipulateVisibleClass("remove"); 40 | }, COPY_SUCCESS_DURATION); 41 | } catch (error) { 42 | toast.error("링크 복사를 실패했습니다. 잠시 후 다시 시도해 주세요."); 43 | } 44 | }; 45 | 46 | return ( 47 | 48 |
49 |

정답입니다 🥳

50 | 내 답안을 공유해볼까요? 51 |
52 | 58 | 66 |
67 |
68 | 69 | 내 답안 보러가기 70 | 71 | {!lastQuiz && ( 72 | 75 | )} 76 |
77 |
78 |
79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /packages/frontend/src/components/quiz/SolvedModal/index.ts: -------------------------------------------------------------------------------- 1 | export { SolvedModal } from "./SolvedModal"; 2 | export { useSolvedModal } from "./useSolvedModal"; 3 | -------------------------------------------------------------------------------- /packages/frontend/src/components/quiz/SolvedModal/useSolvedModal.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react"; 2 | 3 | import useModal from "../../../hooks/useModal"; 4 | 5 | const LAST_QUIZ_ID = 19; 6 | 7 | export function useSolvedModal(id: number) { 8 | const [shareLink, setShareLink] = useState(""); 9 | const { modalOpen, openModal, closeModal } = useModal(); 10 | 11 | const handleSolved = useCallback( 12 | (link: string) => { 13 | setShareLink(link); 14 | openModal(); 15 | }, 16 | [openModal], 17 | ); 18 | 19 | return { 20 | lastQuiz: id === LAST_QUIZ_ID, 21 | shareLink, 22 | modalOpen, 23 | openModal, 24 | closeModal, 25 | onSolved: handleSolved, 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /packages/frontend/src/components/quiz/index.ts: -------------------------------------------------------------------------------- 1 | export { default as QuizContent } from "./QuizContent"; 2 | export { default as QuizLocation } from "./QuizLocation"; 3 | export { default as CommandAccordion } from "./CommandAccordion"; 4 | export { SolvedModal, useSolvedModal } from "./SolvedModal"; 5 | export { default as QuizAnswerModal } from "./QuizAnswerModal"; 6 | -------------------------------------------------------------------------------- /packages/frontend/src/components/terminal/CommandInput.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type ClipboardEventHandler, 3 | type KeyboardEventHandler, 4 | forwardRef, 5 | useImperativeHandle, 6 | useRef, 7 | } from "react"; 8 | 9 | import { focusRef, scrollIntoViewRef } from "../../utils/refObject"; 10 | 11 | import Prompt from "./Prompt"; 12 | import * as styles from "./Terminal.css"; 13 | 14 | interface CommandInputProps { 15 | gitRef: string; 16 | handleInput: KeyboardEventHandler; 17 | } 18 | 19 | export type CommandInputForwardRefType = { 20 | focus: HTMLSpanElement["focus"]; 21 | scrollIntoView: HTMLSpanElement["scrollIntoView"]; 22 | clear: () => void; 23 | }; 24 | 25 | const CommandInput = forwardRef( 26 | ({ gitRef, handleInput }, ref) => { 27 | const inputRef = useRef(null); 28 | 29 | useImperativeHandle( 30 | ref, 31 | () => ({ 32 | focus(options) { 33 | focusRef(inputRef, options); 34 | }, 35 | scrollIntoView(arg) { 36 | scrollIntoViewRef(inputRef, arg); 37 | }, 38 | clear() { 39 | const $element = inputRef.current; 40 | if (!$element) { 41 | return; 42 | } 43 | 44 | $element.textContent = ""; 45 | }, 46 | }), 47 | [], 48 | ); 49 | 50 | const handlePaste: ClipboardEventHandler = (event) => { 51 | event.preventDefault(); 52 | 53 | const pastedDataPlainText = event.clipboardData.getData("text/plain"); 54 | const range = document.getSelection()?.getRangeAt(0); 55 | if (!range) { 56 | return; 57 | } 58 | 59 | range.deleteContents(); 60 | 61 | const textNode = document.createTextNode(pastedDataPlainText); 62 | const cursorAnchorNode = document.createElement("span"); 63 | 64 | range.insertNode(cursorAnchorNode); 65 | range.insertNode(textNode); 66 | range.collapse(false); 67 | 68 | cursorAnchorNode.scrollIntoView(); 69 | cursorAnchorNode.remove(); 70 | }; 71 | 72 | const handleClick = () => { 73 | focusRef(inputRef); 74 | }; 75 | 76 | return ( 77 |
82 | 83 | Enter git command 84 | 85 | 86 | 97 |
98 | ); 99 | }, 100 | ); 101 | 102 | CommandInput.displayName = "CommandInput"; 103 | 104 | export default CommandInput; 105 | -------------------------------------------------------------------------------- /packages/frontend/src/components/terminal/Prompt.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from "@vanilla-extract/css"; 2 | 3 | import color from "../../design-system/tokens/color"; 4 | 5 | export const prompt = style({ 6 | color: color.$semantic.primary, 7 | fontWeight: 700, 8 | paddingRight: 7, 9 | }); 10 | 11 | export const username = style({ 12 | paddingRight: 4, 13 | }); 14 | 15 | export const gitRef = style({ 16 | paddingRight: 4, 17 | color: color.$semantic.secondary, 18 | }); 19 | -------------------------------------------------------------------------------- /packages/frontend/src/components/terminal/Prompt.tsx: -------------------------------------------------------------------------------- 1 | import * as styles from "./Prompt.css"; 2 | 3 | const USER_NAME = "root"; 4 | 5 | interface PromptProps { 6 | gitRef: string; 7 | } 8 | 9 | export default function Prompt({ gitRef }: PromptProps) { 10 | return ( 11 | 12 | {USER_NAME} 13 | {gitRef && ({gitRef})} 14 | >> 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /packages/frontend/src/components/terminal/Terminal.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from "@vanilla-extract/css"; 2 | 3 | import color from "../../design-system/tokens/color"; 4 | import typography from "../../design-system/tokens/typography"; 5 | import { 6 | border, 7 | middleLayer, 8 | widthFull, 9 | } from "../../design-system/tokens/utils.css"; 10 | 11 | export const terminalContainer = style([ 12 | typography.$semantic.code, 13 | middleLayer, 14 | border.all, 15 | { 16 | flex: 1, 17 | minHeight: 160, 18 | width: "100%", 19 | padding: "10px 10px", 20 | overflowY: "auto", 21 | color: color.$scale.grey900, 22 | backgroundColor: color.$semantic.bgDefault, 23 | whiteSpace: "break-spaces", 24 | }, 25 | ]); 26 | 27 | export const commandInputContainer = style([widthFull]); 28 | 29 | export const commandInput = style({ 30 | outline: 0, 31 | wordBreak: "break-all", 32 | }); 33 | -------------------------------------------------------------------------------- /packages/frontend/src/components/terminal/Terminal.tsx: -------------------------------------------------------------------------------- 1 | import { type KeyboardEventHandler, forwardRef } from "react"; 2 | 3 | import { ENTER_KEY } from "../../constants/event"; 4 | import type { TerminalContentType } from "../../types/terminalType"; 5 | 6 | import CommandInput, { CommandInputForwardRefType } from "./CommandInput"; 7 | import * as styles from "./Terminal.css"; 8 | import TerminalContent from "./TerminalContent"; 9 | 10 | interface TerminalProps { 11 | gitRef: string; 12 | contentArray: TerminalContentType[]; 13 | onTerminal: (input: string) => void; 14 | } 15 | 16 | const Terminal = forwardRef( 17 | ({ gitRef, contentArray, onTerminal }, ref) => { 18 | const handleStandardInput: KeyboardEventHandler = async (event) => { 19 | const { 20 | key, 21 | currentTarget, 22 | nativeEvent: { isComposing }, 23 | } = event; 24 | if (isComposing || key !== ENTER_KEY) { 25 | return; 26 | } 27 | 28 | event.preventDefault(); 29 | 30 | const value = (currentTarget.textContent ?? "").trim(); 31 | if (!value) { 32 | return; 33 | } 34 | 35 | onTerminal(value); 36 | }; 37 | 38 | return ( 39 |
40 | 41 | 46 |
47 | ); 48 | }, 49 | ); 50 | 51 | Terminal.displayName = "Terminal"; 52 | 53 | export default Terminal; 54 | -------------------------------------------------------------------------------- /packages/frontend/src/components/terminal/TerminalContent.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | StandardInputType, 3 | StandardOutputType, 4 | TerminalContentType, 5 | } from "../../types/terminalType"; 6 | 7 | import Prompt from "./Prompt"; 8 | 9 | interface TerminalContentProps { 10 | contentArray: TerminalContentType[]; 11 | } 12 | 13 | export default function TerminalContent({ 14 | contentArray, 15 | }: TerminalContentProps) { 16 | const content = contentArray.map(toTerminalContentComponent); 17 | return
{content}
; 18 | } 19 | 20 | function StandardInputContent({ 21 | content, 22 | gitRef, 23 | }: Omit) { 24 | return ( 25 |
26 | 27 | {content} 28 |
29 | ); 30 | } 31 | 32 | function StandardOutputContent({ content }: Omit) { 33 | return ( 34 |
35 | {content} 36 |
37 | ); 38 | } 39 | 40 | function toTerminalContentComponent( 41 | propsWithType: TerminalContentType, 42 | index: number, 43 | ) { 44 | const key = `${propsWithType.type} ${index}`; 45 | switch (propsWithType.type) { 46 | case "stdin": { 47 | const { type, ...props } = propsWithType; 48 | return ; 49 | } 50 | case "stdout": { 51 | const { type, ...props } = propsWithType; 52 | return ; 53 | } 54 | default: { 55 | return null; 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/frontend/src/components/terminal/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Terminal } from "./Terminal"; 2 | -------------------------------------------------------------------------------- /packages/frontend/src/constants/event.ts: -------------------------------------------------------------------------------- 1 | export const ESC_KEY = "Escape"; 2 | export const ENTER_KEY = "Enter"; 3 | -------------------------------------------------------------------------------- /packages/frontend/src/constants/path.ts: -------------------------------------------------------------------------------- 1 | const BROWSWER_PATH = { 2 | MAIN: "/", 3 | QUIZZES: "/quizzes", 4 | SHARE: "/share", 5 | NOT_FOUND: "/404", 6 | } as const; 7 | 8 | const API_PATH = { 9 | QUIZZES: "/quizzes", 10 | SESSION: "/session", 11 | } as const; 12 | 13 | const GIT_BOOK_URL = "https://git-scm.com/docs/git"; 14 | 15 | export { BROWSWER_PATH, API_PATH, GIT_BOOK_URL }; 16 | -------------------------------------------------------------------------------- /packages/frontend/src/contexts/UserQuizStatusContext/UserQuizStatusContext.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type Dispatch, 3 | ReactNode, 4 | createContext, 5 | useContext, 6 | useEffect, 7 | useReducer, 8 | } from "react"; 9 | 10 | import { UserQuizStatus } from "../../types/user"; 11 | import { objectKeys } from "../../utils/types"; 12 | 13 | import { Action, UserQuizStatusActionType } from "./type"; 14 | 15 | const UserQuizStatusContext = createContext({}); 16 | const UserQuizStatusDispatchContext = createContext>(() => {}); 17 | 18 | interface UserQuizStatusProviderProps { 19 | initialUserQuizStatus: UserQuizStatus; 20 | children: ReactNode; 21 | } 22 | 23 | export function UserQuizStatusProvider({ 24 | initialUserQuizStatus, 25 | children, 26 | }: UserQuizStatusProviderProps) { 27 | const [userQuizStatus, dispatch] = useReducer(reducer, initialUserQuizStatus); 28 | 29 | useEffect(() => { 30 | dispatch({ 31 | type: UserQuizStatusActionType.Initialize, 32 | data: initialUserQuizStatus, 33 | }); 34 | }, [initialUserQuizStatus]); 35 | 36 | return ( 37 | 38 | 39 | {children} 40 | 41 | 42 | ); 43 | } 44 | 45 | export function useUserQuizStatus() { 46 | const userQuizStatus = useContext(UserQuizStatusContext); 47 | if (!userQuizStatus) { 48 | throw new Error("UserQuizStatusProvider 컴포넌트로 래핑해야 합니다."); 49 | } 50 | return userQuizStatus; 51 | } 52 | 53 | export function useUserQuizStatusDispatch() { 54 | const userQuizStatusDispatch = useContext(UserQuizStatusDispatchContext); 55 | if (!userQuizStatusDispatch) { 56 | throw new Error("UserQuizStatusProvider 컴포넌트로 래핑해야 합니다."); 57 | } 58 | return userQuizStatusDispatch; 59 | } 60 | 61 | function reducer(state: UserQuizStatus, action: Action): UserQuizStatus { 62 | switch (action.type) { 63 | case UserQuizStatusActionType.Initialize: 64 | return action.data; 65 | 66 | case UserQuizStatusActionType.Reset: 67 | return Object.fromEntries(objectKeys(state).map((key) => [key, false])); 68 | 69 | case UserQuizStatusActionType.ResetQuizById: 70 | return { ...state, [action.id]: false }; 71 | 72 | case UserQuizStatusActionType.SolveQuizById: 73 | return { ...state, [action.id]: true }; 74 | 75 | default: 76 | throw new Error(`${action} not supported`); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /packages/frontend/src/contexts/UserQuizStatusContext/index.ts: -------------------------------------------------------------------------------- 1 | export { UserQuizStatusActionType } from "./type"; 2 | export { 3 | UserQuizStatusProvider, 4 | useUserQuizStatus, 5 | useUserQuizStatusDispatch, 6 | } from "./UserQuizStatusContext"; 7 | -------------------------------------------------------------------------------- /packages/frontend/src/contexts/UserQuizStatusContext/type.ts: -------------------------------------------------------------------------------- 1 | import { UserQuizStatus } from "../../types/user"; 2 | 3 | export enum UserQuizStatusActionType { 4 | Initialize, 5 | Reset, 6 | ResetQuizById, 7 | SolveQuizById, 8 | } 9 | 10 | export type Action = 11 | | InitializeAction 12 | | ResetAction 13 | | ResetQuizByIdAction 14 | | SolveQuizByIdAction; 15 | 16 | type InitializeAction = { 17 | type: UserQuizStatusActionType.Initialize; 18 | data: UserQuizStatus; 19 | }; 20 | 21 | type ResetAction = { 22 | type: UserQuizStatusActionType.Reset; 23 | }; 24 | 25 | type ResetQuizByIdAction = { 26 | type: UserQuizStatusActionType.ResetQuizById; 27 | id: number; 28 | }; 29 | 30 | type SolveQuizByIdAction = { 31 | type: UserQuizStatusActionType.SolveQuizById; 32 | id: number; 33 | }; 34 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Accordion/Accordion.css.ts: -------------------------------------------------------------------------------- 1 | import { style, styleVariants } from "@vanilla-extract/css"; 2 | 3 | import color from "../../../tokens/color"; 4 | import typography from "../../../tokens/typography"; 5 | import { flex } from "../../../tokens/utils.css"; 6 | 7 | export const summaryText = { 8 | sm: typography.$semantic.caption1Regular, 9 | md: typography.$semantic.title3Bold, 10 | }; 11 | 12 | export const summaryColor = styleVariants({ 13 | black: { 14 | color: color.$scale.grey800, 15 | }, 16 | grey: { 17 | color: color.$scale.grey500, 18 | }, 19 | }); 20 | 21 | const summaryContainerBase = style([ 22 | flex, 23 | { 24 | justifyContent: "flex-start", 25 | alignItems: "center", 26 | cursor: "pointer", 27 | }, 28 | ]); 29 | 30 | export const summaryContainer = styleVariants({ 31 | sm: [ 32 | summaryContainerBase, 33 | { 34 | gap: 5, 35 | }, 36 | ], 37 | md: [ 38 | summaryContainerBase, 39 | { 40 | gap: 13, 41 | }, 42 | ], 43 | }); 44 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Accordion/Accordion.tsx: -------------------------------------------------------------------------------- 1 | import { type ReactNode } from "react"; 2 | 3 | import { AccordionContextProvider } from "./AccordionContextProvider"; 4 | import AccordionDetails from "./AccordionDetails"; 5 | import AccordionSummary from "./AccordionSummary"; 6 | 7 | interface AccordionProps { 8 | width?: number | string; 9 | open?: boolean; 10 | children: ReactNode; 11 | } 12 | 13 | export default function Accordion({ 14 | width = 200, 15 | open: initOpen = false, 16 | children, 17 | }: AccordionProps) { 18 | return ( 19 | 20 | {children} 21 | 22 | ); 23 | } 24 | 25 | Accordion.Details = AccordionDetails; 26 | Accordion.Summary = AccordionSummary; 27 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Accordion/AccordionContextProvider.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type ReactNode, 3 | createContext, 4 | useCallback, 5 | useContext, 6 | useEffect, 7 | useMemo, 8 | useState, 9 | } from "react"; 10 | 11 | export type AccordionContextType = { 12 | open: boolean; 13 | width: number | string; 14 | onChange: (open: boolean) => void; 15 | }; 16 | 17 | const AccordionContext = createContext(null); 18 | 19 | export function useAccordion() { 20 | const accordionData = useContext(AccordionContext); 21 | return accordionData; 22 | } 23 | 24 | export function AccordionContextProvider({ 25 | open: initOpen, 26 | width, 27 | children, 28 | }: { 29 | open: boolean; 30 | width: number | string; 31 | children: ReactNode; 32 | }) { 33 | const [open, setOpen] = useState(initOpen); 34 | 35 | const handleChange = useCallback( 36 | (nextOpen: boolean) => { 37 | setOpen(nextOpen); 38 | }, 39 | [setOpen], 40 | ); 41 | 42 | const accordionContextValue = useMemo( 43 | () => ({ open, onChange: handleChange, width }), 44 | [open, handleChange, width], 45 | ); 46 | 47 | useEffect(() => { 48 | setOpen(initOpen); 49 | }, [initOpen]); 50 | 51 | return ( 52 | 53 | {children} 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Accordion/AccordionDetails.tsx: -------------------------------------------------------------------------------- 1 | import { type ReactNode, useEffect, useRef } from "react"; 2 | 3 | import { useAccordion } from "./AccordionContextProvider"; 4 | 5 | interface AccordionDetailsProps { 6 | children: ReactNode; 7 | } 8 | 9 | export default function AccordionDetails({ children }: AccordionDetailsProps) { 10 | const detailsRef = useRef(null); 11 | const accordionContext = useAccordion(); 12 | if (!accordionContext) { 13 | throw new Error( 14 | "Accordion.Details 컴포넌트는 Accordion 컴포넌트로 래핑해야 합니다." 15 | ); 16 | } 17 | 18 | const { width, open, onChange } = accordionContext; 19 | 20 | useEffect(() => { 21 | if (!detailsRef.current) { 22 | return undefined; 23 | } 24 | 25 | const $details = detailsRef.current; 26 | 27 | const handleBeforeMatch = (event: Event) => { 28 | const { target } = event; 29 | if (!(target instanceof HTMLDetailsElement)) { 30 | return; 31 | } 32 | 33 | const detailsOpen = target.open; 34 | if (detailsOpen === open) { 35 | return; 36 | } 37 | 38 | onChange(detailsOpen); 39 | }; 40 | 41 | $details.addEventListener("toggle", handleBeforeMatch); 42 | return () => { 43 | $details.removeEventListener("toggle", handleBeforeMatch); 44 | }; 45 | }, [onChange, open]); 46 | 47 | return ( 48 |
49 | {children} 50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Accordion/AccordionSummary.tsx: -------------------------------------------------------------------------------- 1 | import { MouseEventHandler, type ReactNode } from "react"; 2 | 3 | import classnames from "../../../../utils/classnames"; 4 | 5 | import * as styles from "./Accordion.css"; 6 | import { 7 | type AccordionContextType, 8 | useAccordion, 9 | } from "./AccordionContextProvider"; 10 | import ChevronIcon from "./ChevronIcon/ChevronIcon"; 11 | 12 | interface AccordionSummaryProps { 13 | color?: "black" | "grey"; 14 | size?: "md" | "sm"; 15 | children: ReactNode | RenderComponentType; 16 | } 17 | 18 | type RenderComponentType = (props: AccordionContextType) => ReactNode; 19 | 20 | export default function AccordionSummary({ 21 | color = "black", 22 | size = "md", 23 | children, 24 | }: AccordionSummaryProps) { 25 | const accordionContext = useAccordion(); 26 | if (!accordionContext) { 27 | throw new Error( 28 | "Accordion.Summary 컴포넌트는 Accordion 컴포넌트로 래핑해야 합니다." 29 | ); 30 | } 31 | 32 | const summaryStyle = classnames( 33 | styles.summaryText[size], 34 | styles.summaryColor[color], 35 | styles.summaryContainer[size] 36 | ); 37 | 38 | const { open, onChange } = accordionContext; 39 | const chevronType = open ? "up" : "down"; 40 | 41 | const handleChange: MouseEventHandler = (event) => { 42 | event.preventDefault(); 43 | onChange(!open); 44 | }; 45 | 46 | return ( 47 | // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions 48 | 49 |
50 | {children instanceof Function ? children(accordionContext) : children} 51 |
52 |
53 | 54 |
55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Accordion/ChevronIcon/ChevronIcon.css.ts: -------------------------------------------------------------------------------- 1 | import { style, styleVariants } from "@vanilla-extract/css"; 2 | 3 | import { border, flexCenter } from "../../../../tokens/utils.css"; 4 | 5 | export const containerBase = style([ 6 | flexCenter, 7 | border.all, 8 | { 9 | borderRadius: "50%", 10 | }, 11 | ]); 12 | 13 | export const containerVariants = styleVariants({ 14 | sm: { 15 | width: 18, 16 | height: 18, 17 | }, 18 | md: { 19 | width: 25, 20 | height: 25, 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Accordion/ChevronIcon/ChevronIcon.tsx: -------------------------------------------------------------------------------- 1 | import { BsChevronDown, BsChevronUp } from "react-icons/bs"; 2 | 3 | import classnames from "../../../../../utils/classnames"; 4 | import color from "../../../../tokens/color"; 5 | 6 | import * as styles from "./ChevronIcon.css"; 7 | 8 | interface ChevronIconProps { 9 | size: "md" | "sm"; 10 | type: keyof typeof chevronIconMap; 11 | } 12 | 13 | export default function ChevronIcon({ type, size }: ChevronIconProps) { 14 | const containerStyle = classnames( 15 | styles.containerBase, 16 | styles.containerVariants[size] 17 | ); 18 | 19 | const Chevron = chevronIconMap[type]; 20 | 21 | return ( 22 |
23 | 24 |
25 | ); 26 | } 27 | 28 | const chevronIconMap = { 29 | up: BsChevronUp, 30 | down: BsChevronDown, 31 | }; 32 | 33 | const chevronSizeMap = { 34 | sm: 10, 35 | md: 14, 36 | }; 37 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Accordion/ChevronIcon/index.ts: -------------------------------------------------------------------------------- 1 | import ChevronIcon from "./ChevronIcon"; 2 | 3 | export default ChevronIcon; 4 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Accordion/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Accordion } from "./Accordion"; 2 | export { useAccordion } from "./AccordionContextProvider"; 3 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Badge/Badge.css.ts: -------------------------------------------------------------------------------- 1 | import { style, styleVariants } from "@vanilla-extract/css"; 2 | 3 | import color from "../../../tokens/color"; 4 | import typography from "../../../tokens/typography"; 5 | 6 | export const badgeBase = style([ 7 | typography.$semantic.caption2Regular, 8 | { 9 | height: 22, 10 | padding: "3px 7px", 11 | borderRadius: 5, 12 | }, 13 | ]); 14 | 15 | export const badgeVariants = styleVariants({ 16 | orange: { 17 | color: color.$semantic.badgeOrange, 18 | backgroundColor: color.$semantic.badgeOrangeBg, 19 | }, 20 | yellow: { 21 | color: color.$semantic.badgeYellow, 22 | backgroundColor: color.$semantic.badgeYellowBg, 23 | }, 24 | green: { 25 | color: color.$semantic.badgeGreen, 26 | backgroundColor: color.$semantic.badgeGreenBg, 27 | }, 28 | teal: { 29 | color: color.$semantic.badgeTeal, 30 | backgroundColor: color.$semantic.badgeTealBg, 31 | }, 32 | blue: { 33 | color: color.$semantic.badgeBlue, 34 | backgroundColor: color.$semantic.badgeBlueBg, 35 | }, 36 | purple: { 37 | color: color.$semantic.badgePurple, 38 | backgroundColor: color.$semantic.badgePurpleBg, 39 | }, 40 | }); 41 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Badge/Badge.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | import classnames from "../../../../utils/classnames"; 4 | import { objectKeys } from "../../../../utils/types"; 5 | 6 | import { badgeVariants } from "./Badge.css"; 7 | import * as styles from "./Badge.css"; 8 | 9 | export type BadgeVariantType = keyof typeof badgeVariants; 10 | 11 | export interface BadgeProps { 12 | variant: BadgeVariantType; 13 | children: ReactNode; 14 | } 15 | 16 | export const badgeVariantList = objectKeys(badgeVariants); 17 | 18 | export function Badge({ variant, children }: BadgeProps) { 19 | const badgeStyle = classnames( 20 | styles.badgeBase, 21 | styles.badgeVariants[variant], 22 | ); 23 | return {children}; 24 | } 25 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Badge/index.ts: -------------------------------------------------------------------------------- 1 | export { Badge, badgeVariantList } from "./Badge"; 2 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Button/Button.css.ts: -------------------------------------------------------------------------------- 1 | import { style, styleVariants } from "@vanilla-extract/css"; 2 | 3 | import color from "../../../tokens/color"; 4 | import typography from "../../../tokens/typography"; 5 | import { borderRadius } from "../../../tokens/utils.css"; 6 | 7 | export const buttonBase = style([ 8 | typography.$semantic.title4Regular, 9 | borderRadius, 10 | { 11 | height: 42, 12 | border: "1px solid transparent", 13 | padding: "8px 13px", 14 | 15 | ":disabled": { 16 | borderColor: color.$semantic.bgDisabled, 17 | color: color.$semantic.textDisabled, 18 | backgroundColor: color.$semantic.bgDisabled, 19 | }, 20 | }, 21 | ]); 22 | 23 | export const buttonVariants = styleVariants({ 24 | primaryFill: { 25 | color: color.$semantic.textWhite, 26 | backgroundColor: color.$semantic.primary, 27 | 28 | selectors: { 29 | "&:hover:not(:disabled)": { 30 | backgroundColor: color.$semantic.primaryHover, 31 | }, 32 | }, 33 | }, 34 | 35 | secondaryFill: { 36 | color: color.$semantic.textWhite, 37 | backgroundColor: color.$semantic.secondary, 38 | 39 | selectors: { 40 | "&:hover:not(:disabled)": { 41 | backgroundColor: color.$semantic.secondaryHover, 42 | }, 43 | }, 44 | }, 45 | 46 | primaryLine: { 47 | border: `1px solid ${color.$semantic.primary}`, 48 | color: color.$semantic.primary, 49 | backgroundColor: color.$semantic.bgWhite, 50 | 51 | selectors: { 52 | "&:hover:not(:disabled)": { 53 | backgroundColor: color.$semantic.primaryLow, 54 | }, 55 | }, 56 | }, 57 | 58 | secondaryLine: { 59 | border: `1px solid ${color.$semantic.secondary}`, 60 | color: color.$semantic.secondary, 61 | backgroundColor: color.$semantic.bgWhite, 62 | 63 | selectors: { 64 | "&:hover:not(:disabled)": { 65 | color: color.$semantic.secondary, 66 | backgroundColor: color.$semantic.secondaryLow, 67 | }, 68 | }, 69 | }, 70 | 71 | primaryLow: { 72 | color: color.$semantic.primary, 73 | backgroundColor: color.$semantic.primaryLow, 74 | 75 | selectors: { 76 | "&:hover:not(:disabled)": { 77 | backgroundColor: color.$semantic.primaryLowHover, 78 | }, 79 | }, 80 | }, 81 | 82 | secondaryLow: { 83 | color: color.$semantic.secondary, 84 | backgroundColor: color.$semantic.secondaryLow, 85 | 86 | selectors: { 87 | "&:hover:not(:disabled)": { 88 | backgroundColor: color.$semantic.secondaryLowHover, 89 | }, 90 | }, 91 | }, 92 | }); 93 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Button/Button.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from "@storybook/react"; 2 | 3 | import { Button } from "./Button"; 4 | 5 | const meta: Meta = { 6 | title: "Button", 7 | component: Button, 8 | argTypes: { 9 | onClick: { action: "clicked" }, 10 | }, 11 | }; 12 | 13 | export default meta; 14 | type Story = StoryObj; 15 | 16 | export const Variants: Story = { 17 | args: { 18 | variant: "primaryFill", 19 | children: "variants", 20 | }, 21 | }; 22 | 23 | export const FullWidth: Story = { 24 | args: { 25 | variant: "primaryFill", 26 | children: "full width", 27 | full: true, 28 | }, 29 | }; 30 | 31 | export const Disabled: Story = { 32 | args: { 33 | variant: "primaryFill", 34 | children: "disabled", 35 | disabled: true, 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import type { ButtonHTMLAttributes, ReactNode } from "react"; 2 | 3 | import classnames from "../../../../utils/classnames"; 4 | import { widthFull } from "../../../tokens/utils.css"; 5 | 6 | import * as styles from "./Button.css"; 7 | 8 | export type ButtonVariantType = keyof typeof styles.buttonVariants; 9 | 10 | export interface ButtonProps 11 | extends Pick< 12 | ButtonHTMLAttributes, 13 | "type" | "disabled" | "onClick" 14 | > { 15 | full?: boolean; 16 | variant: ButtonVariantType; 17 | children: ReactNode; 18 | className?: string; 19 | } 20 | 21 | export function Button({ 22 | full = false, 23 | variant, 24 | children, 25 | type = "button", 26 | disabled = false, 27 | onClick, 28 | className = "", 29 | }: ButtonProps) { 30 | const buttonStyle = classnames( 31 | styles.buttonBase, 32 | styles.buttonVariants[variant], 33 | full ? widthFull : "", 34 | className, 35 | ); 36 | 37 | return ( 38 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Button/index.ts: -------------------------------------------------------------------------------- 1 | import { Button } from "./Button"; 2 | 3 | export default Button; 4 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/CodeBlock/CodeBlock.css.ts: -------------------------------------------------------------------------------- 1 | import { globalStyle, style } from "@vanilla-extract/css"; 2 | 3 | import color from "../../../tokens/color"; 4 | import { borderRadius } from "../../../tokens/utils.css"; 5 | 6 | export const container = style([ 7 | borderRadius, 8 | { 9 | backgroundColor: color.$semantic.bgAlt, 10 | width: "100%", 11 | padding: "23px 25px", 12 | marginBottom: 19, 13 | whiteSpace: "break-spaces", 14 | color: color.$scale.grey700, 15 | }, 16 | ]); 17 | 18 | globalStyle(`${container} code`, { 19 | color: color.$scale.coral500, 20 | }); 21 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/CodeBlock/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | import { container } from "./CodeBlock.css"; 2 | 3 | interface CodeBlockProps { 4 | code: string[]; 5 | className?: string; 6 | } 7 | 8 | export function CodeBlock({ code, className = "" }: CodeBlockProps) { 9 | return ( 10 |

16 | ); 17 | } 18 | 19 | function toCodeTag(code: string) { 20 | return code.replaceAll(/`(.*?)`/g, "$1"); 21 | } 22 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/CodeBlock/index.ts: -------------------------------------------------------------------------------- 1 | export { CodeBlock } from "./CodeBlock"; 2 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Footer/Footer.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from "@vanilla-extract/css"; 2 | 3 | import color from "../../../tokens/color"; 4 | import typography from "../../../tokens/typography"; 5 | import { border, flex } from "../../../tokens/utils.css"; 6 | 7 | export const container = style([ 8 | border.top, 9 | { 10 | backgroundColor: color.$semantic.bgDefault, 11 | }, 12 | ]); 13 | 14 | export const content = style([ 15 | flex, 16 | { 17 | justifyContent: "space-between", 18 | alignItems: "flex-end", 19 | marginTop: 13, 20 | }, 21 | ]); 22 | 23 | export const teamName = style([ 24 | typography.$semantic.title2Bold, 25 | { 26 | color: color.$scale.grey800, 27 | }, 28 | ]); 29 | 30 | export const teamInfo = style([ 31 | typography.$semantic.caption1Regular, 32 | { 33 | flex: 1, 34 | color: color.$scale.grey600, 35 | }, 36 | ]); 37 | 38 | export const contact = style({ 39 | color: color.$scale.grey600, 40 | }); 41 | 42 | export const hr = style([ 43 | border.top, 44 | { 45 | margin: "20px 0", 46 | }, 47 | ]); 48 | 49 | export const rightsContainer = style([ 50 | typography.$semantic.caption1Regular, 51 | { 52 | position: "relative", 53 | textAlign: "center", 54 | color: color.$scale.grey500, 55 | }, 56 | ]); 57 | 58 | export const rights = style({ position: "absolute", left: 0 }); 59 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Footer/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { AiFillGithub } from "react-icons/ai"; 2 | 3 | import { footer as footerLayout } from "../../../tokens/layout.css"; 4 | 5 | import * as styles from "./Footer.css"; 6 | 7 | export default function Footer() { 8 | return ( 9 |

10 |
11 | Merge Masters 12 |
13 |
14 |
Team : Merge Master
15 |
16 | Contact :{" "} 17 | 22 | Issues 23 | 24 |
25 |
26 |
27 | 32 | 33 | 34 |
35 |
36 |
37 |
38 | © 2023 All rights reserved 39 | 해당 웹사이트는 Chrome에 최적화되어 있습니다. 40 |
41 |
42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Footer/index.ts: -------------------------------------------------------------------------------- 1 | import Footer from "./Footer"; 2 | 3 | export default Footer; 4 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Header/Header.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from "@vanilla-extract/css"; 2 | 3 | import color from "../../../tokens/color"; 4 | import { border, flexAlignCenter, widthMax } from "../../../tokens/utils.css"; 5 | 6 | export const borderBottom = border.bottom; 7 | 8 | export const container = style([ 9 | flexAlignCenter, 10 | widthMax, 11 | { 12 | height: "100%", 13 | justifyContent: "space-between", 14 | margin: "0 auto", 15 | backgroundColor: color.$semantic.bgDefault, 16 | }, 17 | ]); 18 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | 4 | import { ColorTheme } from "../../../../hooks/useTheme"; 5 | import classnames from "../../../../utils/classnames"; 6 | import { header as headerLayout } from "../../../tokens/layout.css"; 7 | import { useThemeContext } from "../Theme/ThemeContext"; 8 | import ThemeSelect from "../Theme/ThemeSelect"; 9 | 10 | import * as styles from "./Header.css"; 11 | 12 | export default function Header() { 13 | const headerStyle = classnames(styles.borderBottom, headerLayout); 14 | const { colorTheme } = useThemeContext(); 15 | 16 | const imgMap = { 17 | light: "/light-logo.svg", 18 | dark: "/dark-logo.svg", 19 | }[colorTheme as ColorTheme]; 20 | 21 | return ( 22 |
23 |
24 |

25 | 26 | Git Challenge 27 | 28 |

29 | 30 |
31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Header/index.ts: -------------------------------------------------------------------------------- 1 | import Header from "./Header"; 2 | 3 | export default Header; 4 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/IconButton/IconButton.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from "@vanilla-extract/css"; 2 | 3 | import { flex } from "../../../tokens/utils.css"; 4 | 5 | export const button = style([ 6 | flex, 7 | { 8 | padding: 0, 9 | backgroundColor: "transparent", 10 | border: "none", 11 | }, 12 | ]); 13 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/IconButton/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonHTMLAttributes, ReactNode } from "react"; 2 | 3 | import classnames from "../../../../utils/classnames"; 4 | 5 | import * as styles from "./IconButton.css"; 6 | 7 | export interface IconButtonProps 8 | extends Pick, "onClick"> { 9 | className?: string; 10 | children: ReactNode; 11 | } 12 | 13 | export default function IconButton({ 14 | className = "", 15 | children, 16 | onClick, 17 | }: IconButtonProps) { 18 | const iconButtonStyle = classnames(styles.button, className); 19 | return ( 20 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/IconButton/index.ts: -------------------------------------------------------------------------------- 1 | import IconButton from "./IconButton"; 2 | 3 | export default IconButton; 4 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Info/Info.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from "@vanilla-extract/css"; 2 | 3 | import color from "../../../tokens/color"; 4 | import typography from "../../../tokens/typography"; 5 | import { flexAlignCenter } from "../../../tokens/utils.css"; 6 | 7 | export const container = style([ 8 | flexAlignCenter, 9 | typography.$semantic.caption1Regular, 10 | { 11 | color: color.$scale.grey600, 12 | gap: 5, 13 | }, 14 | ]); 15 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Info/Info.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { FaInfoCircle } from "react-icons/fa"; 3 | 4 | import classnames from "../../../../utils/classnames"; 5 | 6 | import * as styles from "./Info.css"; 7 | 8 | interface InfoProps { 9 | className?: string; 10 | children: ReactNode; 11 | } 12 | export default function Info({ className = "", children }: InfoProps) { 13 | return ( 14 |
15 | 16 |

{children}

17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Info/index.ts: -------------------------------------------------------------------------------- 1 | import Info from "./Info"; 2 | 3 | export default Info; 4 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | 3 | import * as layout from "../../../tokens/layout.css"; 4 | import { Header, SideBar } from "../index"; 5 | 6 | interface LayoutProps { 7 | children: ReactElement; 8 | } 9 | export default function Layout({ children }: LayoutProps) { 10 | return ( 11 | <> 12 |
13 |
14 | 15 |
{children}
16 |
17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Layout/index.ts: -------------------------------------------------------------------------------- 1 | import Layout from "./Layout"; 2 | 3 | export default Layout; 4 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/LinkButton/LinkButton.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import { MouseEventHandler } from "react"; 3 | 4 | import Button from "../Button"; 5 | import { ButtonProps } from "../Button/Button"; 6 | 7 | export interface LinkButtonProps extends Omit { 8 | path: string; 9 | } 10 | 11 | export function LinkButton({ path, children, ...rest }: LinkButtonProps) { 12 | const router = useRouter(); 13 | 14 | const handleRoute: MouseEventHandler = () => { 15 | router.push(path); 16 | }; 17 | 18 | return ( 19 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/LinkButton/index.ts: -------------------------------------------------------------------------------- 1 | import { LinkButton } from "./LinkButton"; 2 | 3 | export default LinkButton; 4 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Modal/Modal.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from "@vanilla-extract/css"; 2 | 3 | import color from "../../../tokens/color"; 4 | import { 5 | borderRadius, 6 | boxShadow, 7 | flexAlignCenter, 8 | flexCenter, 9 | flexColumn, 10 | modalLayer, 11 | } from "../../../tokens/utils.css"; 12 | 13 | export const backdrop = style([ 14 | modalLayer, 15 | flexCenter, 16 | { 17 | position: "fixed", 18 | top: 0, 19 | left: 0, 20 | width: "100vw", 21 | height: "100vh", 22 | backgroundColor: "rgba(0, 0, 0, 0.6)", 23 | }, 24 | ]); 25 | 26 | export const container = style([ 27 | boxShadow, 28 | flexColumn, 29 | flexAlignCenter, 30 | borderRadius, 31 | { 32 | backgroundColor: color.$semantic.bgDefault, 33 | padding: "48px 27px 40px 27px", 34 | position: "relative", 35 | }, 36 | ]); 37 | 38 | export const close = style({ 39 | color: color.$scale.grey900, 40 | fontSize: 40, 41 | position: "absolute", 42 | right: 20, 43 | top: 20, 44 | }); 45 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Modal/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useEffect } from "react"; 2 | import { createPortal } from "react-dom"; 3 | import { IoCloseOutline } from "react-icons/io5"; 4 | 5 | import { ESC_KEY } from "../../../../constants/event"; 6 | import useMount from "../../../../hooks/useMount"; 7 | import { preventBubbling } from "../../../../utils/event"; 8 | import IconButton from "../IconButton/IconButton"; 9 | 10 | import * as styles from "./Modal.css"; 11 | 12 | export interface ModalProps { 13 | onClose: () => void; 14 | children: ReactNode; 15 | } 16 | 17 | const setScrollLock = (isLock: boolean) => { 18 | document.body.style.overflow = isLock ? "hidden" : "auto"; 19 | }; 20 | 21 | export default function Modal({ onClose, children }: ModalProps) { 22 | const { mounted } = useMount(); 23 | 24 | useEffect(() => { 25 | const escKeyCloseEvent = (event: KeyboardEvent) => { 26 | if (event.key === ESC_KEY) { 27 | onClose(); 28 | } 29 | }; 30 | setScrollLock(true); 31 | window.addEventListener("keydown", escKeyCloseEvent); 32 | return () => { 33 | window.removeEventListener("keydown", escKeyCloseEvent); 34 | setScrollLock(false); 35 | }; 36 | }, [onClose]); 37 | 38 | if (!mounted) return null; 39 | 40 | return createPortal( 41 |
48 |
55 | 56 | 57 | 58 | {children} 59 |
60 |
, 61 | document.getElementById("modal") as HTMLElement, 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Modal/index.ts: -------------------------------------------------------------------------------- 1 | import Modal from "./Modal"; 2 | 3 | export default Modal; 4 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/SideBar/GitHelpAccordian.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { useRouter } from "next/router"; 3 | 4 | import { BROWSWER_PATH } from "../../../../constants/path"; 5 | import { Accordion } from "../Accordion"; 6 | 7 | import { gitHelpNavigation } from "./nav"; 8 | import * as styles from "./SideBar.css"; 9 | 10 | export default function GitHelpAccordian() { 11 | const { pathname } = useRouter(); 12 | const current = pathname === BROWSWER_PATH.MAIN; 13 | return ( 14 | 15 | 16 | {gitHelpNavigation.title} 17 |
18 |
19 | 23 | {gitHelpNavigation.subTitle} 24 | 25 |
26 |
27 |
28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/SideBar/SideBar.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from "@vanilla-extract/css"; 2 | 3 | import color from "../../../tokens/color"; 4 | import typography from "../../../tokens/typography"; 5 | import { border, flexAlignCenter, flexColumn } from "../../../tokens/utils.css"; 6 | 7 | export const linkContainerStyle = style([ 8 | flexColumn, 9 | border.top, 10 | { 11 | paddingTop: 10, 12 | marginTop: 10, 13 | }, 14 | ]); 15 | 16 | export const linkItemStyle = style({ 17 | height: 40, 18 | selectors: { 19 | "&:hover": { 20 | borderRadius: 8, 21 | backgroundColor: color.$semantic.bgAlt, 22 | }, 23 | }, 24 | }); 25 | 26 | export const baseLinkStyle = style([ 27 | flexAlignCenter, 28 | typography.$semantic.title4Regular, 29 | { 30 | width: "100%", 31 | height: "100%", 32 | paddingLeft: 15, 33 | textDecoration: "none", 34 | gap: 2, 35 | }, 36 | ]); 37 | 38 | export const currentLinkStyle = style([ 39 | baseLinkStyle, 40 | { 41 | color: color.$scale.grey900, 42 | }, 43 | ]); 44 | 45 | export const linkStyle = style([ 46 | baseLinkStyle, 47 | { 48 | color: color.$scale.grey600, 49 | }, 50 | ]); 51 | 52 | export const checkIcon = style({ 53 | color: color.$semantic.success, 54 | }); 55 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/SideBar/SideBar.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { useRouter } from "next/router"; 3 | import { IoMdCheckmark } from "react-icons/io"; 4 | 5 | import { BROWSWER_PATH } from "../../../../constants/path"; 6 | import { useUserQuizStatus } from "../../../../contexts/UserQuizStatusContext"; 7 | import * as layout from "../../../tokens/layout.css"; 8 | import { Accordion } from "../Accordion"; 9 | 10 | import GitHelpAccordian from "./GitHelpAccordian"; 11 | import { sidebarNavigation } from "./nav"; 12 | import * as styles from "./SideBar.css"; 13 | 14 | export default function SideBar() { 15 | return ( 16 | 27 | ); 28 | } 29 | 30 | interface SubTitleListProps { 31 | subItems: { 32 | id: number; 33 | subTitle: string; 34 | }[]; 35 | } 36 | 37 | function SubTitleList({ subItems }: SubTitleListProps) { 38 | const { 39 | query: { id }, 40 | } = useRouter(); 41 | const userQuizStatus = useUserQuizStatus(); 42 | 43 | const idNum = id ? +id : 0; 44 | 45 | return ( 46 |
    47 | {subItems.map(({ subTitle, id: subItemId }) => ( 48 | 55 | ))} 56 |
57 | ); 58 | } 59 | 60 | interface SubTitleItemProps { 61 | id: number; 62 | subTitle: string; 63 | current: boolean; 64 | solved: boolean; 65 | } 66 | 67 | function SubTitleItem({ subTitle, id, current, solved }: SubTitleItemProps) { 68 | return ( 69 |
  • 70 | 74 | {subTitle} 75 | {solved && } 76 | 77 |
  • 78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/SideBar/index.tsx: -------------------------------------------------------------------------------- 1 | import SideBar from "./SideBar"; 2 | 3 | export default SideBar; 4 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/SideBar/nav.ts: -------------------------------------------------------------------------------- 1 | export const gitHelpNavigation = { 2 | title: "$ Git Help", 3 | subTitle: "명령어 이해하기", 4 | }; 5 | 6 | export const sidebarNavigation = [ 7 | { 8 | id: 1, 9 | title: "Git Start", 10 | subItems: [ 11 | { 12 | id: 1, 13 | subTitle: "git 시작하기", 14 | }, 15 | { 16 | id: 2, 17 | subTitle: "내 정보 설정하기", 18 | }, 19 | { 20 | id: 3, 21 | subTitle: "파일 스테이징 하기", 22 | }, 23 | { 24 | id: 4, 25 | subTitle: "커밋하기", 26 | }, 27 | { 28 | id: 5, 29 | subTitle: "브랜치 만들기", 30 | }, 31 | { 32 | id: 6, 33 | subTitle: "브랜치 바꾸기", 34 | }, 35 | ], 36 | }, 37 | { 38 | id: 2, 39 | title: "Git Advanced", 40 | subItems: [ 41 | { 42 | id: 7, 43 | subTitle: "커밋 메시지 수정하기", 44 | }, 45 | { 46 | id: 8, 47 | subTitle: "커밋 취소하기", 48 | }, 49 | { 50 | id: 9, 51 | subTitle: "파일 되돌리기", 52 | }, 53 | { 54 | id: 10, 55 | subTitle: "파일 삭제하기", 56 | }, 57 | { 58 | id: 11, 59 | subTitle: "변경 사항 저장하기", 60 | }, 61 | { 62 | id: 12, 63 | subTitle: "커밋 가져오기", 64 | }, 65 | { 66 | id: 13, 67 | subTitle: "커밋 이력 조작하기", 68 | }, 69 | { 70 | id: 14, 71 | subTitle: "변경 사항 되돌리기", 72 | }, 73 | ], 74 | }, 75 | { 76 | id: 3, 77 | title: "Remote Start", 78 | subItems: [ 79 | { 80 | id: 15, 81 | subTitle: "원격 저장소 등록하기", 82 | }, 83 | { 84 | id: 16, 85 | subTitle: "브랜치 생성하고 이동하기", 86 | }, 87 | { 88 | id: 17, 89 | subTitle: "브랜치 최신화하기", 90 | }, 91 | { 92 | id: 18, 93 | subTitle: "원격 저장소로 보내기", 94 | }, 95 | { 96 | id: 19, 97 | subTitle: "브랜치 삭제하기", 98 | }, 99 | ], 100 | }, 101 | ]; 102 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Theme/ThemeContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import * as React from "react"; 3 | 4 | import useTheme from "../../../../hooks/useTheme"; 5 | 6 | export type ThemeContextType = ReturnType; 7 | 8 | const initialContext: ThemeContextType = { 9 | colorTheme: "light", 10 | setColorTheme: () => {}, 11 | }; 12 | 13 | export const ThemeContext = createContext(initialContext); 14 | 15 | ThemeContext.displayName = "ThemeContext"; 16 | 17 | export function useThemeContext(): ThemeContextType { 18 | return React.useContext(ThemeContext); 19 | } 20 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Theme/ThemeSelect.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from "@vanilla-extract/css"; 2 | 3 | import color from "../../../tokens/color"; 4 | import typography from "../../../tokens/typography"; 5 | import { 6 | border, 7 | borderRadius, 8 | flexAlignCenter, 9 | flexCenter, 10 | flexColumn, 11 | widthFull, 12 | } from "../../../tokens/utils.css"; 13 | 14 | const selectWidth = 106; 15 | const selectPadding = 10; 16 | const iconGap = 5; 17 | 18 | export const selectContainer = style([ 19 | { 20 | position: "relative", 21 | marginRight: 15, 22 | }, 23 | ]); 24 | 25 | export const selectedItem = style([ 26 | flexAlignCenter, 27 | { 28 | gap: iconGap, 29 | }, 30 | ]); 31 | 32 | export const select = style([ 33 | border.all, 34 | borderRadius, 35 | flexAlignCenter, 36 | typography.$semantic.body2Regular, 37 | { 38 | justifyContent: "space-between", 39 | width: selectWidth, 40 | height: 36, 41 | backgroundColor: color.$semantic.bgDefault, 42 | color: color.$scale.grey700, 43 | padding: selectPadding, 44 | transition: "border 0.2s ease", 45 | ":hover": { 46 | border: `1px solid ${color.$scale.grey600}`, 47 | }, 48 | }, 49 | ]); 50 | 51 | export const optionList = style([ 52 | flexColumn, 53 | flexCenter, 54 | border.all, 55 | { 56 | position: "absolute", 57 | top: 40, 58 | right: 0, 59 | width: selectWidth, 60 | backgroundColor: color.$scale.grey00, 61 | borderRadius: "6px", 62 | paddingInlineStart: 0, 63 | padding: "6px 0px", 64 | gap: "6px", 65 | }, 66 | ]); 67 | 68 | export const option = style([ 69 | flexAlignCenter, 70 | { 71 | cursor: "pointer", 72 | width: "90%", 73 | height: "28px", 74 | borderRadius: 4, 75 | padding: 4, 76 | ":hover": { 77 | backgroundColor: color.$scale.grey50, 78 | }, 79 | }, 80 | ]); 81 | 82 | export const optionButton = style([ 83 | flexAlignCenter, 84 | widthFull, 85 | typography.$semantic.body2Regular, 86 | { 87 | height: "100%", 88 | padding: 0, 89 | margin: 0, 90 | color: color.$scale.grey700, 91 | border: "none", 92 | backgroundColor: "transparent", 93 | gap: iconGap, 94 | }, 95 | ]); 96 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Theme/ThemeWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | 3 | import useTheme from "../../../../hooks/useTheme"; 4 | 5 | import { ThemeContext } from "./ThemeContext"; 6 | 7 | interface ThemeWrapperProps { 8 | children: ReactElement; 9 | } 10 | 11 | export default function ThemeWrapper({ children }: ThemeWrapperProps) { 12 | const theme = useTheme(); 13 | 14 | return ( 15 | {children} 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Toast/Toast.css.ts: -------------------------------------------------------------------------------- 1 | import { globalStyle } from "@vanilla-extract/css"; 2 | 3 | import color from "../../../tokens/color"; 4 | 5 | globalStyle(".Toastify .Toastify__toast", { 6 | display: "block", 7 | width: "max-content", 8 | height: 46, 9 | minHeight: 46, 10 | maxHeight: 46, 11 | marginLeft: "auto", 12 | marginRight: "auto", 13 | borderRadius: 8, 14 | padding: "12px 20px", 15 | textAlign: "center", 16 | color: color.$scale.grey00, 17 | backgroundColor: color.$scale.grey800, 18 | }); 19 | 20 | globalStyle(".Toastify .Toastify__toast-body", { 21 | display: "flex", 22 | justifyContent: "center", 23 | alignItems: "center", 24 | padding: 0, 25 | }); 26 | 27 | globalStyle(".Toastify .Toastify__toast-icon", { 28 | width: 22, 29 | marginRight: 8, 30 | }); 31 | 32 | globalStyle(".Toastify .Toastify__toast-body>div:last-child", { 33 | flex: "0 0 auto", 34 | }); 35 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Toast/ToastContainer.tsx: -------------------------------------------------------------------------------- 1 | import { ToastContainer as BaseToastContainer, Slide } from "react-toastify"; 2 | 3 | import typography from "../../../tokens/typography"; 4 | 5 | import "./Toast.css"; 6 | 7 | export default function ToastContainer() { 8 | return ( 9 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Toast/ToastIcon.css.ts: -------------------------------------------------------------------------------- 1 | import { style, styleVariants } from "@vanilla-extract/css"; 2 | 3 | import color from "../../../tokens/color"; 4 | 5 | export const iconBase = style({ 6 | display: "flex", 7 | justifyContent: "center", 8 | alignItems: "center", 9 | width: 22, 10 | height: 22, 11 | color: color.$semantic.textWhite, 12 | borderRadius: "50%", 13 | }); 14 | 15 | export const iconVariants = styleVariants({ 16 | success: { backgroundColor: color.$semantic.success }, 17 | error: { backgroundColor: color.$semantic.danger }, 18 | }); 19 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Toast/ToastIcon.tsx: -------------------------------------------------------------------------------- 1 | import { BsX } from "react-icons/bs"; 2 | import { FaCheck } from "react-icons/fa"; 3 | 4 | import classnames from "../../../../utils/classnames"; 5 | import { block } from "../../../tokens/utils.css"; 6 | 7 | import * as styles from "./ToastIcon.css"; 8 | 9 | interface ToastIconProps { 10 | type: "error" | "success"; 11 | } 12 | 13 | export default function ToastIcon({ type }: ToastIconProps) { 14 | const { Icon, size } = iconMap[type]; 15 | return ( 16 |
    17 | 18 |
    19 | ); 20 | } 21 | 22 | ToastIcon.error = () => ; 23 | ToastIcon.success = () => ; 24 | 25 | const iconMap = { 26 | error: { Icon: BsX, size: 20 }, 27 | success: { Icon: FaCheck, size: 13 }, 28 | }; 29 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Toast/index.ts: -------------------------------------------------------------------------------- 1 | export { default as toast } from "./toast"; 2 | export { default as ToastContainer } from "./ToastContainer"; 3 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/Toast/toast.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ToastContent, 3 | type ToastOptions, 4 | toast as baseToast, 5 | } from "react-toastify"; 6 | 7 | import ToastIcon from "./ToastIcon"; 8 | 9 | export default function toast( 10 | content: ToastContent, 11 | options?: ToastOptions 12 | ) { 13 | return baseToast(content, options); 14 | } 15 | 16 | toast.success = (content: string, options?: ToastOptions) => 17 | toast(content, { 18 | ...options, 19 | icon: ToastIcon.success, 20 | }); 21 | 22 | toast.error = (content: string, options?: ToastOptions) => 23 | toast(content, { 24 | ...options, 25 | icon: ToastIcon.error, 26 | }); 27 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/components/common/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Header } from "./Header"; 2 | export { default as Button } from "./Button"; 3 | export { default as Modal } from "./Modal"; 4 | export { default as SideBar } from "./SideBar"; 5 | export { Badge, badgeVariantList } from "./Badge"; 6 | export { toast, ToastContainer } from "./Toast"; 7 | export { Accordion, useAccordion } from "./Accordion"; 8 | export { default as Footer } from "./Footer"; 9 | export { default as Info } from "./Info"; 10 | export { CodeBlock } from "./CodeBlock"; 11 | export { default as LinkButton } from "./LinkButton"; 12 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/styles/reset.css: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | div, 4 | span, 5 | applet, 6 | object, 7 | iframe, 8 | h1, 9 | h2, 10 | h3, 11 | h4, 12 | h5, 13 | h6, 14 | p, 15 | blockquote, 16 | pre, 17 | abbr, 18 | acronym, 19 | address, 20 | big, 21 | cite, 22 | code, 23 | del, 24 | dfn, 25 | em, 26 | img, 27 | ins, 28 | kbd, 29 | q, 30 | s, 31 | samp, 32 | small, 33 | strike, 34 | strong, 35 | sub, 36 | sup, 37 | tt, 38 | var, 39 | b, 40 | u, 41 | i, 42 | center, 43 | dl, 44 | dt, 45 | dd, 46 | ol, 47 | ul, 48 | li, 49 | fieldset, 50 | form, 51 | label, 52 | legend, 53 | table, 54 | caption, 55 | tbody, 56 | tfoot, 57 | thead, 58 | tr, 59 | th, 60 | td, 61 | article, 62 | aside, 63 | canvas, 64 | details, 65 | embed, 66 | figure, 67 | figcaption, 68 | footer, 69 | header, 70 | hgroup, 71 | menu, 72 | nav, 73 | output, 74 | ruby, 75 | section, 76 | summary, 77 | time, 78 | mark, 79 | audio, 80 | video { 81 | margin: 0; 82 | padding: 0; 83 | border: 0; 84 | font-size: 100%; 85 | font: inherit; 86 | vertical-align: baseline; 87 | } 88 | /* HTML5 display-role reset for older browsers */ 89 | article, 90 | aside, 91 | details, 92 | figcaption, 93 | figure, 94 | footer, 95 | header, 96 | hgroup, 97 | menu, 98 | nav, 99 | section { 100 | display: block; 101 | } 102 | body { 103 | line-height: 1; 104 | font-family: "Pretendard Variable", "Noto Sans KR", sans-serif; 105 | font-weight: 400; 106 | background-color: var(--mm-semantic-color-background-default); 107 | } 108 | a { 109 | color: inherit; 110 | } 111 | ol, 112 | ul { 113 | list-style: none; 114 | } 115 | blockquote, 116 | q { 117 | quotes: none; 118 | } 119 | blockquote:before, 120 | blockquote:after, 121 | q:before, 122 | q:after { 123 | content: ""; 124 | content: none; 125 | } 126 | table { 127 | border-collapse: collapse; 128 | border-spacing: 0; 129 | } 130 | 131 | text { 132 | font-size: 12px; 133 | fill: var(--mm-scale-color-grey-700); 134 | } 135 | 136 | button { 137 | cursor: pointer; 138 | } 139 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/tokens/layout.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from "@vanilla-extract/css"; 2 | 3 | import { 4 | flex, 5 | flexColumn, 6 | scrollBarHidden, 7 | topLayer, 8 | widthFull, 9 | widthMax, 10 | } from "./utils.css"; 11 | 12 | export const headerHeight = "56px"; 13 | const footerHeight = "250px"; 14 | 15 | export const header = style([ 16 | topLayer, 17 | widthFull, 18 | { 19 | height: headerHeight, 20 | position: "fixed", 21 | top: 0, 22 | left: 0, 23 | }, 24 | ]); 25 | 26 | export const base = style([ 27 | flex, 28 | widthMax, 29 | { 30 | height: "100vh", 31 | paddingTop: headerHeight, 32 | margin: "0 auto", 33 | }, 34 | ]); 35 | 36 | export const sideBar = style([ 37 | flexColumn, 38 | scrollBarHidden, 39 | { 40 | maxHeight: "100vh", 41 | width: 250, 42 | padding: "30px 0px", 43 | gap: 24, 44 | }, 45 | ]); 46 | 47 | export const container = style([ 48 | flexColumn, 49 | { 50 | width: 1030, 51 | }, 52 | ]); 53 | 54 | export const footer = style([ 55 | widthMax, 56 | { 57 | height: footerHeight, 58 | margin: "0 auto", 59 | padding: "45px 0", 60 | }, 61 | ]); 62 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/tokens/typography.ts: -------------------------------------------------------------------------------- 1 | const $semantic = { 2 | h1: "mm-semantic-typography-h1", 3 | h2: "mm-semantic-typography-h2", 4 | h3: "mm-semantic-typography-h3", 5 | title1Regular: "mm-semantic-typography-title1-regular", 6 | title1Bold: "mm-semantic-typography-title1-bold", 7 | title2Regular: "mm-semantic-typography-title2-regular", 8 | title2Bold: "mm-semantic-typography-title2-bold", 9 | title3Regular: "mm-semantic-typography-title3-regular", 10 | title3Bold: "mm-semantic-typography-title3-bold", 11 | title4Regular: "mm-semantic-typography-title4-regular", 12 | title4Bold: "mm-semantic-typography-title4-bold", 13 | body1Regular: "mm-semantic-typography-body1-regular", 14 | body1Bold: "mm-semantic-typography-body1-bold", 15 | body2Regular: "mm-semantic-typography-body2-regular", 16 | body2Bold: "mm-semantic-typography-body2-bold", 17 | caption1Regular: "mm-semantic-typography-caption1-regular", 18 | caption1Bold: "mm-semantic-typography-caption1-bold", 19 | caption2Regular: "mm-semantic-typography-caption2-regular", 20 | caption2Bold: "mm-semantic-typography-caption2-bold", 21 | code: "mm-semantic-typography-code", 22 | }; 23 | 24 | const typography = { $semantic }; 25 | export default typography; 26 | -------------------------------------------------------------------------------- /packages/frontend/src/design-system/tokens/utils.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from "@vanilla-extract/css"; 2 | 3 | import color from "./color"; 4 | 5 | export const widthMax = style({ maxWidth: 1280 }); 6 | export const widthFull = style({ width: "100%" }); 7 | export const backLayer = style({ zIndex: -1 }); 8 | export const baseLayer = style({ zIndex: 0 }); 9 | export const middleLayer = style({ zIndex: 50 }); 10 | export const topLayer = style({ zIndex: 100 }); 11 | export const modalLayer = style({ zIndex: 1000 }); 12 | export const block = style({ 13 | display: "block", 14 | }); 15 | export const flex = style({ 16 | display: "flex", 17 | }); 18 | export const flexColumn = style([ 19 | flex, 20 | { 21 | flexDirection: "column", 22 | }, 23 | ]); 24 | export const flexAlignCenter = style([ 25 | flex, 26 | { 27 | alignItems: "center", 28 | }, 29 | ]); 30 | export const flexJustifyCenter = style([ 31 | flex, 32 | { 33 | justifyContent: "center", 34 | }, 35 | ]); 36 | export const flexCenter = style([flex, flexAlignCenter, flexJustifyCenter]); 37 | export const flexColumnCenter = style([ 38 | flexCenter, 39 | { 40 | flexDirection: "column", 41 | }, 42 | ]); 43 | 44 | export const boxShadow = style({ 45 | boxShadow: "0 3px 10px rgba(0,0,0,0.1), 0 3px 3px rgba(0,0,0,0.05)", 46 | }); 47 | 48 | export const scrollBarHidden = style({ 49 | overflow: "scroll", 50 | msOverflowStyle: "none", 51 | scrollbarWidth: "none", 52 | "::-webkit-scrollbar": { 53 | display: "none", 54 | }, 55 | }); 56 | 57 | export const border = { 58 | top: style({ borderTop: `1px solid ${color.$semantic.border}` }), 59 | bottom: style({ borderBottom: `1px solid ${color.$semantic.border}` }), 60 | verticalSide: style({ 61 | border: `1px solid ${color.$semantic.border}`, 62 | borderTop: "none", 63 | borderBottom: "none", 64 | }), 65 | horizontalSide: style({ 66 | border: `1px solid ${color.$semantic.border}`, 67 | borderLeft: "none", 68 | borderRight: "none", 69 | }), 70 | all: style({ 71 | border: `1px solid ${color.$semantic.border}`, 72 | }), 73 | }; 74 | 75 | export const borderRadius = style({ 76 | borderRadius: 8, 77 | }); 78 | -------------------------------------------------------------------------------- /packages/frontend/src/hooks/useModal.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | export default function useModal() { 4 | const [modalOpen, setModalOpen] = useState(false); 5 | const closeModal = () => { 6 | setModalOpen(false); 7 | }; 8 | 9 | const openModal = () => { 10 | setModalOpen(true); 11 | }; 12 | 13 | return { modalOpen, openModal, closeModal }; 14 | } 15 | -------------------------------------------------------------------------------- /packages/frontend/src/hooks/useMount.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export default function useMount() { 4 | const [mounted, setMounted] = useState(false); 5 | 6 | useEffect(() => { 7 | setMounted(true); 8 | }, []); 9 | 10 | return { mounted, setMounted }; 11 | } 12 | -------------------------------------------------------------------------------- /packages/frontend/src/hooks/useResizableSplitView.ts: -------------------------------------------------------------------------------- 1 | import { MouseEvent, useRef } from "react"; 2 | 3 | export default function useResizableSplitView() { 4 | const barRef = useRef(null); 5 | const topRef = useRef(null); 6 | const prevBarClientY = useRef(0); 7 | const prevUpHeight = useRef(0); 8 | 9 | const handleBarHover = (event: MouseEvent) => { 10 | if (!topRef.current) return; 11 | prevBarClientY.current = event.clientY; 12 | 13 | const { height } = topRef.current.getBoundingClientRect(); 14 | prevUpHeight.current = height; 15 | document.addEventListener("mousemove", handleMouseMove); 16 | document.addEventListener("mouseup", handleMouseUp); 17 | }; 18 | 19 | const handleMouseMove = ({ clientY }: { clientY: number }) => { 20 | if (!topRef.current) { 21 | return; 22 | } 23 | const dClientY = clientY - prevBarClientY.current; 24 | const nextHeight = prevUpHeight.current + dClientY; 25 | topRef.current.style.height = `${Math.max(nextHeight, 0)}px`; 26 | }; 27 | 28 | const handleMouseUp = () => { 29 | document.removeEventListener("mousemove", handleMouseMove); 30 | document.removeEventListener("mouseup", handleMouseUp); 31 | }; 32 | 33 | return { barRef, topRef, handleBarHover }; 34 | } 35 | -------------------------------------------------------------------------------- /packages/frontend/src/hooks/useScroll/type.ts: -------------------------------------------------------------------------------- 1 | export type DirectionType = "up" | "down" | "left" | "right"; 2 | 3 | export type Direction = { 4 | [key in DirectionType as string]: string; 5 | }; 6 | 7 | export type ScrollReturnValues = { 8 | ref: React.MutableRefObject; 9 | style: { opacity: number; transform: string }; 10 | }; 11 | -------------------------------------------------------------------------------- /packages/frontend/src/hooks/useScroll/useScrollClipPath.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from "react"; 2 | 3 | import { Direction } from "./type"; 4 | 5 | function useScrollClipPath( 6 | direction = "left", 7 | duration = 1, 8 | delay = 0, 9 | threshold = 0.7, 10 | ) { 11 | const element = useRef(null); 12 | 13 | const handleDirection: Direction = { 14 | up: "inset(100% 0 0 0)", 15 | down: "inset(0 0 100% 0)", 16 | left: "inset(0 100% 0 0)", 17 | right: "inset(0 0 0 100%)", 18 | }; 19 | 20 | const onScroll = useCallback( 21 | ([entry]: IntersectionObserverEntry[]) => { 22 | const { current } = element; 23 | if (entry.isIntersecting && current) { 24 | current.style.transitionProperty = "transform, clip-path"; 25 | current.style.transitionDuration = `${duration * 1.5}s, ${duration}s`; 26 | current.style.transitionTimingFunction = "cubic-bezier(0, 0, 0.2, 1)"; 27 | current.style.transitionDelay = `${delay}s`; 28 | current.style.transform = "scale(1)"; 29 | current.style.clipPath = "inset(0 0 0 0)"; 30 | } 31 | }, 32 | [delay, duration], 33 | ); 34 | 35 | useEffect(() => { 36 | let observer: IntersectionObserver; 37 | 38 | if (element.current) { 39 | observer = new IntersectionObserver(onScroll, { threshold }); 40 | observer.observe(element.current.parentNode); 41 | } 42 | 43 | return () => observer && observer.disconnect(); 44 | }, [onScroll]); 45 | 46 | return { 47 | ref: element, 48 | style: { 49 | transform: "scale(1.2)", 50 | clipPath: handleDirection[direction], 51 | }, 52 | }; 53 | } 54 | 55 | export default useScrollClipPath; 56 | -------------------------------------------------------------------------------- /packages/frontend/src/hooks/useScroll/useScrollFadeIn.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from "react"; 2 | 3 | import { Direction, DirectionType } from "./type"; 4 | 5 | function useScrollFadeIn( 6 | direction: DirectionType = "up", 7 | duration = 1000, 8 | delay = 200, 9 | threshold = 0.7, 10 | ) { 11 | const element = useRef(null); 12 | 13 | const handleDirection: Direction = { 14 | up: "translate3d(0, 25%, 0)", 15 | down: "translate3d(0, -25%, 0)", 16 | left: "translate3d(25%, 0, 0)", 17 | right: "translate3d(-25%, 0, 0)", 18 | }; 19 | 20 | const onScroll = useCallback( 21 | ([entry]: IntersectionObserverEntry[]) => { 22 | const { current } = element; 23 | if (entry.isIntersecting && current) { 24 | current.style.transition = `opacity ${duration}ms ${delay}ms, transform ${duration}ms ${delay}ms`; 25 | current.style.transitionTimingFunction = "cubic-bezier(0, 0, 0.2, 1)"; 26 | current.style.opacity = "1"; 27 | current.style.transform = "translate3d(0, 0, 0)"; 28 | } 29 | }, 30 | [delay, duration], 31 | ); 32 | 33 | useEffect(() => { 34 | let observer: IntersectionObserver; 35 | 36 | if (element.current) { 37 | observer = new IntersectionObserver(onScroll, { threshold }); 38 | observer.observe(element.current); 39 | } 40 | 41 | return () => observer && observer.disconnect(); 42 | }, [onScroll]); 43 | 44 | return { 45 | ref: element, 46 | style: { 47 | opacity: 0, 48 | transform: handleDirection[direction], 49 | }, 50 | }; 51 | } 52 | 53 | export default useScrollFadeIn; 54 | -------------------------------------------------------------------------------- /packages/frontend/src/hooks/useTheme.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from "react"; 2 | 3 | export type ColorTheme = "light" | "dark"; 4 | 5 | export type ThemeContext = { 6 | colorTheme: ColorTheme | undefined; 7 | setColorTheme: (theme: ColorTheme) => void; 8 | }; 9 | 10 | const ColorThemeStorageKey = "colorTheme"; 11 | 12 | export default function useTheme() { 13 | const [colorTheme, setColorTheme] = useState( 14 | undefined, 15 | ); 16 | const themeContext = useMemo( 17 | () => ({ colorTheme, setColorTheme }), 18 | [colorTheme], 19 | ); 20 | 21 | useEffect(() => { 22 | if (typeof window !== "undefined") { 23 | const color = localStorage.getItem(ColorThemeStorageKey); 24 | setColorTheme(color ? (color as ColorTheme) : "light"); 25 | } 26 | }, []); 27 | 28 | useEffect(() => { 29 | if (!colorTheme) return; 30 | document.documentElement.dataset.theme = colorTheme; 31 | localStorage.setItem(ColorThemeStorageKey, colorTheme); 32 | }, [colorTheme]); 33 | 34 | return themeContext; 35 | } 36 | -------------------------------------------------------------------------------- /packages/frontend/src/mocks/apis/data/quizContentData.ts: -------------------------------------------------------------------------------- 1 | const quizContentMockData = { 2 | id: 3, 3 | title: "git add & git status", 4 | description: `현재 디렉터리의 Git 저장소 환경에서 user name과 user email을 여러분의 name과 email로 설정해주세요.`, 5 | keywords: ["add", "status"], 6 | category: "Git Start", 7 | }; 8 | 9 | export default quizContentMockData; 10 | -------------------------------------------------------------------------------- /packages/frontend/src/mocks/apis/quizHandlers.ts: -------------------------------------------------------------------------------- 1 | import { rest } from "msw"; 2 | 3 | import { API_PATH } from "../../constants/path"; 4 | import { isString } from "../../utils/typeGuard"; 5 | 6 | const baseUrl = "/api/v1"; 7 | const quizPath = `${baseUrl}${API_PATH.QUIZZES}`; 8 | 9 | export const quizHandlers = [ 10 | rest.post(`${quizPath}/:id/submit`, (req, res, ctx) => { 11 | const { id } = req.params; 12 | if (!isString(id)) { 13 | return res(ctx.status(404)); 14 | } 15 | 16 | const idNum = parseInt(id, 10); 17 | if (idNum < 1 || idNum > 19) { 18 | return res(ctx.status(404)); 19 | } 20 | 21 | return res(ctx.json({ solved: true, link: "mock-share-link" })); 22 | }), 23 | rest.delete(`${quizPath}/:id/command`, (req, res, ctx) => { 24 | const { id } = req.params; 25 | if (!isString(id)) { 26 | return res(ctx.status(404)); 27 | } 28 | 29 | const idNum = parseInt(id, 10); 30 | if (idNum < 1 || idNum > 19) { 31 | return res(ctx.status(404)); 32 | } 33 | 34 | if (idNum === 1) { 35 | return res(ctx.status(500)); 36 | } 37 | 38 | return res(); 39 | }), 40 | ]; 41 | -------------------------------------------------------------------------------- /packages/frontend/src/mocks/browser.ts: -------------------------------------------------------------------------------- 1 | import { setupWorker } from "msw"; 2 | 3 | import { handlers } from "./handlers"; 4 | 5 | // This configures a Service Worker with the given request handlers. 6 | export const worker = setupWorker(...handlers); 7 | -------------------------------------------------------------------------------- /packages/frontend/src/mocks/handlers.ts: -------------------------------------------------------------------------------- 1 | import { quizHandlers } from "./apis/quizHandlers"; 2 | 3 | export const handlers = [...quizHandlers]; 4 | -------------------------------------------------------------------------------- /packages/frontend/src/mocks/index.ts: -------------------------------------------------------------------------------- 1 | async function initMocks() { 2 | if (typeof window === "undefined") { 3 | const { server } = await import("./server"); 4 | server.listen({ 5 | onUnhandledRequest: "bypass", 6 | }); 7 | } else { 8 | const { worker } = await import("./browser"); 9 | worker.start({ 10 | onUnhandledRequest: "bypass", 11 | }); 12 | } 13 | } 14 | 15 | initMocks(); 16 | 17 | export {}; 18 | -------------------------------------------------------------------------------- /packages/frontend/src/mocks/server.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from "msw/node"; 2 | 3 | import { handlers } from "./handlers"; 4 | 5 | export const server = setupServer(...handlers); 6 | -------------------------------------------------------------------------------- /packages/frontend/src/pages/_app.page.tsx: -------------------------------------------------------------------------------- 1 | import "../design-system/styles/global.css"; 2 | 3 | import type { AppProps } from "next/app"; 4 | import Head from "next/head"; 5 | import React, { useEffect, useState } from "react"; 6 | 7 | import "react-toastify/dist/ReactToastify.min.css"; 8 | 9 | import { sessionAPI } from "../apis/session"; 10 | import { UserQuizStatusProvider } from "../contexts/UserQuizStatusContext"; 11 | import { ToastContainer, toast } from "../design-system/components/common"; 12 | import Layout from "../design-system/components/common/Layout"; 13 | import ThemeWrapper from "../design-system/components/common/Theme/ThemeWrapper"; 14 | import { UserQuizStatus } from "../types/user"; 15 | 16 | if (process.env.NEXT_PUBLIC_API_MOCKING === "enabled") { 17 | import("../mocks"); 18 | } 19 | 20 | export default function App({ Component, pageProps }: AppProps) { 21 | const [userQuizStatus, setUserQuizStatus] = useState({}); 22 | 23 | useEffect(() => { 24 | (async () => { 25 | try { 26 | const nextUserQuizStatus = await sessionAPI.getUserQuizStatus(); 27 | setUserQuizStatus(nextUserQuizStatus); 28 | } catch (error) { 29 | toast.error("일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요"); 30 | } 31 | })(); 32 | }, []); 33 | 34 | return ( 35 | <> 36 | 37 | 38 | Git Challenge 39 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /packages/frontend/src/pages/_document.page.tsx: -------------------------------------------------------------------------------- 1 | import { Head, Html, Main, NextScript } from "next/document"; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 12 | 18 | 24 | 25 | 30 | 31 | 32 | 33 | 34 |
    35 |