├── .github ├── ISSUE_TEMPLATE │ ├── 버그-이슈-템플릿.md │ └── 이슈-생성-템플릿.md ├── pull_request_template.md └── workflows │ ├── deploy-backend.yml │ ├── deploy-frontend.yml │ ├── deploy-static.yml │ ├── test-backend.yml │ └── test-frontend.yml ├── .gitignore ├── .gitmessage.txt ├── .husky ├── commit-msg └── pre-commit ├── .idea ├── .gitignore ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── modules.xml ├── vcs.xml └── web08-BooQuiz.iml ├── .prettierrc ├── README.md ├── apps ├── backend │ ├── .dockerignore │ ├── .eslintrc.js │ ├── .gitignore │ ├── Dockerfile │ ├── README.md │ ├── config │ │ ├── database.config.ts │ │ ├── http.config.ts │ │ └── typeorm.config.ts │ ├── nest-cli.json │ ├── package.json │ ├── src │ │ ├── app.controller.spec.ts │ │ ├── app.controller.ts │ │ ├── app.module.ts │ │ ├── app.service.ts │ │ ├── chat │ │ │ ├── chat.controller.spec.ts │ │ │ ├── chat.controller.ts │ │ │ ├── chat.module.ts │ │ │ ├── chat.service.spec.ts │ │ │ ├── chat.service.ts │ │ │ ├── entities │ │ │ │ └── chat-message.entity.ts │ │ │ └── repository │ │ │ │ ├── chat.memory.repository.spec.ts │ │ │ │ └── chat.memory.repository.ts │ │ ├── common │ │ │ ├── base-entity.ts │ │ │ └── constants.ts │ │ ├── core │ │ │ └── SessionWsAdapter.ts │ │ ├── logger │ │ │ ├── http-logger.middleware.ts │ │ │ └── winston.config.ts │ │ ├── main.ts │ │ ├── play │ │ │ ├── dto │ │ │ │ ├── current-quiz-result.dto.ts │ │ │ │ ├── current-quiz.dto.ts │ │ │ │ ├── quiz-join.dto.ts │ │ │ │ ├── quiz-result-summary.dto.ts │ │ │ │ ├── quiz-submit.dto.ts │ │ │ │ ├── response-player.dto.ts │ │ │ │ └── submit-response.dto.ts │ │ │ ├── entities │ │ │ │ ├── client-info.entity.ts │ │ │ │ ├── quiz-summary.entity.ts │ │ │ │ ├── rank.entity.ts │ │ │ │ └── send-event.entity.ts │ │ │ ├── play.gateway.spec.ts │ │ │ ├── play.gateway.ts │ │ │ ├── play.module.ts │ │ │ ├── play.service.spec.ts │ │ │ └── play.service.ts │ │ ├── quiz-zone │ │ │ ├── dto │ │ │ │ ├── check-existing-quiz-zone.dto.ts │ │ │ │ ├── create-quiz-zone.dto.ts │ │ │ │ └── find-quiz-zone.dto.ts │ │ │ ├── entities │ │ │ │ ├── player.entity.ts │ │ │ │ ├── quiz-zone.entity.ts │ │ │ │ ├── quiz.entity.ts │ │ │ │ └── submitted-quiz.entity.ts │ │ │ ├── quiz-zone.controller.spec.ts │ │ │ ├── quiz-zone.controller.ts │ │ │ ├── quiz-zone.module.ts │ │ │ ├── quiz-zone.service.spec.ts │ │ │ ├── quiz-zone.service.ts │ │ │ └── repository │ │ │ │ ├── quiz-zone.memory.repository.spec.ts │ │ │ │ ├── quiz-zone.memory.repository.ts │ │ │ │ └── quiz-zone.repository.interface.ts │ │ └── quiz │ │ │ ├── dto │ │ │ ├── create-quiz-set-request.dto.ts │ │ │ ├── create-quiz-set-response.dto.ts │ │ │ ├── find-quizzes-response.dto.ts │ │ │ ├── search-quiz-set-request.dto.ts │ │ │ ├── search-quiz-set-response.dto.ts │ │ │ └── update-quiz-request.dto.ts │ │ │ ├── entity │ │ │ ├── quiz-set.entity.ts │ │ │ └── quiz.entitiy.ts │ │ │ ├── quiz-set.controller.spec.ts │ │ │ ├── quiz-set.controller.ts │ │ │ ├── quiz.controller.spec.ts │ │ │ ├── quiz.controller.ts │ │ │ ├── quiz.module.ts │ │ │ ├── quiz.service.spec.ts │ │ │ ├── quiz.service.ts │ │ │ └── repository │ │ │ ├── quiz-set.repository.ts │ │ │ └── quiz.repository.ts │ ├── test │ │ ├── app.e2e-spec.ts │ │ ├── jest-e2e.json │ │ ├── play.e2e-spec.ts │ │ ├── quiz-zone.e2e-spec.ts │ │ └── quiz.e2e-spec.ts │ ├── tsconfig.build.json │ └── tsconfig.json └── frontend │ ├── .gitignore │ ├── .storybook │ ├── main.ts │ └── preview.ts │ ├── README.md │ ├── components.json │ ├── eslint.config.js │ ├── index.html │ ├── package.json │ ├── postcss.config.js │ ├── public │ ├── BooQuizFavicon.png │ └── vite.svg │ ├── src │ ├── App.css │ ├── App.tsx │ ├── assets │ │ └── react.svg │ ├── blocks │ │ ├── CreateQuizZone │ │ │ ├── CandidateQuizzes.tsx │ │ │ ├── CreateQuiz.tsx │ │ │ ├── CreateQuizSet.tsx │ │ │ ├── CreateQuizZoneBasic.tsx │ │ │ ├── SearchQuizSet.tsx │ │ │ └── SearchQuizSetResults.tsx │ │ └── QuizZone │ │ │ ├── QuizCompleted.tsx │ │ │ ├── QuizInProgress.tsx │ │ │ ├── QuizWaiting.tsx │ │ │ ├── QuizZoneInProgress.tsx │ │ │ ├── QuizZoneLoading.tsx │ │ │ ├── QuizZoneLobby.tsx │ │ │ └── QuizZoneResult.tsx │ ├── components │ │ ├── boundary │ │ │ ├── AsyncBoundary.tsx │ │ │ └── ErrorBoundary.tsx │ │ ├── common │ │ │ ├── ChatBox.tsx │ │ │ ├── CommonButton.stories.tsx │ │ │ ├── CommonButton.tsx │ │ │ ├── ContentBox.stories.tsx │ │ │ ├── ContentBox.tsx │ │ │ ├── CustomAlert.stories.tsx │ │ │ ├── CustomAlert.tsx │ │ │ ├── CustomAlertDialog.tsx │ │ │ ├── CustomAlertDialogContent.tsx │ │ │ ├── Input.stories.tsx │ │ │ ├── Input.tsx │ │ │ ├── Logo.tsx │ │ │ ├── NavBar.tsx │ │ │ ├── ParticipantGrid.tsx │ │ │ ├── PlayersGrid.tsx │ │ │ ├── PodiumPlayers.tsx │ │ │ ├── ProgressBar.stories.tsx │ │ │ ├── ProgressBar.tsx │ │ │ ├── TextCopy.stories.tsx │ │ │ ├── TextCopy.tsx │ │ │ ├── TimerDisplay.stories.tsx │ │ │ ├── TimerDisplay.tsx │ │ │ ├── TooltipWrapper.tsx │ │ │ ├── Typography.stories.tsx │ │ │ └── Typogrpahy.tsx │ │ └── ui │ │ │ ├── alert-dialog.tsx │ │ │ ├── avatar.tsx │ │ │ ├── button.tsx │ │ │ ├── progress.tsx │ │ │ └── tooltip.tsx │ ├── constants │ │ └── quiz-set.constants.ts │ ├── hook │ │ ├── quizZone │ │ │ ├── useQuizZone.test.ts │ │ │ └── useQuizZone.tsx │ │ ├── useAsyncError.ts │ │ ├── useTimer.ts │ │ ├── useValidInput.test.ts │ │ ├── useValidInput.ts │ │ └── useWebSocket.tsx │ ├── index.css │ ├── lib │ │ └── utils.ts │ ├── main.tsx │ ├── pages │ │ ├── CreateQuizZonePage.tsx │ │ ├── MainPage.tsx │ │ ├── NotFoundPage.tsx │ │ ├── QuizZonePage.tsx │ │ └── RootLayout.tsx │ ├── router │ │ └── router.tsx │ ├── test │ │ └── setup.ts │ ├── types │ │ ├── create-quiz-zone.types.ts │ │ ├── error.types.ts │ │ ├── quizZone.types.ts │ │ └── timer.types.ts │ ├── utils │ │ ├── atob.ts │ │ ├── errorUtils.ts │ │ ├── requests.ts │ │ └── validators.ts │ ├── vite-env.d.ts │ └── workers │ │ └── timer.worker.ts │ ├── tailwind.config.cjs │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── commitlint.config.cjs ├── docs ├── README.md ├── backend │ └── src │ │ ├── app.controller │ │ ├── README.md │ │ └── classes │ │ │ └── AppController.md │ │ ├── app.module │ │ ├── README.md │ │ └── classes │ │ │ └── AppModule.md │ │ ├── app.service │ │ ├── README.md │ │ └── classes │ │ │ └── AppService.md │ │ └── main │ │ └── README.md └── modules.md ├── ecosystem.config.cjs ├── package.json ├── packages └── shared │ ├── package.json │ └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── tsconfig.base.json └── typedoc.json /.github/ISSUE_TEMPLATE/버그-이슈-템플릿.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 버그 이슈 템플릿 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 버그 설명 11 | 어떤 버그인지 명확하고 간결하게 설명해주세요. 12 | 13 | ## 재현 방법 14 | 버그를 재현하는 단계를 설명해주세요: 15 | 1. '...'로 이동 16 | 2. '....'를 클릭 17 | 3. '....'까지 스크롤 18 | 4. 에러 발생 19 | 20 | ## 예상한 동작 21 | 원래 어떻게 동작해야 하는지 설명해주세요. 22 | 23 | ## 스크린샷 24 | 가능하다면 문제를 설명하는데 도움이 되는 스크린샷을 추가해주세요. 25 | 26 | ## 환경 정보 27 | - OS: [예: iOS] 28 | - 브라우저 [예: chrome, safari] 29 | - 버전 [예: 22] 30 | 31 | ## 추가 정보 32 | 문제 해결에 도움이 될 만한 추가 정보를 여기에 작성해주세요. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/이슈-생성-템플릿.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 이슈 생성 템플릿 3 | about: '프로젝트 진행 중에 필요한 이슈를 생성 할 수 있음. ' 4 | title: "[FEAT]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 어떤 기능인가요? 11 | 기능에 대해 간단히 설명해주세요. 12 | 13 | ## 구현 방법 14 | 가능하다면 이 기능을 어떻게 구현할 수 있을지 아이디어를 제시해주세요. 15 | 16 | ## 추가 정보 17 | 기능 구현에 도움이 될 만한 추가 정보나 스크린샷 등을 첨부해주세요. 18 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## 🔍️ 이 PR을 통해 해결하려는 문제가 무엇인가요? 2 | 3 | >어떤 기능을 구현한건지, 이슈 대응이라면 어떤 이슈인지 PR이 열리게 된 계기와 목적을 Reviewer 들이 쉽게 이해할 수 있도록 적어 주세요 4 | >일감 백로그 링크나 다이어그램, 피그마를 첨부해도 좋아요 5 | 6 | * 7 | 8 | ## ✨ 이 PR에서 핵심적으로 변경된 사항은 무엇일까요? 9 | > 문제를 해결하면서 주요하게 변경된 사항들을 적어 주세요 10 | * 11 | 12 | ## 🔖 핵심 변경 사항 외에 추가적으로 변경된 부분이 있나요? 13 | > 없으면 "없음" 이라고 기재해 주세요 14 | * 15 | 16 | ## 🙏 Reviewer 분들이 이런 부분을 신경써서 봐 주시면 좋겠어요 17 | > 개발 과정에서 다른 분들의 의견은 어떠한지 궁금했거나 크로스 체크가 필요하다고 느껴진 코드가 있다면 남겨주세요 18 | * 19 | 20 | ## 🩺 이 PR에서 테스트 혹은 검증이 필요한 부분이 있을까요? 21 | > 테스트가 필요한 항목이나 테스트 코드가 추가되었다면 함께 적어주세요 22 | * 23 | 24 | ### 📌 PR 진행 시 이러한 점들을 참고해 주세요 25 | * Reviewer 분들은 코드 리뷰 시 좋은 코드의 방향을 제시하되, 코드 수정을 강제하지 말아 주세요. 26 | * Reviewer 분들은 좋은 코드를 발견한 경우, 칭찬과 격려를 아끼지 말아 주세요. 27 | * Review는 특수한 케이스가 아니면 Reviewer로 지정된 시점 기준으로 1일 이내에 진행해 주세요. 28 | * Comment 작성 시 Prefix로 P1, P2, P3 를 적어 주시면 Assignee가 보다 명확하게 Comment에 대해 대응할 수 있어요 29 | * P1 : 꼭 반영해 주세요 (Request Changes) - 이슈가 발생하거나 취약점이 발견되는 케이스 등 30 | * P2 : 반영을 적극적으로 고려해 주시면 좋을 것 같아요 (Comment) 31 | * P3 : 이런 방법도 있을 것 같아요~ 등의 사소한 의견입니다 (Chore) 32 | # 33 | 34 | --- 35 | ## 📝 Assignee를 위한 CheckList 36 | - [ ] To-Do Item -------------------------------------------------------------------------------- /.github/workflows/deploy-frontend.yml: -------------------------------------------------------------------------------- 1 | name: frontend deploy 2 | 3 | on: 4 | push: 5 | branches: [develop, main] 6 | paths: 7 | - apps/frontend/** 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | name: test frontend 13 | environment: ${{ github.ref == 'refs/heads/main' && 'prod' || 'staging' }} 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: 'node' 23 | 24 | - name: Install pnpm 25 | run: npm install -g pnpm 26 | 27 | - name: Install root dependencies 28 | run: pnpm install --no-frozen-lockfile 29 | 30 | - name: Build frontend 31 | working-directory: apps/frontend 32 | env: 33 | VITE_API_URL: ${{secrets.VITE_API_URL}} 34 | VITE_WS_URL: ${{secrets.VITE_WS_URL}} 35 | run: pnpm run build 36 | 37 | - name: Upload artifacts 38 | uses: actions/upload-artifact@v4 39 | with: 40 | name: frontend-build 41 | path: apps/frontend/dist 42 | 43 | deploy-frontend: 44 | needs: build 45 | runs-on: ubuntu-latest 46 | name: Deploy Frontend 47 | environment: ${{ github.ref == 'refs/heads/main' && 'prod' || 'staging' }} 48 | 49 | steps: 50 | - name: Download artifacts 51 | uses: actions/download-artifact@v4 52 | with: 53 | name: frontend-build 54 | path: apps/frontend/dist 55 | 56 | - name: Set up SSH Key 57 | run: | 58 | mkdir -p ~/.ssh 59 | echo "${{ secrets.PUBLIC_SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa 60 | chmod 600 ~/.ssh/id_rsa 61 | ssh-keyscan -t rsa ${{ secrets.PUBLIC_SERVER_IP }} >> ~/.ssh/known_hosts 62 | 63 | - name: Deploy to Nginx 64 | run: | 65 | PUBLIC_SERVER_IP=${{ secrets.PUBLIC_SERVER_IP }} 66 | DEPLOY_USER=${{ secrets.DEPLOY_USER }} 67 | DEPLOY_PATH=${{ secrets.DEPLOY_PATH }} 68 | 69 | scp -i ~/.ssh/id_rsa -r apps/frontend/dist/* $DEPLOY_USER@$PUBLIC_SERVER_IP:$DEPLOY_PATH 70 | ssh -i ~/.ssh/id_rsa $DEPLOY_USER@$PUBLIC_SERVER_IP "chmod -R 700 $DEPLOY_PATH" -------------------------------------------------------------------------------- /.github/workflows/deploy-static.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Storybook and Docs 2 | 3 | on: 4 | pull_request: 5 | branches: [develop] 6 | 7 | jobs: 8 | deploy-docs: 9 | runs-on: ubuntu-latest 10 | name: Deploy Storybook and TypeDocs 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v4 15 | 16 | - name: Set up Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: 'node' 20 | 21 | - name: Install pnpm 22 | run: npm install -g pnpm 23 | 24 | - name: Install dependencies 25 | run: pnpm install --no-frozen-lockfile 26 | 27 | - name: Build Storybook 28 | working-directory: apps/frontend 29 | run: pnpm run build-storybook 30 | 31 | - name: Build docs 32 | run: pnpm run docs 33 | 34 | - name: Move docs, storybook, and Jekyll config to output folder 35 | run: | 36 | mkdir -p ./static 37 | mv docs ./static/docs 38 | mv apps/frontend/storybook-static ./static/storybook 39 | 40 | - name: Deploy to GitHub Pages 41 | uses: peaceiris/actions-gh-pages@v3 42 | with: 43 | github_token: ${{ secrets.GITHUB_TOKEN }} 44 | publish_dir: ./static 45 | -------------------------------------------------------------------------------- /.github/workflows/test-backend.yml: -------------------------------------------------------------------------------- 1 | name: backend test 2 | 3 | on: 4 | pull_request: 5 | branches: [develop, main] 6 | paths: 7 | - apps/backend/** 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | name: test backend 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v4 17 | 18 | - name: Set up Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: 'node' 22 | 23 | - name: Install pnpm 24 | run: npm install -g pnpm 25 | 26 | - name: Install root dependencies 27 | run: pnpm install --no-frozen-lockfile 28 | 29 | - name: Build backend 30 | working-directory: apps/backend 31 | run: pnpm run build 32 | 33 | - name: Run tests for backend 34 | working-directory: apps/backend 35 | run: pnpm test -------------------------------------------------------------------------------- /.github/workflows/test-frontend.yml: -------------------------------------------------------------------------------- 1 | name: frontend test 2 | 3 | on: 4 | pull_request: 5 | branches: [develop, main] 6 | paths: 7 | - apps/frontend/** 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | name: test frontend 13 | environment: ${{ github.base_ref == 'main' && 'prod' || 'staging' }} 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: 'node' 23 | 24 | - name: Install pnpm 25 | run: npm install -g pnpm 26 | 27 | - name: Install root dependencies 28 | run: pnpm install --no-frozen-lockfile 29 | 30 | - name: Build frontend 31 | working-directory: apps/frontend 32 | env: 33 | VITE_API_URL: ${{secrets.VITE_API_URL}} 34 | VITE_WS_URL: ${{secrets.VITE_WS_URL}} 35 | run: pnpm run build 36 | 37 | - name: Run tests for frontend 38 | working-directory: apps/frontend 39 | run: pnpm test -------------------------------------------------------------------------------- /.gitmessage.txt: -------------------------------------------------------------------------------- 1 | : 2 | 3 | 4 | 5 | EPIC: # 6 | Story: # 7 | Task: # -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no-install commitlint --edit "$1" -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web08-BooQuiz/2d1fc99ea736831c7828a769574289f5df31bb53/.husky/pre-commit -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # 디폴트 무시된 파일 2 | /shelf/ 3 | /workspace.xml 4 | # 에디터 기반 HTTP 클라이언트 요청 5 | /httpRequests/ 6 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 49 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/web08-BooQuiz.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 4, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "quoteProps": "as-needed", 8 | "jsxSingleQuote": false, 9 | "trailingComma": "all", 10 | "bracketSpacing": true, 11 | "jsxBracketSameLine": false, 12 | "arrowParens": "always", 13 | "proseWrap": "preserve", 14 | "endOfLine": "lf" 15 | } 16 | -------------------------------------------------------------------------------- /apps/backend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | test -------------------------------------------------------------------------------- /apps/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 | -------------------------------------------------------------------------------- /apps/backend/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | /build 5 | 6 | :booquiz 7 | :memory 8 | 9 | # Logs 10 | logs 11 | *.log 12 | npm-debug.log* 13 | pnpm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | lerna-debug.log* 17 | 18 | # OS 19 | .DS_Store 20 | 21 | # Tests 22 | /coverage 23 | /.nyc_output 24 | 25 | # IDEs and editors 26 | /.idea 27 | .project 28 | .classpath 29 | .c9/ 30 | *.launch 31 | .settings/ 32 | *.sublime-workspace 33 | 34 | # IDE - VSCode 35 | .vscode/* 36 | !.vscode/settings.json 37 | !.vscode/tasks.json 38 | !.vscode/launch.json 39 | !.vscode/extensions.json 40 | 41 | # dotenv environment variable files 42 | .env 43 | .env.development.local 44 | .env.test.local 45 | .env.production.local 46 | .env.local 47 | 48 | # temp directory 49 | .temp 50 | .tmp 51 | 52 | # Runtime data 53 | pids 54 | *.pid 55 | *.seed 56 | *.pid.lock 57 | 58 | # Diagnostic reports (https://nodejs.org/api/report.html) 59 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 60 | 61 | :booquiz -------------------------------------------------------------------------------- /apps/backend/Dockerfile: -------------------------------------------------------------------------------- 1 | # 빌드 단계 2 | FROM node:20-alpine AS builder 3 | WORKDIR /app 4 | 5 | # pnpm 전역 설치 6 | RUN npm install -g pnpm 7 | 8 | # 의존성 파일들 복사 9 | COPY pnpm-lock.yaml pnpm-workspace.yaml ./ 10 | COPY apps/backend/package.json ./apps/backend/ 11 | COPY apps/backend/tsconfig*.json ./apps/backend 12 | 13 | # backend 의존성 설치 (락파일 검사 무시) 14 | RUN pnpm install -C apps/backend --no-frozen-lockfile 15 | 16 | # 소스 코드 복사 및 빌드 실행 17 | COPY apps/backend ./apps/backend 18 | # 수정: 디렉토리를 이동한 후 빌드 실행 19 | WORKDIR /app/apps/backend 20 | RUN pnpm run build 21 | 22 | # 프로덕션 단계 23 | FROM node:20-alpine AS production 24 | WORKDIR /app 25 | 26 | # 빌드 도구 설치 및 환경 변수 설정 27 | RUN apk add --no-cache build-base 28 | ENV PNPM_HOME="/root/.local/share/pnpm" 29 | ENV PATH="$PNPM_HOME:$PATH" 30 | 31 | 32 | # 프로덕션 환경용 pnpm 설치 33 | RUN npm install -g pnpm 34 | RUN pnpm add -g pm2 35 | 36 | # 빌더 단계에서 필요한 파일들만 복사 37 | COPY --from=builder /app/apps/backend/dist ./dist 38 | COPY --from=builder /app/apps/backend/package.json ./ 39 | COPY pnpm-lock.yaml pnpm-workspace.yaml ./ 40 | # 프로덕션 실행에 필요한 의존성만 설치 (devDependencies 제외) 41 | # --no-frozen-lockfile: 락파일 버전 제약을 완화하여 호환되는 최신 버전 허용 42 | RUN pnpm install --no-frozen-lockfile 43 | 44 | ARG NODE_ENV 45 | ARG SESSION_SECRET 46 | ARG MYSQL_HOST 47 | ARG DB_PORT 48 | ARG DB_USERNAME 49 | ARG DB_PASSWORD 50 | ARG DB_DATABASE 51 | ARG DB_SYNCHRONIZE 52 | 53 | ENV NODE_ENV=$NODE_ENV 54 | ENV SESSION_SECRET=$SESSION_SECRET 55 | ENV MYSQL_HOST=$MYSQL_HOST 56 | ENV DB_PORT=$DB_PORT 57 | ENV DB_USERNAME=$DB_USERNAME 58 | ENV DB_PASSWORD=$DB_PASSWORD 59 | ENV DB_DATABASE=$DB_DATABASE 60 | ENV DB_SYNCHRONIZE=$DB_SYNCHRONIZE 61 | 62 | # 포트 설정 및 앱 실행 63 | EXPOSE 3000 64 | CMD ["pm2-runtime", "start", "dist/src/main.js"] -------------------------------------------------------------------------------- /apps/backend/config/database.config.ts: -------------------------------------------------------------------------------- 1 | export default () => ({ 2 | host: process.env.MYSQL_HOST, 3 | db_port: parseInt(process.env.DB_PORT, 10), 4 | db_username: process.env.DB_USERNAME, 5 | db_password: process.env.DB_PASSWORD, 6 | db_database: process.env.DB_DATABASE, 7 | db_synchronize: process.env.DB_SYNCHRONIZE === 'true', 8 | }); 9 | -------------------------------------------------------------------------------- /apps/backend/config/http.config.ts: -------------------------------------------------------------------------------- 1 | export enum Environment { 2 | Development = 'DEV', 3 | Staging = 'STG', 4 | Production = 'PROD', 5 | } 6 | 7 | export default () => ({ 8 | env: process.env.NODE_ENV || Environment.Development, 9 | port: parseInt(process.env.PORT) || 3000, 10 | sessionSecret: process.env.SESSION_SECRET || 'development-session-secret', 11 | }); 12 | -------------------------------------------------------------------------------- /apps/backend/config/typeorm.config.ts: -------------------------------------------------------------------------------- 1 | import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm'; 2 | import { Quiz } from '../src/quiz/entity/quiz.entitiy'; 3 | import { QuizSet } from '../src/quiz/entity/quiz-set.entity'; 4 | import { ConfigService } from '@nestjs/config'; 5 | import { Environment } from './http.config'; 6 | import { Injectable } from '@nestjs/common'; 7 | 8 | @Injectable() 9 | export class TypeormConfig implements TypeOrmOptionsFactory { 10 | constructor(private readonly configService: ConfigService) {} 11 | 12 | createTypeOrmOptions(): TypeOrmModuleOptions { 13 | const env = this.configService.get('env'); 14 | 15 | if(env === 'DEV') { 16 | 17 | return { 18 | type: 'sqlite', 19 | database: ':booquiz', 20 | entities: [Quiz, QuizSet], 21 | synchronize: true, 22 | logging: ['query'], 23 | } 24 | } 25 | 26 | return { 27 | type: 'mysql', 28 | host: this.configService.get('host'), 29 | port: this.configService.get('db_port'), 30 | username: this.configService.get('db_username'), 31 | password: this.configService.get('db_password'), 32 | database: this.configService.get('db_database'), 33 | entities: [Quiz, QuizSet], 34 | synchronize: this.configService.get('db_synchronize', true), 35 | logging: ['query'], 36 | }; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/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 | -------------------------------------------------------------------------------- /apps/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "pm2-runtime start node dist/main", 15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 16 | "test": "jest", 17 | "test:watch": "jest --watch", 18 | "test:cov": "jest --coverage", 19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 20 | "test:e2e": "jest --config ./test/jest-e2e.json" 21 | }, 22 | "dependencies": { 23 | "@nestjs/common": "^10.0.0", 24 | "@nestjs/config": "^3.3.0", 25 | "@nestjs/core": "^10.0.0", 26 | "@nestjs/mapped-types": "*", 27 | "@nestjs/platform-express": "^10.0.0", 28 | "@nestjs/platform-ws": "^10.4.7", 29 | "@nestjs/swagger": "^8.0.5", 30 | "@nestjs/typeorm": "^10.0.2", 31 | "@nestjs/websockets": "^10.4.7", 32 | "backend": "file:", 33 | "class-transformer": "^0.5.1", 34 | "class-validator": "^0.14.1", 35 | "cookie": "^1.0.1", 36 | "cookie-parser": "^1.4.7", 37 | "express-session": "^1.18.1", 38 | "get-port": "^7.1.0", 39 | "mysql2": "^3.11.4", 40 | "nest-winston": "^1.9.7", 41 | "reflect-metadata": "^0.2.2", 42 | "rxjs": "^7.8.1", 43 | "socket.io-client": "^4.8.1", 44 | "sqlite3": "^5.1.7", 45 | "superwstest": "^2.0.4", 46 | "typeorm": "^0.3.20", 47 | "typeorm-transactional": "^0.5.0", 48 | "winston": "^3.17.0", 49 | "ws": "^8.18.0" 50 | }, 51 | "devDependencies": { 52 | "@nestjs/cli": "^10.0.0", 53 | "@nestjs/schematics": "^10.0.0", 54 | "@nestjs/testing": "^10.0.0", 55 | "@types/cookie-parser": "^1.4.7", 56 | "@types/express": "^5.0.0", 57 | "@types/express-session": "^1.18.0", 58 | "@types/jest": "^29.5.2", 59 | "@types/node": "^20.3.1", 60 | "@types/supertest": "^6.0.0", 61 | "@types/ws": "^8.5.13", 62 | "@typescript-eslint/eslint-plugin": "^8.0.0", 63 | "@typescript-eslint/parser": "^8.0.0", 64 | "eslint": "^9.15.0", 65 | "eslint-config-prettier": "^9.0.0", 66 | "eslint-plugin-prettier": "^5.0.0", 67 | "jest": "^29.5.0", 68 | "prettier": "^3.0.0", 69 | "source-map-support": "^0.5.21", 70 | "supertest": "^7.0.0", 71 | "ts-jest": "^29.1.0", 72 | "ts-loader": "^9.4.3", 73 | "ts-node": "^10.9.1", 74 | "tsconfig-paths": "^4.2.0", 75 | "typescript": "^5.1.3" 76 | }, 77 | "jest": { 78 | "moduleFileExtensions": [ 79 | "js", 80 | "json", 81 | "ts" 82 | ], 83 | "rootDir": "src", 84 | "testRegex": ".*\\.spec\\.ts$", 85 | "transform": { 86 | "^.+\\.(t|j)s$": "ts-jest" 87 | }, 88 | "collectCoverageFrom": [ 89 | "**/*.(t|j)s" 90 | ], 91 | "coverageDirectory": "../coverage", 92 | "testEnvironment": "node" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /apps/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: [ 12 | AppService, 13 | { 14 | provide: 'winston', 15 | useValue: { 16 | log: jest.fn(), 17 | info: jest.fn(), 18 | error: jest.fn(), 19 | warn: jest.fn(), 20 | debug: jest.fn(), 21 | }, 22 | }, 23 | ], 24 | }).compile(); 25 | 26 | appController = app.get(AppController); 27 | }); 28 | 29 | describe('root', () => { 30 | it('should return "Hello World!"', () => { 31 | expect(appController.getHello()).toBe('Hello World!'); 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /apps/backend/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } 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 | -------------------------------------------------------------------------------- /apps/backend/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareConsumer, Module, NestModule, ValidationPipe } from '@nestjs/common'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | import { QuizZoneModule } from './quiz-zone/quiz-zone.module'; 5 | import { PlayModule } from './play/play.module'; 6 | import { ConfigModule, ConfigService } from '@nestjs/config'; 7 | import httpConfig from '../config/http.config'; 8 | import { APP_PIPE } from '@nestjs/core'; 9 | import { WinstonModule } from 'nest-winston'; 10 | import { winstonConfig } from './logger/winston.config'; 11 | import { HttpLoggingMiddleware } from './logger/http-logger.middleware'; 12 | import databaseConfig from '../config/database.config'; 13 | import { TypeOrmModule } from '@nestjs/typeorm'; 14 | import { TypeormConfig } from '../config/typeorm.config'; 15 | import { DataSource } from 'typeorm'; 16 | import { addTransactionalDataSource } from 'typeorm-transactional'; 17 | import { QuizModule } from './quiz/quiz.module'; 18 | import { ChatModule } from './chat/chat.module'; 19 | 20 | @Module({ 21 | imports: [ 22 | QuizZoneModule, 23 | PlayModule, 24 | ConfigModule.forRoot({ 25 | load: [httpConfig, databaseConfig], 26 | isGlobal: true, 27 | }), 28 | WinstonModule.forRoot(winstonConfig), 29 | TypeOrmModule.forRootAsync({ 30 | imports: [ConfigModule], 31 | inject: [ConfigService], 32 | useClass: TypeormConfig, 33 | dataSourceFactory: async (options) => { 34 | const dataSource = new DataSource(options); 35 | await dataSource.initialize(); 36 | return addTransactionalDataSource(dataSource); 37 | }, 38 | }), 39 | QuizModule, 40 | ChatModule, 41 | ], 42 | controllers: [AppController], 43 | providers: [ 44 | AppService, 45 | { 46 | provide: APP_PIPE, 47 | useClass: ValidationPipe, 48 | }, 49 | ], 50 | }) 51 | export class AppModule implements NestModule { 52 | configure(consumer: MiddlewareConsumer) { 53 | consumer.apply(HttpLoggingMiddleware).forRoutes('*'); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /apps/backend/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, Logger } from '@nestjs/common'; 2 | import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; 3 | 4 | @Injectable() 5 | export class AppService { 6 | constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger) {} 7 | 8 | getHello(): string { 9 | this.logger.error('HELL'); 10 | return 'Hello World!'; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /apps/backend/src/chat/chat.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ChatController } from './chat.controller'; 3 | 4 | describe('ChatController', () => { 5 | let controller: ChatController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [ChatController], 10 | }).compile(); 11 | 12 | controller = module.get(ChatController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /apps/backend/src/chat/chat.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | 3 | @Controller('chat') 4 | export class ChatController {} 5 | -------------------------------------------------------------------------------- /apps/backend/src/chat/chat.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ChatController } from './chat.controller'; 3 | import { ChatRepositoryMemory } from './repository/chat.memory.repository'; 4 | import { ChatService } from './chat.service'; 5 | 6 | @Module({ 7 | controllers: [ChatController], 8 | providers: [ 9 | ChatService, 10 | { 11 | provide: 'ChatStorage', 12 | useValue: new Map(), 13 | }, 14 | { 15 | provide: 'ChatRepository', 16 | useClass: ChatRepositoryMemory, 17 | }, 18 | ], 19 | exports: [ChatService], 20 | }) 21 | export class ChatModule {} 22 | -------------------------------------------------------------------------------- /apps/backend/src/chat/chat.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ChatService } from './chat.service'; 3 | import { NotFoundException } from '@nestjs/common'; 4 | import { ChatMessage } from './entities/chat-message.entity'; 5 | 6 | describe('ChatService', () => { 7 | let service: ChatService; 8 | let chatRepository: { [key: string]: jest.Mock }; 9 | 10 | beforeEach(async () => { 11 | chatRepository = { 12 | set: jest.fn(), 13 | get: jest.fn(), 14 | add: jest.fn(), 15 | has: jest.fn(), 16 | delete: jest.fn(), 17 | }; 18 | 19 | const module: TestingModule = await Test.createTestingModule({ 20 | providers: [ 21 | ChatService, 22 | { 23 | provide: 'ChatRepository', 24 | useValue: chatRepository, 25 | }, 26 | ], 27 | }).compile(); 28 | 29 | service = module.get(ChatService); 30 | }); 31 | 32 | it('정의되어 있어야 합니다', () => { 33 | expect(service).toBeDefined(); 34 | }); 35 | 36 | it('채팅방을 설정할 수 있어야 합니다', async () => { 37 | await service.set('room1'); 38 | expect(chatRepository.set).toHaveBeenCalledWith('room1'); 39 | }); 40 | 41 | it('채팅 메시지를 가져올 수 있어야 합니다', async () => { 42 | const chatMessage: ChatMessage = { clientId: '1', message: 'Hello', nickname: 'nickname1' }; 43 | chatRepository.get.mockResolvedValue([chatMessage]); 44 | chatRepository.has.mockResolvedValue(true); 45 | 46 | const messages = await service.get('room1'); 47 | expect(messages).toEqual([chatMessage]); 48 | }); 49 | 50 | it('채팅 메시지를 추가할 수 있어야 합니다', async () => { 51 | const chatMessage: ChatMessage = { clientId: '1', message: 'Hello', nickname: 'nickname1' }; 52 | chatRepository.has.mockResolvedValue(true); 53 | 54 | await service.add('room1', chatMessage); 55 | expect(chatRepository.add).toHaveBeenCalledWith('room1', chatMessage); 56 | }); 57 | 58 | it('존재하지 않는 채팅방에 메시지를 추가하려고 하면 예외가 발생해야 합니다', async () => { 59 | const chatMessage: ChatMessage = { clientId: '1', message: 'Hello', nickname: 'nickname1' }; 60 | chatRepository.has.mockResolvedValue(false); 61 | 62 | await expect(service.add('room1', chatMessage)).rejects.toThrow(NotFoundException); 63 | }); 64 | 65 | it('채팅 메시지가 존재하는지 확인할 수 있어야 합니다', async () => { 66 | await chatRepository.has.mockResolvedValue(true); 67 | 68 | const hasMessages = await service.has('room1'); 69 | expect(hasMessages).toBe(true); 70 | }); 71 | 72 | it('채팅 메시지를 삭제할 수 있어야 합니다', async () => { 73 | await service.delete('room1'); 74 | expect(chatRepository.delete).toHaveBeenCalledWith('room1'); 75 | }); 76 | 77 | it('존재하지 않는 채팅방에 메시지를 가져오려고 하면 예외가 발생해야 합니다', async () => { 78 | await chatRepository.has.mockResolvedValue(false); 79 | 80 | await expect(service.get('room1')).rejects.toThrow(NotFoundException); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /apps/backend/src/chat/chat.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Inject, NotFoundException } from '@nestjs/common'; 2 | import { ChatRepositoryMemory } from './repository/chat.memory.repository'; 3 | import { ChatMessage } from './entities/chat-message.entity'; 4 | 5 | @Injectable() 6 | export class ChatService { 7 | constructor( 8 | @Inject('ChatRepository') 9 | private readonly chatRepository: ChatRepositoryMemory, 10 | ) {} 11 | 12 | async set(id: string) { 13 | this.chatRepository.set(id); 14 | } 15 | 16 | async get(id: string) { 17 | if (!(await this.chatRepository.has(id))) { 18 | throw new NotFoundException('퀴즈 존에 대한 채팅이 존재하지 않습니다.'); 19 | } 20 | return this.chatRepository.get(id); 21 | } 22 | 23 | async add(id: string, chatMessage: ChatMessage) { 24 | if (!(await this.chatRepository.has(id))) { 25 | throw new NotFoundException('퀴즈 존에 대한 채팅이 존재하지 않습니다.'); 26 | } 27 | return this.chatRepository.add(id, chatMessage); 28 | } 29 | 30 | async has(id: string) { 31 | return await this.chatRepository.has(id); 32 | } 33 | 34 | async delete(id: string) { 35 | return this.chatRepository.delete(id); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /apps/backend/src/chat/entities/chat-message.entity.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 사용자가 보낸 채팅 메시지를 나타내는 엔티티 3 | * 4 | * @property clientId - 클라이언트 ID 5 | * @property nickname - 클라이언트의 닉네임 6 | * @property message - 채팅 메시지 7 | */ 8 | 9 | export interface ChatMessage { 10 | clientId: string; 11 | nickname: string; 12 | message: string; 13 | } 14 | -------------------------------------------------------------------------------- /apps/backend/src/chat/repository/chat.memory.repository.spec.ts: -------------------------------------------------------------------------------- 1 | import { ChatRepositoryMemory } from './chat.memory.repository'; 2 | import { ChatMessage } from '../entities/chat-message.entity'; 3 | 4 | // ChatRepositoryMemory 테스트 파일 5 | describe('ChatRepositoryMemory', () => { 6 | let repository: ChatRepositoryMemory; 7 | let chatStorage: Map; 8 | 9 | beforeEach(() => { 10 | chatStorage = new Map(); 11 | repository = new ChatRepositoryMemory(chatStorage); 12 | }); 13 | 14 | it('채팅 레포지토리가 정의되어 있어야 합니다', () => { 15 | expect(repository).toBeDefined(); 16 | }); 17 | 18 | it('채팅 메시지를 추가할 수 있어야 합니다', async () => { 19 | const chatMessage: ChatMessage = { clientId: '1', message: 'Hello', nickname: 'nickname1' }; 20 | await repository.add('room1', chatMessage); 21 | 22 | const messages = await repository.get('room1'); 23 | expect(messages).toEqual([chatMessage]); 24 | }); 25 | 26 | it('채팅 메시지를 가져올 수 있어야 합니다', async () => { 27 | const chatMessage: ChatMessage = { clientId: '1', message: 'Hello', nickname: 'nickname1' }; 28 | chatStorage.set('room1', [chatMessage]); 29 | 30 | const messages = await repository.get('room1'); 31 | expect(messages).toEqual([chatMessage]); 32 | }); 33 | 34 | it('메시지가 없으면 undefined를 반환해야 합니다', async () => { 35 | const messages = await repository.get('room2'); 36 | expect(messages).toBeNull(); 37 | }); 38 | 39 | it('채팅 메시지를 삭제할 수 있어야 합니다', async () => { 40 | const chatMessage: ChatMessage = { clientId: '1', message: 'Hello', nickname: 'nickname1' }; 41 | chatStorage.set('room1', [chatMessage]); 42 | 43 | await repository.delete('room1'); 44 | const messages = await repository.get('room1'); 45 | expect(messages).toBeNull(); 46 | }); 47 | 48 | it('채팅 메시지가 존재하는지 확인할 수 있어야 합니다', async () => { 49 | const chatMessage: ChatMessage = { clientId: '1', message: 'Hello', nickname: 'nickname1' }; 50 | chatStorage.set('room1', [chatMessage]); 51 | 52 | const hasMessages = await repository.has('room1'); 53 | expect(hasMessages).toBe(true); 54 | 55 | const hasNoMessages = await repository.has('room2'); 56 | expect(hasNoMessages).toBe(false); 57 | }); 58 | 59 | it('채팅 메시지의 수를 제한할 수 있어야 합니다', async () => { 60 | const chatMessages: ChatMessage[] = Array.from({ length: 51 }, (_, i) => ({ 61 | clientId: `${i}`, 62 | message: `Message ${i}`, 63 | nickname: `nickname${i}`, 64 | })); 65 | for (const message of chatMessages) { 66 | await repository.add('room1', message); 67 | } 68 | 69 | const messages = await repository.get('room1'); 70 | expect(messages.length).toBe(50); 71 | expect(messages[0].clientId).toBe('1'); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /apps/backend/src/chat/repository/chat.memory.repository.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { ChatMessage } from '../entities/chat-message.entity'; 3 | 4 | @Injectable() 5 | export class ChatRepositoryMemory { 6 | private readonly maxMessageLength: number; 7 | 8 | constructor( 9 | @Inject('ChatStorage') 10 | private readonly data: Map, 11 | ) { 12 | this.maxMessageLength = 50; 13 | } 14 | 15 | async set(id: string) { 16 | this.data.set(id, []); 17 | } 18 | 19 | async get(id: string) { 20 | return this.data.get(id) || null; 21 | } 22 | 23 | async add(id: string, chatMessage: ChatMessage) { 24 | let messages = this.data.get(id); 25 | if (!messages) { 26 | messages = []; 27 | } 28 | if (messages.length >= this.maxMessageLength) { 29 | messages.shift(); 30 | } 31 | messages.push(chatMessage); 32 | this.data.set(id, messages); 33 | } 34 | 35 | async has(id: string) { 36 | return this.data.has(id); 37 | } 38 | 39 | async delete(id: string) { 40 | this.data.delete(id); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /apps/backend/src/common/base-entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PrimaryGeneratedColumn, 3 | CreateDateColumn, 4 | UpdateDateColumn, BeforeUpdate, 5 | } from 'typeorm'; 6 | 7 | export abstract class BaseEntity { 8 | @PrimaryGeneratedColumn() 9 | id: number; 10 | 11 | @CreateDateColumn({ 12 | type: 'datetime', 13 | default: () => 'CURRENT_TIMESTAMP', 14 | name: 'created_at', 15 | }) 16 | createAt: Date; 17 | 18 | @UpdateDateColumn({ 19 | type: 'datetime', 20 | default: () => 'CURRENT_TIMESTAMP', 21 | name: 'updated_at', 22 | }) 23 | updateAt: Date; 24 | 25 | @BeforeUpdate() 26 | updateTimestamp() { 27 | this.updateAt = new Date(); // SQLite에서 수동으로 갱신 28 | } 29 | } -------------------------------------------------------------------------------- /apps/backend/src/common/constants.ts: -------------------------------------------------------------------------------- 1 | export enum QUIZ_ZONE_STAGE { 2 | LOBBY = 'LOBBY', 3 | IN_PROGRESS = 'IN_PROGRESS', 4 | RESULT = 'RESULT', 5 | } 6 | 7 | export enum PLAYER_STATE { 8 | WAIT = 'WAIT', 9 | PLAY = 'PLAY', 10 | SUBMIT = 'SUBMIT', 11 | } 12 | 13 | export const CLOSE_CODE = { 14 | NORMAL: 1000, 15 | GOING_AWAY: 1001, 16 | PROTOCOL_ERROR: 1002, 17 | REFUSE: 1003, 18 | NO_STATUS: 1005, 19 | ABNORMAL: 1006, 20 | INCONSISTENT_DATA: 1007, 21 | POLICY_VIOLATION: 1008, 22 | }; 23 | 24 | // 닉네임 접두사 25 | const prefixes: string[] = [ 26 | '불사조', 27 | '천상의', 28 | '불의', 29 | '붉은', 30 | '어둠의', 31 | '달빛', 32 | '푸른', 33 | '검은', 34 | '황금의', 35 | '은빛', 36 | '새벽의', 37 | '태양의', 38 | '영원의', 39 | '바다의', 40 | '대지의', 41 | '하늘의', 42 | '강철의', 43 | '빛의', 44 | '고대의', 45 | '심연의', 46 | '구름의', 47 | '섬광의', 48 | '운명의', 49 | '폭풍의', 50 | '신비의', 51 | '얼음의', 52 | '화염의', 53 | '별의', 54 | '천둥의', 55 | '미친', 56 | ]; 57 | 58 | // 닉네임 접미사 59 | const suffixes: string[] = [ 60 | '마법사', 61 | '현자', 62 | '검사', 63 | '용사', 64 | '기사', 65 | '전사', 66 | '궁수', 67 | '암살자', 68 | '무사', 69 | '도적', 70 | '영웅', 71 | '제왕', 72 | '성자', 73 | '마왕', 74 | '기병', 75 | '법사', 76 | '검객', 77 | '마술사', 78 | '투사', 79 | '마수', 80 | '검투사', 81 | '마인', 82 | '왕', 83 | '사제', 84 | '괴수', 85 | '현자', 86 | '용자', 87 | '유령', 88 | '악마', 89 | '정령', 90 | ]; 91 | 92 | export const getRandomNickName = (): string => { 93 | return `${prefixes[Math.floor(Math.random() * prefixes.length)]}${suffixes[Math.floor(Math.random() * suffixes.length)]}`; 94 | }; 95 | 96 | export enum QUIZ_TYPE { 97 | SHORT_ANSWER = 'SHORT', 98 | } 99 | -------------------------------------------------------------------------------- /apps/backend/src/core/SessionWsAdapter.ts: -------------------------------------------------------------------------------- 1 | import { WsAdapter } from '@nestjs/platform-ws'; 2 | import { RequestHandler } from 'express'; 3 | import { NestApplication } from '@nestjs/core'; 4 | import { Session } from 'express-session'; 5 | 6 | export interface WebSocketWithSession extends WebSocket { 7 | session: Session; 8 | } 9 | 10 | export class SessionWsAdapter extends WsAdapter { 11 | constructor( 12 | private readonly app: NestApplication | any, 13 | private readonly sessionMiddleware: RequestHandler, 14 | ) { 15 | super(app); 16 | } 17 | 18 | create( 19 | port: number, 20 | options?: Record & { 21 | namespace?: string; 22 | server?: any; 23 | path?: string; 24 | }, 25 | ): any { 26 | const httpServer = this.app.getHttpServer(); 27 | const wsServer = super.create(port, options); 28 | 29 | httpServer.removeAllListeners('upgrade'); 30 | 31 | httpServer.on('upgrade', (request, socket, head) => { 32 | this.sessionMiddleware(request, {} as any, () => { 33 | try { 34 | const baseUrl = 'ws://' + request.headers.host + '/'; 35 | const pathname = new URL(request.url, baseUrl).pathname; 36 | const wsServersCollection = this.wsServersRegistry.get(port); 37 | 38 | let isRequestDelegated = false; 39 | for (const wsServer of wsServersCollection) { 40 | if (pathname === wsServer.path) { 41 | wsServer.handleUpgrade(request, socket, head, (ws: unknown) => { 42 | ws['session'] = request.session; 43 | wsServer.emit('connection', ws, request); 44 | }); 45 | isRequestDelegated = true; 46 | break; 47 | } 48 | } 49 | if (!isRequestDelegated) { 50 | socket.destroy(); 51 | } 52 | } catch (err: any) { 53 | socket.end('HTTP/1.1 400\r\n' + err.message); 54 | } 55 | }); 56 | }); 57 | 58 | return wsServer; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /apps/backend/src/logger/http-logger.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, Logger, NestMiddleware } from '@nestjs/common'; 2 | import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; 3 | 4 | @Injectable() 5 | export class HttpLoggingMiddleware implements NestMiddleware { 6 | constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger) {} 7 | 8 | use(req: any, res: any, next: () => void) { 9 | const { method, url } = req; 10 | const startTime = Date.now(); 11 | 12 | res.on('finish', () => { 13 | const duration = Date.now() - startTime; 14 | this.logger.log('info', `${method} ${url} ${res.statusCode} - ${duration}ms`, { 15 | context: 'HTTP', 16 | }); 17 | }); 18 | 19 | next(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /apps/backend/src/logger/winston.config.ts: -------------------------------------------------------------------------------- 1 | import { WinstonModuleOptions } from 'nest-winston'; 2 | import * as winston from 'winston'; 3 | 4 | export const winstonConfig: WinstonModuleOptions = { 5 | transports: [ 6 | new winston.transports.Console({ 7 | format: winston.format.combine( 8 | winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), 9 | winston.format.colorize(), 10 | winston.format.printf(({ level, message, timestamp, context }) => { 11 | return `[${timestamp}] [${level}] ${context ? `[${context}] ` : ''}${message}`; 12 | }), 13 | ), 14 | }), 15 | new winston.transports.File({ 16 | filename: 'logs/app.log', 17 | format: winston.format.json(), 18 | }), 19 | ], 20 | }; 21 | -------------------------------------------------------------------------------- /apps/backend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import cookieParser from 'cookie-parser'; 4 | import session from 'express-session'; 5 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 6 | import { ConfigService } from '@nestjs/config'; 7 | import { Environment } from '../config/http.config'; 8 | import { INestApplication, ValidationPipe } from '@nestjs/common'; 9 | import { SessionWsAdapter } from './core/SessionWsAdapter'; 10 | import { initializeTransactionalContext } from 'typeorm-transactional'; 11 | 12 | async function bootstrap() { 13 | initializeTransactionalContext(); 14 | 15 | const app = await NestFactory.create(AppModule); 16 | const configService = app.get(ConfigService); 17 | 18 | const env = configService.get('env'); 19 | const port = configService.get('port'); 20 | const sessionSecret = configService.get('sessionSecret'); 21 | 22 | switch (env) { 23 | case Environment.Development: 24 | case Environment.Staging: 25 | setupSwagger(app); 26 | break; 27 | case Environment.Production: 28 | break; 29 | } 30 | 31 | const sessionMiddleware = session({ 32 | secret: sessionSecret, 33 | resave: false, 34 | saveUninitialized: true, 35 | }); 36 | 37 | app.use(cookieParser()); 38 | app.use(sessionMiddleware); 39 | 40 | app.useGlobalPipes(new ValidationPipe({ transform: true })); 41 | app.useWebSocketAdapter(new SessionWsAdapter(app, sessionMiddleware)); 42 | 43 | await app.listen(port); 44 | } 45 | 46 | function setupSwagger(app: INestApplication) { 47 | const config = new DocumentBuilder() 48 | .setTitle('BooQuiz') 49 | .setDescription('BooQuiz API description') 50 | .build(); 51 | const document = SwaggerModule.createDocument(app, config); 52 | SwaggerModule.setup('/api/swagger', app, document); 53 | } 54 | 55 | bootstrap(); 56 | -------------------------------------------------------------------------------- /apps/backend/src/play/dto/current-quiz-result.dto.ts: -------------------------------------------------------------------------------- 1 | export interface CurrentQuizResultDto { 2 | answer?: string; 3 | totalPlayerCount: number; 4 | correctPlayerCount: number; 5 | } 6 | -------------------------------------------------------------------------------- /apps/backend/src/play/dto/current-quiz.dto.ts: -------------------------------------------------------------------------------- 1 | import { QUIZ_ZONE_STAGE } from '../../common/constants'; 2 | 3 | /** 4 | * 현재 진행중인 퀴즈에 대한 DTO 5 | * 6 | * @property question - 현재 진행 중인 퀴즈의 질문 7 | * @property stage - 현재 퀴즈의 진행 상태 8 | * @property currentIndex - 현재 퀴즈의 인덱스 9 | * @property playTime - 퀴즈 플레이 시간 10 | * @property startTime - 퀴즈 시작 시간 11 | * @property deadlineTime - 퀴즈 마감 시간 12 | */ 13 | export interface CurrentQuizDto { 14 | readonly question: string; 15 | readonly stage: QUIZ_ZONE_STAGE; 16 | readonly currentIndex: number; 17 | readonly playTime: number; 18 | readonly startTime: number; 19 | readonly deadlineTime: number; 20 | } 21 | -------------------------------------------------------------------------------- /apps/backend/src/play/dto/quiz-join.dto.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 퀴즈 참여를 위한 DTO 3 | * 4 | * @property quizZoneId - 참여할 퀴즈 존 ID 5 | */ 6 | export interface QuizJoinDto { 7 | quizZoneId: string; 8 | } -------------------------------------------------------------------------------- /apps/backend/src/play/dto/quiz-result-summary.dto.ts: -------------------------------------------------------------------------------- 1 | import { SubmittedQuiz } from '../../quiz-zone/entities/submitted-quiz.entity'; 2 | import { Quiz } from '../../quiz-zone/entities/quiz.entity'; 3 | 4 | /** 5 | * 퀴즈 결과 출력을 위한 DTO 6 | * 7 | * @property score - 퀴즈 결과/점수 8 | * @property submits - 플레이어가 제출한 퀴즈 목록 9 | * @property quizzes - 전체 퀴즈 목록 10 | */ 11 | export interface QuizResultSummaryDto { 12 | score: number; 13 | submits: SubmittedQuiz[]; 14 | quizzes: Quiz[]; 15 | } 16 | -------------------------------------------------------------------------------- /apps/backend/src/play/dto/quiz-submit.dto.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 클라이언트의 퀴즈 제출을 위한 DTO 3 | * @property index - 퀴즈의 인덱스 4 | * @property answer - 플레이어가 제출한 답 5 | * @property submittedAt - 플레이어가 정답을 제출한 시각 6 | */ 7 | export interface QuizSubmitDto { 8 | index: number; 9 | answer: string; 10 | submittedAt: number; 11 | } 12 | -------------------------------------------------------------------------------- /apps/backend/src/play/dto/response-player.dto.ts: -------------------------------------------------------------------------------- 1 | class ResponsePlayerDto { 2 | constructor( 3 | public readonly id: string, 4 | public readonly nickname: string, 5 | ) {} 6 | } 7 | -------------------------------------------------------------------------------- /apps/backend/src/play/dto/submit-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ChatMessage } from 'src/chat/entities/chat-message.entity'; 2 | export class SubmitResponseDto { 3 | constructor( 4 | public readonly fastestPlayerIds: string[], 5 | public readonly submittedCount: number, 6 | public readonly totalPlayerCount: number, 7 | public readonly chatMessages: ChatMessage[], 8 | ) {} 9 | } 10 | -------------------------------------------------------------------------------- /apps/backend/src/play/entities/client-info.entity.ts: -------------------------------------------------------------------------------- 1 | import { WebSocketWithSession } from '../../core/SessionWsAdapter'; 2 | 3 | /** 4 | * 퀴즈 클라이언트의 정보를 나타냅니다. 5 | * 6 | * @property quizZoneId - 클라이언트가 참여한 퀴즈존 ID 7 | * @property socket - 클라이언트의 WebSocket 연결 8 | */ 9 | export interface ClientInfo { 10 | quizZoneId: string; 11 | socket: WebSocketWithSession; 12 | } 13 | -------------------------------------------------------------------------------- /apps/backend/src/play/entities/quiz-summary.entity.ts: -------------------------------------------------------------------------------- 1 | import { Rank } from './rank.entity'; 2 | 3 | export interface QuizSummary { 4 | readonly ranks: Rank[]; 5 | readonly endSocketTime?: number; 6 | } -------------------------------------------------------------------------------- /apps/backend/src/play/entities/rank.entity.ts: -------------------------------------------------------------------------------- 1 | export interface Rank { 2 | readonly id: string; 3 | readonly nickname: string; 4 | readonly score: number; 5 | readonly ranking: number; 6 | } -------------------------------------------------------------------------------- /apps/backend/src/play/entities/send-event.entity.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 웹소켓 서버가 사용자에게 응답할 메시지 형식을 정의합니다. 3 | * 4 | * @property event - 클라이언트에게 전송할 이벤트 이름 5 | * @property data - 클라이언트에게 전송할 데이터 6 | */ 7 | export interface SendEventMessage { 8 | event: string; 9 | data: T; 10 | } -------------------------------------------------------------------------------- /apps/backend/src/play/play.gateway.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { PlayGateway } from './play.gateway'; 3 | import { PlayService } from './play.service'; 4 | import { QuizZoneService } from '../quiz-zone/quiz-zone.service'; 5 | import { ChatService } from '../chat/chat.service'; 6 | 7 | describe('PlayGateway', () => { 8 | let gateway: PlayGateway; 9 | 10 | const mockPlayService = { 11 | join: jest.fn(), 12 | }; 13 | 14 | const mockQuizZoneService = { 15 | findOne: jest.fn(), 16 | }; 17 | 18 | beforeEach(async () => { 19 | const module: TestingModule = await Test.createTestingModule({ 20 | providers: [ 21 | PlayGateway, 22 | { 23 | provide: PlayService, 24 | useValue: mockPlayService, 25 | }, 26 | { 27 | provide: 'PlayInfoStorage', 28 | useValue: new Map(), 29 | }, 30 | { 31 | provide: 'ClientInfoStorage', 32 | useValue: new Map(), 33 | }, 34 | { 35 | provide: QuizZoneService, 36 | useValue: mockQuizZoneService, 37 | }, 38 | { 39 | provide: ChatService, 40 | useValue: { 41 | /* mock implementation */ 42 | }, 43 | }, 44 | ], 45 | }).compile(); 46 | 47 | gateway = module.get(PlayGateway); 48 | }); 49 | 50 | it('should be defined', () => { 51 | expect(gateway).toBeDefined(); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /apps/backend/src/play/play.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PlayService } from './play.service'; 3 | import { PlayGateway } from './play.gateway'; 4 | import { QuizZoneModule } from '../quiz-zone/quiz-zone.module'; 5 | import { ChatModule } from 'src/chat/chat.module'; 6 | 7 | @Module({ 8 | imports: [QuizZoneModule, ChatModule], 9 | providers: [ 10 | PlayGateway, 11 | { 12 | provide: 'PlayInfoStorage', 13 | useValue: new Map(), 14 | }, 15 | { 16 | provide: 'ClientInfoStorage', 17 | useValue: new Map(), 18 | }, 19 | PlayService, 20 | ], 21 | }) 22 | export class PlayModule {} 23 | -------------------------------------------------------------------------------- /apps/backend/src/quiz-zone/dto/check-existing-quiz-zone.dto.ts: -------------------------------------------------------------------------------- 1 | class CheckExistingQuizZoneDto { 2 | constructor( 3 | public readonly isDuplicateConnection: boolean, 4 | public readonly newQuizZoneId: string, 5 | public readonly existingQuizZoneId?: string, 6 | ) {} 7 | } 8 | -------------------------------------------------------------------------------- /apps/backend/src/quiz-zone/dto/create-quiz-zone.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsInt, IsNotEmpty, IsString, Length, Matches, Max, Min } from 'class-validator'; 2 | 3 | /** 4 | * 퀴즈존을 생성할 때 사용하는 DTO 클래스 5 | * 6 | * 퀴즈존 ID는 다음 규칙을 따릅니다: 7 | * - 5-10글자 길이 8 | * - 숫자와 알파벳 조합 9 | * - 중복 불가 (중복 체크 로직 추가 예정) 10 | */ 11 | export class CreateQuizZoneDto { 12 | @IsString({ message: '핀번호가 없습니다.' }) 13 | @Length(5, 10, { message: '핀번호는 5글자 이상 10글자 이하로 입력해주세요.' }) 14 | @Matches(RegExp('^[a-zA-Z0-9]*$'), { message: '숫자와 알파벳 조합만 가능합니다.' }) 15 | readonly quizZoneId: string; 16 | 17 | @IsString({ message: '제목이 없습니다.' }) 18 | @Length(1, 100, { message: '제목은 1글자 이상 100글자 이하로 입력해주세요.' }) 19 | readonly title: string; 20 | 21 | @IsString({ message: '설명이 없습니다.' }) 22 | @Length(0, 300, { message: '설명은 300글자 이하로 입력해주세요.' }) 23 | readonly description: string; 24 | 25 | @IsInt({ message: '최대 플레이어 수가 없습니다.' }) 26 | @Min(1, { message: '최소 1명 이상이어야 합니다.' }) 27 | @Max(300, { message: '최대 300명까지 가능합니다.' }) 28 | readonly limitPlayerCount: number; 29 | 30 | @IsNotEmpty({ message: '퀴즈존을 선택해주세요.' }) 31 | quizSetId: number; 32 | } 33 | -------------------------------------------------------------------------------- /apps/backend/src/quiz-zone/dto/find-quiz-zone.dto.ts: -------------------------------------------------------------------------------- 1 | import { PLAYER_STATE, QUIZ_ZONE_STAGE } from '../../common/constants'; 2 | import { CurrentQuizDto } from '../../play/dto/current-quiz.dto'; 3 | import { SubmittedQuiz } from '../entities/submitted-quiz.entity'; 4 | import { ChatMessage } from 'src/chat/entities/chat-message.entity'; 5 | import { Rank } from '../../play/entities/rank.entity'; 6 | import { Quiz } from '../entities/quiz.entity'; 7 | 8 | /** 9 | * 퀴즈 게임에 참여하는 플레이어 엔티티 10 | * 11 | * @property id: 플레이어 세션 ID 12 | * @property nickname: 플레이어의 닉네임 13 | * @property score: 플레이어의 점수 14 | * @property submits: 플레이어가 제출한 퀴즈 목록 15 | * @property state: 플레이어의 현재 상태 16 | */ 17 | export interface Player { 18 | id: string; 19 | nickname: string; 20 | score?: number; 21 | submits?: SubmittedQuiz[]; 22 | state: PLAYER_STATE; 23 | } 24 | 25 | /** 26 | * 퀴즈 존을 찾기 위한 DTO 27 | * 28 | * @property currentPlayer - 현재 플레이어 29 | * @property title - 퀴즈 존의 제목 30 | * @property description - 퀴즈 존의 설명 31 | * @property quizCount - 퀴즈 존의 퀴즈 개수 32 | * @property stage - 퀴즈 존의 진행 상태 33 | * @property hostId - 퀴즈 존의 호스트 ID 34 | * @property currentQuiz - 현재 출제 중인 퀴즈 35 | * @property maxPlayers - 퀴즈 존의 최대 플레이어 수 36 | */ 37 | export interface FindQuizZoneDto { 38 | readonly currentPlayer: Player; 39 | readonly title: string; 40 | readonly description: string; 41 | readonly quizCount: number; 42 | readonly stage: QUIZ_ZONE_STAGE; 43 | readonly hostId: string; 44 | readonly currentQuiz?: CurrentQuizDto; 45 | readonly maxPlayers?: number; 46 | readonly chatMessages?: ChatMessage[]; 47 | 48 | readonly ranks?: Rank[]; 49 | readonly endSocketTime?: number; 50 | readonly score?: number; 51 | readonly quizzes?: Quiz[]; 52 | readonly submits?: SubmittedQuiz[]; 53 | } 54 | -------------------------------------------------------------------------------- /apps/backend/src/quiz-zone/entities/player.entity.ts: -------------------------------------------------------------------------------- 1 | import { SubmittedQuiz } from './submitted-quiz.entity'; 2 | import { PLAYER_STATE } from '../../common/constants'; 3 | 4 | /** 5 | * 퀴즈 게임에 참여하는 플레이어 엔티티 6 | * 7 | * @property id: 플레이어 세션 ID 8 | * @property score: 플레이어의 점수 9 | * @property submits: 플레이어가 제출한 퀴즈 목록 10 | * @property state: 플레이어의 현재 상태 11 | */ 12 | export interface Player { 13 | id: string; 14 | nickname: string; 15 | score: number; 16 | submits: SubmittedQuiz[]; 17 | state: PLAYER_STATE; 18 | } 19 | -------------------------------------------------------------------------------- /apps/backend/src/quiz-zone/entities/quiz-zone.entity.ts: -------------------------------------------------------------------------------- 1 | import { Quiz } from './quiz.entity'; 2 | import { Player } from './player.entity'; 3 | import { QUIZ_ZONE_STAGE } from '../../common/constants'; 4 | import { QuizSummary } from '../../play/entities/quiz-summary.entity'; 5 | /** 6 | * 퀴즈 게임을 진행하는 공간을 나타내는 퀴즈존 인터페이스 7 | * 8 | * @property players 플레이어 목록 9 | * @property hostId 퀴즈 존을 생성한 관리자 ID 10 | * @property maxPlayers 퀴즈 존의 최대 플레이어 수 11 | * @property quizzes 퀴즈 목록 12 | * @property stage 퀴즈 존의 현재 상태 13 | * @property title 퀴즈 세트의 제목 14 | * @property description 퀴즈 세트의 설명 15 | * @property currentQuizIndex 현재 출제 중인 퀴즈의 인덱스 16 | * @property currentQuizStartTime 현재 퀴즈의 출제 시작 시간 17 | * @property currentQuizDeadlineTime 현재 퀴즈의 제출 마감 시간 18 | * @property intervalTime 퀴즈 간의 간격 시간 19 | */ 20 | export interface QuizZone { 21 | players: Map; 22 | hostId: string; 23 | maxPlayers: number; 24 | quizzes: Quiz[]; 25 | stage: QUIZ_ZONE_STAGE; 26 | title: string; 27 | description: string; 28 | currentQuizIndex: number; 29 | currentQuizStartTime: number; 30 | currentQuizDeadlineTime: number; 31 | intervalTime: number; 32 | summaries?: QuizSummary; 33 | } 34 | -------------------------------------------------------------------------------- /apps/backend/src/quiz-zone/entities/quiz.entity.ts: -------------------------------------------------------------------------------- 1 | import { QUIZ_TYPE } from '../../common/constants'; 2 | 3 | /** 4 | * 퀴즈 엔티티 5 | * 6 | * @property question: 퀴즈의 질문 7 | * @property answer: 퀴즈의 정답 8 | * @property playTime: 퀴즈의 플레이 시간 9 | */ 10 | export interface Quiz { 11 | question: string; 12 | answer: string; 13 | playTime: number; 14 | quizType: QUIZ_TYPE; 15 | } 16 | -------------------------------------------------------------------------------- /apps/backend/src/quiz-zone/entities/submitted-quiz.entity.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 플레이어가 제출한 퀴즈 엔티티 3 | * 4 | * @property index: 퀴즈의 인덱스 5 | * @property answer: 플레이어가 제출한 답 6 | * @property submittedAt: 플레이어가 제출한 시각 7 | * @property receivedAt: 플레이어가 제출한 시각 8 | */ 9 | export interface SubmittedQuiz { 10 | index: number; 11 | answer?: string; 12 | submittedAt?: number; 13 | receivedAt?: number; 14 | submitRank?: number; 15 | } 16 | -------------------------------------------------------------------------------- /apps/backend/src/quiz-zone/quiz-zone.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Body, 4 | Controller, 5 | Get, 6 | HttpCode, 7 | Param, 8 | Post, 9 | Session, 10 | } from '@nestjs/common'; 11 | import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; 12 | import { QuizZoneService } from './quiz-zone.service'; 13 | import { ChatService } from '../chat/chat.service'; 14 | import { CreateQuizZoneDto } from './dto/create-quiz-zone.dto'; 15 | 16 | @ApiTags('Quiz Zone') 17 | @Controller('quiz-zone') 18 | export class QuizZoneController { 19 | constructor( 20 | private readonly quizZoneService: QuizZoneService, 21 | private readonly chatService: ChatService, 22 | ) {} 23 | 24 | @Post() 25 | @HttpCode(201) 26 | @ApiOperation({ summary: '새로운 퀴즈존 생성' }) 27 | @ApiResponse({ status: 201, description: '퀴즈존이 성공적으로 생성되었습니다.' }) 28 | @ApiResponse({ status: 400, description: '세션 정보가 없습니다.' }) 29 | async create( 30 | @Body() createQuizZoneDto: CreateQuizZoneDto, 31 | @Session() session: Record, 32 | ): Promise { 33 | if (!session || !session.id) { 34 | throw new BadRequestException('세션 정보가 없습니다.'); 35 | } 36 | const hostId = session.id; 37 | await this.quizZoneService.create(createQuizZoneDto, hostId); 38 | await this.chatService.set(createQuizZoneDto.quizZoneId); 39 | } 40 | 41 | @Get('check/:quizZoneId') 42 | @HttpCode(200) 43 | @ApiOperation({ summary: '사용자 참여중인 퀴즈존 정보 확인' }) 44 | @ApiParam({ name: 'id', description: '퀴즈존의 ID' }) 45 | @ApiResponse({ 46 | status: 200, 47 | description: '기존 참여 정보가 성공적으로 반환되었습니다.', 48 | }) 49 | @ApiResponse({ status: 400, description: '세션 정보가 없습니다.' }) 50 | async checkExistingQuizZoneParticipation( 51 | @Session() session: Record, 52 | @Param('quizZoneId') quizZoneId: string, 53 | ) { 54 | const sessionQuizZoneId = session.quizZoneId; 55 | return sessionQuizZoneId === undefined || sessionQuizZoneId === quizZoneId; 56 | } 57 | 58 | @Get(':quizZoneId') 59 | @HttpCode(200) 60 | @ApiOperation({ summary: '사용자에 대한 퀴즈존 정보 반환' }) 61 | @ApiParam({ name: 'id', description: '퀴즈존의 ID' }) 62 | @ApiResponse({ 63 | status: 200, 64 | description: '대기실 정보가 성공적으로 반환되었습니다.', 65 | }) 66 | @ApiResponse({ status: 400, description: '세션 정보가 없습니다.' }) 67 | async findQuizZoneInfo( 68 | @Session() session: Record, 69 | @Param('quizZoneId') quizZoneId: string, 70 | ) { 71 | const serverTime = Date.now(); 72 | const quizZoneInfo = await this.quizZoneService.getQuizZoneInfo( 73 | session.id, 74 | quizZoneId, 75 | session.quizZoneId, 76 | ); 77 | session['quizZoneId'] = quizZoneId; 78 | return { 79 | ...quizZoneInfo, 80 | serverTime, 81 | }; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /apps/backend/src/quiz-zone/quiz-zone.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { QuizZoneService } from './quiz-zone.service'; 3 | import { QuizZoneController } from './quiz-zone.controller'; 4 | import { QuizZoneRepositoryMemory } from './repository/quiz-zone.memory.repository'; 5 | import { QuizService } from '../quiz/quiz.service'; 6 | import { QuizModule } from '../quiz/quiz.module'; 7 | import { ChatModule } from 'src/chat/chat.module'; 8 | 9 | @Module({ 10 | controllers: [QuizZoneController], 11 | imports: [QuizModule, ChatModule], 12 | providers: [ 13 | QuizZoneService, 14 | { 15 | provide: 'QuizZoneRepository', 16 | useClass: QuizZoneRepositoryMemory, 17 | }, 18 | { 19 | provide: 'QuizZoneStorage', 20 | useValue: new Map(), 21 | }, 22 | ], 23 | exports: [QuizZoneService], 24 | }) 25 | export class QuizZoneModule {} 26 | -------------------------------------------------------------------------------- /apps/backend/src/quiz-zone/repository/quiz-zone.memory.repository.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { QuizZoneRepositoryMemory } from './quiz-zone.memory.repository'; 3 | import { QuizZone } from '../entities/quiz-zone.entity'; 4 | import { QUIZ_ZONE_STAGE } from '../../common/constants'; 5 | 6 | describe('QuizZoneRepositoryMemory', () => { 7 | let storage: Map; 8 | let repository: QuizZoneRepositoryMemory; 9 | 10 | beforeEach(async () => { 11 | storage = new Map(); 12 | 13 | const module: TestingModule = await Test.createTestingModule({ 14 | providers: [ 15 | QuizZoneRepositoryMemory, 16 | { 17 | provide: 'QuizZoneStorage', 18 | useValue: storage, 19 | }, 20 | ], 21 | }).compile(); 22 | 23 | repository = module.get(QuizZoneRepositoryMemory); 24 | }); 25 | 26 | describe('set', () => { 27 | it('세션 id와 퀴즈존 정보를 통해 새로운 퀴즈존 정보를 저장한다.', async () => { 28 | const quizZoneId = 'some-id'; 29 | 30 | const quizZone: QuizZone = { 31 | players: new Map(), 32 | hostId: 'adminId', 33 | maxPlayers: 4, 34 | quizzes: [], 35 | stage: QUIZ_ZONE_STAGE.LOBBY, 36 | title: 'title', 37 | description: 'description', 38 | currentQuizIndex: 0, 39 | currentQuizStartTime: 0, 40 | currentQuizDeadlineTime: 0, 41 | intervalTime: 30_000, 42 | }; 43 | 44 | await repository.set(quizZoneId, quizZone); 45 | 46 | expect(storage.get(quizZoneId)).toEqual(quizZone); // 생성된 객체와 동일한지 확인 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /apps/backend/src/quiz-zone/repository/quiz-zone.memory.repository.ts: -------------------------------------------------------------------------------- 1 | import { IQuizZoneRepository } from './quiz-zone.repository.interface'; 2 | import { Inject, Injectable } from '@nestjs/common'; 3 | import { QuizZone } from '../entities/quiz-zone.entity'; 4 | 5 | @Injectable() 6 | export class QuizZoneRepositoryMemory implements IQuizZoneRepository { 7 | constructor( 8 | @Inject('QuizZoneStorage') 9 | private readonly data: Map, 10 | ) {} 11 | 12 | async get(id: string) { 13 | return this.data.get(id) ?? null; 14 | } 15 | 16 | async set(id: string, quizZone: QuizZone) { 17 | this.data.set(id, quizZone); 18 | } 19 | 20 | async has(id: string) { 21 | return this.data.has(id); 22 | } 23 | 24 | async delete(id: string) { 25 | this.data.delete(id); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apps/backend/src/quiz-zone/repository/quiz-zone.repository.interface.ts: -------------------------------------------------------------------------------- 1 | import { QuizZone } from '../entities/quiz-zone.entity'; 2 | 3 | /** 4 | * 퀴즈 존 저장소를 위한 인터페이스입니다. 5 | * 6 | * 이 인터페이스는 퀴즈 존 데이터를 저장, 조회, 삭제하는 메서드를 정의합니다. 7 | */ 8 | export interface IQuizZoneRepository { 9 | /** 10 | * 주어진 키에 해당하는 퀴즈 존을 반환합니다. 11 | * 12 | * @param key - 조회할 퀴즈 존의 키 13 | * @returns 퀴즈 존 객체 14 | */ 15 | get(key: string): Promise; 16 | 17 | /** 18 | * 주어진 키에 퀴즈 존을 저장합니다. 19 | * 20 | * @param key - 퀴즈 존을 저장할 키 21 | * @param value - 저장할 퀴즈 존 객체 22 | * @returns 저장 작업 완료 23 | */ 24 | set(key: string, value: QuizZone): Promise; 25 | 26 | /** 27 | * 주어진 키에 해당하는 퀴즈 존을 삭제합니다. 28 | * 29 | * @param key - 삭제할 퀴즈 존의 키 30 | * @returns 삭제 작업 완료 31 | */ 32 | delete(key: string): Promise; 33 | 34 | /** 35 | * 주어진 키에 해당하는 퀴즈존이 존재하는지 확인합니다. 36 | * 37 | * @param key - 존재 여부를 확인할 퀴즈존의 키 38 | * @returns 존재 여부 39 | */ 40 | has(key: string): Promise; 41 | } 42 | -------------------------------------------------------------------------------- /apps/backend/src/quiz/dto/create-quiz-set-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { QUIZ_TYPE } from '../../common/constants'; 3 | import { Quiz } from '../entity/quiz.entitiy'; 4 | import { QuizSet } from '../entity/quiz-set.entity'; 5 | import { 6 | ArrayMaxSize, 7 | ArrayMinSize, 8 | IsBoolean, 9 | IsEnum, 10 | IsNumber, 11 | IsString, 12 | Length, 13 | Max, 14 | Min, 15 | ValidateNested, 16 | } from 'class-validator'; 17 | import { Type } from 'class-transformer'; 18 | 19 | export class QuizDetailsDto { 20 | @ApiProperty({ description: '퀴즈 질문' }) 21 | @IsString({ message: '퀴즈 질문은 문자열이어야 합니다.' }) 22 | @Length(1, 200, { message: '퀴즈의 질문은 1~200자 이어야 합니다.' }) 23 | readonly question: string; 24 | 25 | @ApiProperty({ description: '퀴즈 정답' }) 26 | @IsString({ message: '퀴즈 정답은 문자열이어야 합니다.' }) 27 | @Length(1, 30, { message: '퀴즈의 대답은 1~30자 이어야 합니다.' }) 28 | readonly answer: string; 29 | 30 | @ApiProperty({ description: '퀴즈 시간' }) 31 | @Min(3, { message: '퀴즈 시간은 3초 이상이어야 합니다.' }) 32 | @Max(60 * 10, { message: '퀴즈 시간은 10분 이하여야 합니다.' }) 33 | @IsNumber({}, { message: '퀴즈 시간 값이 숫자가 아닙니다.' }) 34 | readonly playTime: number; 35 | 36 | @ApiProperty({ description: '퀴즈 타입' }) 37 | @IsEnum(QUIZ_TYPE, { message: '정해진 퀴즈 타입이 아닙니다.' }) 38 | readonly quizType: QUIZ_TYPE; 39 | 40 | toEntity(quizSet: QuizSet) { 41 | return new Quiz(this.question, this.answer, this.playTime, this.quizType, quizSet); 42 | } 43 | } 44 | 45 | export class CreateQuizSetRequestDto { 46 | @ApiProperty() 47 | @IsString({ message: '퀴즈셋의 이름은 문자열이어야 합니다.' }) 48 | @Length(1, 30, { message: '퀴즈셋의 이름은 1~30자 이어야 합니다.' }) 49 | readonly quizSetName: string; 50 | 51 | @ApiProperty({ description: '퀴즈셋이 기본으로 보일지 결정하는 flag입니다' }) 52 | @IsBoolean({ message: '값이 boolean이어야 합니다.' }) 53 | readonly recommended?: boolean = false; 54 | 55 | @ApiProperty({ type: [QuizDetailsDto], description: '퀴즈 세부 정보' }) 56 | @ValidateNested({ each: true }) 57 | @ArrayMinSize(1, { message: '퀴즈가 한개 이상 포함되어야합니다.' }) 58 | @ArrayMaxSize(10, { message: '퀴즈는 최대 10개만 생성할 수 있습니다.' }) 59 | @Type(() => QuizDetailsDto) 60 | readonly quizDetails: QuizDetailsDto[]; 61 | } 62 | -------------------------------------------------------------------------------- /apps/backend/src/quiz/dto/create-quiz-set-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class CreateQuizSetResponseDto { 4 | @ApiProperty({ description: '새로 생성된 퀴즈셋 id' }) 5 | readonly id: number; 6 | } 7 | -------------------------------------------------------------------------------- /apps/backend/src/quiz/dto/find-quizzes-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { QUIZ_TYPE } from '../../common/constants'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class FindQuizzesResponseDto { 5 | @ApiProperty({ description: '해당 퀴즈 질문' }) 6 | readonly question: string; 7 | @ApiProperty({ description: '해당 퀴즈 정답' }) 8 | readonly answer: string; 9 | @ApiProperty({ description: '해당 퀴즈 시간' }) 10 | readonly playTime: number; 11 | @ApiProperty({ description: '해당 퀴즈 타입' }) 12 | readonly quizType: QUIZ_TYPE; 13 | @ApiProperty({ description: '해당 퀴즈 id' }) 14 | readonly id: number; 15 | } 16 | -------------------------------------------------------------------------------- /apps/backend/src/quiz/dto/search-quiz-set-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsNumber, IsString, Length } from 'class-validator'; 3 | 4 | export class SearchQuizSetRequestDTO { 5 | @IsString({ message: '퀴즈셋의 이름은 문자열이어야 합니다.' }) 6 | @Length(0, 30, { message: '퀴즈셋의 이름은 1~30자 이어야 합니다.' }) 7 | readonly name: string = ''; 8 | @Type(() => Number) 9 | @IsNumber({}, { message: 'page 값이 숫자가 아닙니다.' }) 10 | readonly page?: number = 1; 11 | @Type(() => Number) 12 | @IsNumber({}, { message: '페이지 크기 값이 숫자가 아닙니다.' }) 13 | readonly size?: number = 10; 14 | } 15 | -------------------------------------------------------------------------------- /apps/backend/src/quiz/dto/search-quiz-set-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { QuizSet } from '../entity/quiz-set.entity'; 2 | 3 | export class SearchQuizSetResponseDTO { 4 | 5 | readonly quizSetDetails: QuizSetDetails[]; 6 | readonly total: number; 7 | readonly currentPage: number; 8 | } 9 | 10 | export class QuizSetDetails { 11 | readonly id: number; 12 | readonly name: string; 13 | 14 | static from(quizSet : QuizSet) : QuizSetDetails { 15 | return { 16 | id: quizSet.id, 17 | name: quizSet.name, 18 | }; 19 | } 20 | } -------------------------------------------------------------------------------- /apps/backend/src/quiz/dto/update-quiz-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { QUIZ_TYPE } from '../../common/constants'; 3 | import { Quiz } from '../entity/quiz.entitiy'; 4 | import { IsEnum, IsNumber, IsString, Length, Max, Min } from 'class-validator'; 5 | 6 | export class UpdateQuizRequestDto { 7 | @ApiProperty({ description: '업데이트 하는 퀴즈 질문' }) 8 | @IsString({message: '퀴즈 질문은 문자열이어야 합니다.'}) 9 | @Length(1, 200) 10 | readonly question: string; 11 | @ApiProperty({ description: '업데이트 하는 퀴즈 정답' }) 12 | @IsString({message: '퀴즈 정답은 문자열이어야 합니다.'}) 13 | @Length(1, 30, {message: '퀴즈의 대답은 1~30자 이어야 합니다.'}) 14 | readonly answer: string; 15 | @ApiProperty({ description: '업데이트 하는 퀴즈 시간' }) 16 | @Min(3, {message: '퀴즈 시간은 3초 이상이어야 합니다.'}) 17 | @Max(60 * 10, {message: '퀴즈 시간은 10분 이하여야 합니다.'}) 18 | @IsNumber({}, {message: '퀴즈 시간 값이 숫자가 아닙니다.'}) 19 | readonly playTime: number; 20 | @ApiProperty({ description: '업데이트 하는 퀴즈 타입' }) 21 | @IsEnum(QUIZ_TYPE, {message: '정해진 퀴즈 타입이 아닙니다.'}) 22 | readonly quizType: QUIZ_TYPE; 23 | } 24 | -------------------------------------------------------------------------------- /apps/backend/src/quiz/entity/quiz-set.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; 2 | import { Quiz } from './quiz.entitiy'; 3 | import { BaseEntity } from '../../common/base-entity'; 4 | 5 | @Entity('quiz_set') 6 | export class QuizSet extends BaseEntity { 7 | @PrimaryGeneratedColumn() 8 | id: number; 9 | 10 | @Column({ type: 'varchar', length: 50 }) 11 | name: string; 12 | 13 | @Column({ type: 'boolean', default: false }) 14 | recommended: boolean; 15 | 16 | @OneToMany((type) => Quiz, (quiz) => quiz.quizSet) 17 | quizzes?: Quiz[]; 18 | 19 | constructor(name: string, recommended: boolean = false) { 20 | super(); 21 | this.name = name; 22 | this.recommended = recommended; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /apps/backend/src/quiz/entity/quiz.entitiy.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; 2 | import { QuizSet } from './quiz-set.entity'; 3 | import { QUIZ_TYPE } from '../../common/constants'; 4 | import { BaseEntity } from '../../common/base-entity'; 5 | 6 | /** 7 | * 퀴즈 엔티티 8 | * 9 | * @property question: 퀴즈의 질문 10 | * @property answer: 퀴즈의 정답 11 | * @property playTime: 퀴즈의 플레이 시간 12 | */ 13 | @Entity() 14 | export class Quiz extends BaseEntity { 15 | @PrimaryGeneratedColumn() 16 | id: number; 17 | 18 | @Column({ type: 'varchar', length: 255 }) 19 | question: string; 20 | 21 | @Column({ type: 'varchar', length: 50 }) 22 | answer: string; 23 | 24 | @Column({ type: 'int', name: 'play_time' }) 25 | playTime: number; 26 | 27 | @Column({ name: 'quiz_type', type: 'varchar', length: 20 }) 28 | quizType: QUIZ_TYPE; 29 | 30 | @ManyToOne((type) => QuizSet, (quizSet) => quizSet.quizzes) 31 | @JoinColumn({ name: 'quiz_set_id', referencedColumnName: 'id' }) 32 | quizSet: QuizSet; 33 | 34 | constructor( 35 | question: string, 36 | answer: string, 37 | playTime: number, 38 | quizType: QUIZ_TYPE, 39 | quizSet: QuizSet, 40 | ) { 41 | super(); 42 | this.question = question; 43 | this.answer = answer; 44 | this.playTime = playTime; 45 | this.quizType = quizType; 46 | this.quizSet = quizSet; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /apps/backend/src/quiz/quiz-set.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { QuizController } from './quiz.controller'; 3 | import { QuizService } from './quiz.service'; 4 | import { CreateQuizSetRequestDto } from './dto/create-quiz-set-request.dto'; 5 | import { NotFoundException } from '@nestjs/common'; 6 | import { QuizSetController } from './quiz-set.controller'; 7 | import { QUIZ_TYPE } from '../common/constants'; 8 | 9 | describe('QuizController', () => { 10 | let quizSetController: QuizSetController; 11 | let quizService: QuizService; 12 | 13 | const mockQuizService = { 14 | createQuizSet: jest.fn(), 15 | createQuizzes: jest.fn(), 16 | getQuizzes: jest.fn(), 17 | }; 18 | 19 | beforeEach(async () => { 20 | const module: TestingModule = await Test.createTestingModule({ 21 | controllers: [QuizSetController], 22 | providers: [ 23 | { 24 | provide: QuizService, 25 | useValue: mockQuizService, 26 | }, 27 | ], 28 | }).compile(); 29 | 30 | quizSetController = module.get(QuizSetController); 31 | quizService = module.get(QuizService); 32 | }); 33 | 34 | 35 | describe('createQuiz', () => { 36 | it('새로운 퀴즈를 생성한다.', async () => { 37 | // given 38 | const dto = { 39 | quizSetName: "퀴즈셋 이름", 40 | quizDetails: 41 | [ 42 | { 43 | question: '지브리는 뭘로 돈 벌게요?', 44 | answer: '토토로', 45 | playTime: 30000, 46 | quizType: QUIZ_TYPE.SHORT_ANSWER, 47 | }, 48 | ] 49 | } as CreateQuizSetRequestDto; 50 | 51 | // when 52 | await quizSetController.createQuizSet(dto); 53 | 54 | // then 55 | expect(quizService.createQuizzes).toHaveBeenCalledWith(dto); 56 | }); 57 | }); 58 | 59 | describe('findQuizzes', () => { 60 | it('퀴즈셋의 퀴즈들을 성공적으로 반환한다.', async () => { 61 | // given 62 | const quizSetId = 1; 63 | const quizzes = [ 64 | { 65 | id: 1, 66 | question: '퀴즈 질문', 67 | answer: '퀴즈 정답', 68 | playTime: 1000, 69 | quizType: 'SHORT_ANSWER', 70 | }, 71 | ]; 72 | mockQuizService.getQuizzes.mockResolvedValue(quizzes); 73 | 74 | // when 75 | const result = await quizSetController.findQuizzes(quizSetId); 76 | 77 | // then 78 | expect(quizService.getQuizzes).toHaveBeenCalledWith(quizSetId); 79 | expect(result).toEqual(quizzes); 80 | }); 81 | 82 | it('존재하지 않는 퀴즈셋 ID인 경우 예외를 던진다.', async () => { 83 | // given 84 | const quizSetId = 2; 85 | mockQuizService.getQuizzes.mockRejectedValue( 86 | new NotFoundException(`해당 퀴즈셋을 찾을 수 없습니다.`), 87 | ); 88 | 89 | // when & then 90 | await expect(quizSetController.findQuizzes(quizSetId)).rejects.toThrow(NotFoundException); 91 | }); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /apps/backend/src/quiz/quiz-set.controller.ts: -------------------------------------------------------------------------------- 1 | import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; 2 | import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Query } from '@nestjs/common'; 3 | import { QuizService } from './quiz.service'; 4 | import { SearchQuizSetResponseDTO } from './dto/search-quiz-set-response.dto'; 5 | import { SearchQuizSetRequestDTO } from './dto/search-quiz-set-request.dto'; 6 | import { CreateQuizSetRequestDto } from './dto/create-quiz-set-request.dto'; 7 | import { FindQuizzesResponseDto } from './dto/find-quizzes-response.dto'; 8 | 9 | @ApiTags('Quiz') 10 | @Controller('quiz-set') 11 | export class QuizSetController { 12 | constructor(private quizService: QuizService) {} 13 | 14 | @Get() 15 | @HttpCode(HttpStatus.OK) 16 | @ApiOperation({summary: '퀴즈셋 검색'}) 17 | @ApiResponse({ 18 | status: HttpStatus.OK, 19 | description: '퀴즈셋의 검색을 성공적으로 반환했습니다', 20 | type: SearchQuizSetResponseDTO 21 | }) 22 | async searchQuizSet( 23 | @Query() searchQuery: SearchQuizSetRequestDTO, 24 | ): Promise { 25 | return this.quizService.searchQuizSet(searchQuery); 26 | } 27 | 28 | @Post() 29 | @HttpCode(HttpStatus.CREATED) 30 | @ApiOperation({ summary: '새로운 퀴즈셋, 퀴즈 생성' }) 31 | @ApiResponse({ status: HttpStatus.CREATED, description: '퀴즈셋이 성공적으로 생성되었습니다.' }) 32 | @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: '요청 데이터가 유효하지 않습니다' }) 33 | async createQuizSet( 34 | @Body() createSetQuizDto: CreateQuizSetRequestDto 35 | ) { 36 | return this.quizService.createQuizzes(createSetQuizDto); 37 | } 38 | 39 | @Get(':quizSetId') 40 | @HttpCode(HttpStatus.OK) 41 | @ApiOperation({ summary: '해당 퀴즈셋의 퀴즈들 조회' }) 42 | @ApiResponse({ 43 | status: HttpStatus.OK, 44 | description: '해당 퀴즈셋의 퀴즈들을 성공적으로 반환했습니다.', 45 | type: FindQuizzesResponseDto, 46 | isArray: true, 47 | }) 48 | @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: '해당 퀴즈셋의 id가 없습니다.' }) 49 | async findQuizzes(@Param('quizSetId') quizSetId: number): Promise { 50 | return this.quizService.getQuizzes(quizSetId); 51 | } 52 | 53 | @Delete(':quizSetId') 54 | @HttpCode(HttpStatus.OK) 55 | @ApiOperation({ summary: '퀴즈셋 삭제' }) 56 | @ApiResponse({ 57 | status: HttpStatus.OK, 58 | description: '해당 퀴즈셋의 퀴즈들을 성공적으로 삭제했습니다.', 59 | }) 60 | @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: '해당 퀴즈셋의 id가 없습니다.' }) 61 | async deleteQuizSet(@Param('quizSetId') quizSetId: number): Promise { 62 | return this.quizService.deleteQuizSet(quizSetId); 63 | } 64 | } -------------------------------------------------------------------------------- /apps/backend/src/quiz/quiz.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { QuizController } from './quiz.controller'; 3 | import { QuizService } from './quiz.service'; 4 | import { UpdateQuizRequestDto } from './dto/update-quiz-request.dto'; 5 | import { QUIZ_TYPE } from '../common/constants'; 6 | 7 | describe('QuizController', () => { 8 | let quizController: QuizController; 9 | let quizService: QuizService; 10 | 11 | const mockQuizService = { 12 | createQuizSet: jest.fn(), 13 | createQuizzes: jest.fn(), 14 | getQuizzes: jest.fn(), 15 | deleteQuiz: jest.fn(), 16 | updateQuiz: jest.fn(), 17 | }; 18 | 19 | beforeEach(async () => { 20 | const module: TestingModule = await Test.createTestingModule({ 21 | controllers: [QuizController], 22 | providers: [ 23 | { 24 | provide: QuizService, 25 | useValue: mockQuizService, 26 | }, 27 | ], 28 | }).compile(); 29 | 30 | quizController = module.get(QuizController); 31 | quizService = module.get(QuizService); 32 | }); 33 | 34 | it('updateQuiz', async () => { 35 | //given 36 | const quizId = 1; 37 | const updateQuizRequestDto: UpdateQuizRequestDto = { 38 | question: '업데이트 퀴즈 질문', 39 | answer: '업데이트 퀴즈 정답', 40 | playTime: 10, 41 | quizType: QUIZ_TYPE.SHORT_ANSWER 42 | } 43 | 44 | mockQuizService.updateQuiz.mockResolvedValue(undefined); 45 | 46 | //when 47 | await quizController.updateQuiz(quizId, updateQuizRequestDto); 48 | 49 | //then 50 | expect(mockQuizService.updateQuiz).toHaveBeenCalled(); 51 | expect(mockQuizService.updateQuiz).toHaveBeenCalledWith(quizId, updateQuizRequestDto); 52 | }) 53 | 54 | it('deleteQuiz', async () => { 55 | //given 56 | const quizId = 1; 57 | mockQuizService.deleteQuiz.mockResolvedValue(undefined); 58 | 59 | //when 60 | await quizController.deleteQuiz(quizId); 61 | 62 | //then 63 | expect(mockQuizService.deleteQuiz).toHaveBeenCalled(); 64 | expect(mockQuizService.deleteQuiz).toHaveBeenCalledWith(quizId); 65 | }) 66 | 67 | }); 68 | -------------------------------------------------------------------------------- /apps/backend/src/quiz/quiz.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | HttpCode, 6 | HttpStatus, 7 | Param, 8 | Patch, 9 | } from '@nestjs/common'; 10 | import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; 11 | import { QuizService } from './quiz.service'; 12 | import { UpdateQuizRequestDto } from './dto/update-quiz-request.dto'; 13 | 14 | @ApiTags('Quiz') 15 | @Controller('quiz') 16 | export class QuizController { 17 | constructor(private readonly quizService: QuizService) {} 18 | 19 | @Patch(':quizId') 20 | @HttpCode(HttpStatus.OK) 21 | @ApiOperation({ summary: '퀴즈 수정' }) 22 | @ApiResponse({ 23 | status: HttpStatus.OK, 24 | description: '퀴즈의 정보를 성공적으로 수정하였습니다.', 25 | }) 26 | async updateQuiz( 27 | @Param('quizId') quizId: number, 28 | @Body() updateQuizRequestDto: UpdateQuizRequestDto, 29 | ) { 30 | return this.quizService.updateQuiz(quizId, updateQuizRequestDto); 31 | } 32 | 33 | @Delete(':quizId') 34 | @HttpCode(HttpStatus.OK) 35 | @ApiOperation({ summary: '퀴즈 삭제' }) 36 | @ApiResponse({ 37 | status: HttpStatus.OK, 38 | description: '퀴즈의 정보를 성공적으로 삭제하였습니다.', 39 | }) 40 | @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: '해당 퀴즈셋의 id가 없습니다.' }) 41 | async deleteQuiz(@Param('quizId') quizId: number): Promise { 42 | return this.quizService.deleteQuiz(quizId); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /apps/backend/src/quiz/quiz.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { Quiz } from './entity/quiz.entitiy'; 4 | import { QuizSet } from './entity/quiz-set.entity'; 5 | import { QuizController } from './quiz.controller'; 6 | import { QuizService } from './quiz.service'; 7 | import { QuizRepository } from './repository/quiz.repository'; 8 | import { QuizSetRepository } from './repository/quiz-set.repository'; 9 | import { QuizSetController } from './quiz-set.controller'; 10 | 11 | @Module({ 12 | imports: [TypeOrmModule.forFeature([Quiz, QuizSet])], 13 | controllers: [QuizController, QuizSetController], 14 | providers: [QuizService, QuizRepository, QuizSetRepository], 15 | exports: [QuizService], 16 | }) 17 | export class QuizModule {} 18 | -------------------------------------------------------------------------------- /apps/backend/src/quiz/quiz.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable } from '@nestjs/common'; 2 | import { CreateQuizSetRequestDto } from './dto/create-quiz-set-request.dto'; 3 | import { QuizRepository } from './repository/quiz.repository'; 4 | import { QuizSetRepository } from './repository/quiz-set.repository'; 5 | import { UpdateQuizRequestDto } from './dto/update-quiz-request.dto'; 6 | import { QuizSetDetails } from './dto/search-quiz-set-response.dto'; 7 | import { SearchQuizSetRequestDTO } from './dto/search-quiz-set-request.dto'; 8 | import { FindQuizzesResponseDto } from './dto/find-quizzes-response.dto'; 9 | import { Transactional } from 'typeorm-transactional'; 10 | 11 | @Injectable() 12 | export class QuizService { 13 | constructor( 14 | private quizRepository: QuizRepository, 15 | private quizSetRepository: QuizSetRepository, 16 | ) {} 17 | 18 | @Transactional() 19 | async createQuizzes(createQuizDto: CreateQuizSetRequestDto) { 20 | const quizSet = await this.quizSetRepository.save({ 21 | name: createQuizDto.quizSetName, 22 | recommended: createQuizDto.recommended, 23 | }); 24 | 25 | const quizzes = createQuizDto.quizDetails.map((dto) => { 26 | return dto.toEntity(quizSet); 27 | }); 28 | 29 | await this.quizRepository.save(quizzes); 30 | 31 | return quizSet.id; 32 | } 33 | 34 | async getQuizzes(quizSetId: number): Promise { 35 | const quizSet = await this.findQuizSet(quizSetId); 36 | return await this.quizRepository.findBy({ quizSet: { id: quizSetId } }); 37 | } 38 | 39 | async updateQuiz(quizId: number, updateQuizRequestDto: UpdateQuizRequestDto) { 40 | const quiz = await this.findQuiz(quizId); 41 | 42 | const updatedQuiz = { 43 | ...quiz, 44 | ...updateQuizRequestDto, 45 | }; 46 | 47 | await this.quizRepository.save(updatedQuiz); 48 | } 49 | 50 | async deleteQuiz(quizId: number) { 51 | await this.findQuiz(quizId); 52 | 53 | await this.quizRepository.delete({ id: quizId }); 54 | } 55 | 56 | async deleteQuizSet(quizSetId: number) { 57 | const quizSet = await this.findQuizSet(quizSetId); 58 | 59 | await this.quizSetRepository.delete({ id: quizSetId }); 60 | } 61 | 62 | async findQuizSet(quizSetId: number) { 63 | const quizSet = await this.quizSetRepository.findOneBy({ id: quizSetId }); 64 | if (!quizSet) { 65 | throw new BadRequestException(`해당 퀴즈셋을 찾을 수 없습니다.`); 66 | } 67 | 68 | return quizSet; 69 | } 70 | 71 | async findQuiz(quizId: number) { 72 | const quiz = await this.quizRepository.findOneBy({ id: quizId }); 73 | if (!quiz) { 74 | throw new BadRequestException(`퀴즈를 찾을 수 없습니다.`); 75 | } 76 | 77 | return quiz; 78 | } 79 | 80 | async searchQuizSet(searchQuery: SearchQuizSetRequestDTO) { 81 | const { name, page, size } = searchQuery; 82 | 83 | const [quizSets, count] = await Promise.all([ 84 | this.quizSetRepository.searchByName(name, page, size), 85 | this.quizSetRepository.countByName(name), 86 | ]); 87 | 88 | const quizSetDetails = quizSets.map(QuizSetDetails.from); 89 | return { quizSetDetails, total: count, currentPage: page }; 90 | } 91 | 92 | } 93 | -------------------------------------------------------------------------------- /apps/backend/src/quiz/repository/quiz-set.repository.ts: -------------------------------------------------------------------------------- 1 | import { DataSource, ILike, Repository } from 'typeorm'; 2 | import { QuizSet } from '../entity/quiz-set.entity'; 3 | import { Injectable } from '@nestjs/common'; 4 | 5 | @Injectable() 6 | export class QuizSetRepository extends Repository { 7 | constructor(dataSource: DataSource) { 8 | super(QuizSet, dataSource.manager); 9 | } 10 | 11 | searchByName(name: string, page: number, pageSize: number) { 12 | return this.find({ 13 | where: {name: ILike(`${name}%`)}, 14 | order: {recommended: 'DESC', createAt: 'desc' }, 15 | skip: (page - 1) * pageSize, 16 | take: pageSize, 17 | }); 18 | } 19 | 20 | countByName(name: string) { 21 | return this.count({ where: { name: ILike(`${name}%`) } }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /apps/backend/src/quiz/repository/quiz.repository.ts: -------------------------------------------------------------------------------- 1 | import { Quiz } from '../entity/quiz.entitiy'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { DataSource, Repository } from 'typeorm'; 4 | 5 | @Injectable() 6 | export class QuizRepository extends Repository { 7 | constructor(dataSource: DataSource) { 8 | super(Quiz, dataSource.manager); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /apps/backend/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | afterAll(async () => { 18 | await app.close(); 19 | }); 20 | 21 | it('/ (GET)', () => { 22 | return request(app.getHttpServer()) 23 | .get('/') 24 | .expect(200) 25 | .expect('Hello World!'); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /apps/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 | } 10 | -------------------------------------------------------------------------------- /apps/backend/test/play.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import { WsAdapter } from '@nestjs/platform-ws'; 4 | import { PlayModule } from '../src/play/play.module'; 5 | import cookieParser from 'cookie-parser'; 6 | import session from 'express-session'; 7 | import { Server } from 'http'; 8 | import wsrequest from 'superwstest'; 9 | 10 | describe('PlayGateway (e2e)', () => { 11 | let app: INestApplication; 12 | let server: Server; 13 | let agent: any; 14 | 15 | beforeAll(async () => { 16 | const moduleFixture: TestingModule = await Test.createTestingModule({ 17 | imports: [PlayModule], 18 | }).compile(); 19 | 20 | app = moduleFixture.createNestApplication(); 21 | app.use(cookieParser()); 22 | app.use( 23 | session({ 24 | secret: 'my-secret', 25 | resave: true, 26 | saveUninitialized: true, 27 | }), 28 | ); 29 | app.useWebSocketAdapter(new WsAdapter(app)); 30 | 31 | await app.init(); 32 | 33 | server = app.getHttpServer(); 34 | }); 35 | 36 | afterAll(async () => { 37 | await app.close(); 38 | }); 39 | 40 | beforeEach((done) => { 41 | server.listen(0, 'localhost', done); 42 | }); 43 | 44 | afterEach((done) => { 45 | server.close(done); 46 | }); 47 | 48 | describe('WebSocket Connection Test', () => { 49 | it('should connect successfully', async () => { 50 | // await wsrequest(server).post('/quiz-zone').expect(201); 51 | await wsrequest(server).ws('/play').set('Cookie', 'sid=12345'); 52 | }); 53 | 54 | it('should receive pong', async () => { 55 | await wsrequest(server) 56 | .ws('/play') 57 | .set('Cookie', 'sid=12345') 58 | .expectJson({}) 59 | // .expectText('connected') 60 | .sendJson({ 61 | event: 'createPlay', 62 | }) 63 | .expectJson({ 64 | event: 'pong', 65 | }) 66 | .close(); 67 | }); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /apps/backend/test/quiz-zone.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import request from 'supertest'; 4 | import { AppModule } from '../src/app.module'; 5 | import cookieParser from 'cookie-parser'; 6 | import session from 'express-session'; 7 | import TestAgent from 'supertest/lib/agent'; 8 | 9 | describe('QuizZoneController (e2e)', () => { 10 | let app: INestApplication; 11 | let agent: TestAgent; // agent 추가 12 | 13 | beforeAll(async () => { 14 | const moduleFixture: TestingModule = await Test.createTestingModule({ 15 | imports: [AppModule], 16 | }).compile(); 17 | 18 | app = moduleFixture.createNestApplication(); 19 | app.use(cookieParser()); 20 | app.use( 21 | session({ 22 | secret: 'my-secret', 23 | resave: true, 24 | saveUninitialized: true, 25 | }), 26 | ); 27 | 28 | await app.init(); 29 | 30 | agent = request.agent(app.getHttpServer()); 31 | }); 32 | 33 | afterAll(async () => { 34 | await app.close(); 35 | }); 36 | 37 | describe('Error cases', () => { 38 | it('POST /quiz-zone 중복 되면 에러', async () => { 39 | await agent.post('/quiz-zone').expect(201); // 첫 번째 요청 40 | await agent.post('/quiz-zone').expect(409); // 두 번째 요청 (중복 에러) 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /apps/backend/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /apps/backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "declaration": true, 6 | "removeComments": true, 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "allowSyntheticDefaultImports": true, 10 | "target": "ES2021", 11 | "sourceMap": true, 12 | "outDir": "./dist", 13 | "baseUrl": "./", 14 | "incremental": true, 15 | "skipLibCheck": true, 16 | "strictNullChecks": false, 17 | "noImplicitAny": false, 18 | "strictBindCallApply": false, 19 | "forceConsistentCasingInFileNames": false, 20 | "noFallthroughCasesInSwitch": false, 21 | "esModuleInterop": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /apps/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | *storybook.log 27 | -------------------------------------------------------------------------------- /apps/frontend/.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from '@storybook/react-vite'; 2 | import { join, dirname } from 'path'; 3 | 4 | function getAbsolutePath(value: string): string { 5 | return dirname(require.resolve(join(value, 'package.json'))); 6 | } 7 | 8 | const config: StorybookConfig = { 9 | stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], 10 | addons: [ 11 | getAbsolutePath('@storybook/addon-onboarding'), 12 | getAbsolutePath('@storybook/addon-essentials'), 13 | getAbsolutePath('@chromatic-com/storybook'), 14 | getAbsolutePath('@storybook/addon-interactions'), 15 | // Tailwind 애드온 추가 16 | { 17 | name: '@storybook/addon-styling', 18 | options: { 19 | postCss: true, 20 | }, 21 | }, 22 | ], 23 | framework: { 24 | name: getAbsolutePath('@storybook/react-vite'), 25 | options: {}, 26 | }, 27 | }; 28 | 29 | export default config; 30 | -------------------------------------------------------------------------------- /apps/frontend/.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from '@storybook/react'; 2 | import '@/index.css'; 3 | 4 | const preview: Preview = { 5 | parameters: { 6 | controls: { 7 | matchers: { 8 | color: /(background|color)$/i, 9 | date: /Date$/i, 10 | }, 11 | }, 12 | }, 13 | }; 14 | 15 | export default preview; 16 | -------------------------------------------------------------------------------- /apps/frontend/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } -------------------------------------------------------------------------------- /apps/frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /apps/frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | BooQuiz 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /apps/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "start": "vite", 10 | "lint": "eslint .", 11 | "preview": "vite preview", 12 | "storybook": "storybook dev -p 6006", 13 | "build-storybook": "storybook build", 14 | "test": "vitest", 15 | "test:run": "vitest run" 16 | }, 17 | "dependencies": { 18 | "@radix-ui/react-alert-dialog": "^1.1.2", 19 | "@radix-ui/react-avatar": "^1.1.1", 20 | "@radix-ui/react-icons": "^1.3.1", 21 | "@radix-ui/react-progress": "^1.1.0", 22 | "@radix-ui/react-slot": "^1.1.0", 23 | "@radix-ui/react-tooltip": "^1.1.4", 24 | "@storybook/preview-api": "^8.4.2", 25 | "@types/react-router-dom": "^5.3.3", 26 | "class-variance-authority": "^0.7.0", 27 | "clsx": "^2.1.1", 28 | "lucide-react": "^0.454.0", 29 | "react": "^18.3.1", 30 | "react-dom": "^18.3.1", 31 | "react-router-dom": "^6.27.0", 32 | "tailwind-merge": "^2.5.4", 33 | "tailwindcss-animate": "^1.0.7", 34 | "ws": "^8.18.0" 35 | }, 36 | "devDependencies": { 37 | "@chromatic-com/storybook": "^3.2.2", 38 | "@eslint/js": "^9.13.0", 39 | "@storybook/addon-essentials": "^8.4.2", 40 | "@storybook/addon-interactions": "^8.4.2", 41 | "@storybook/addon-onboarding": "^8.4.2", 42 | "@storybook/addon-styling": "^2.0.0", 43 | "@storybook/blocks": "^8.4.2", 44 | "@storybook/react": "^8.4.2", 45 | "@storybook/react-vite": "^8.4.2", 46 | "@storybook/test": "^8.4.2", 47 | "@testing-library/jest-dom": "^6.6.3", 48 | "@testing-library/react": "^16.0.1", 49 | "@testing-library/react-hooks": "^8.0.1", 50 | "@types/node": "^22.9.0", 51 | "@types/react": "^18.3.12", 52 | "@types/react-dom": "^18.3.1", 53 | "@vitejs/plugin-react": "^4.3.3", 54 | "autoprefixer": "^10.4.20", 55 | "eslint-plugin-react-hooks": "^5.0.0", 56 | "eslint-plugin-react-refresh": "^0.4.14", 57 | "globals": "^15.11.0", 58 | "jsdom": "^25.0.1", 59 | "postcss": "^8.4.47", 60 | "storybook": "^8.4.2", 61 | "tailwindcss": "^3.4.14", 62 | "typescript": "~5.6.2", 63 | "typescript-eslint": "^8.11.0", 64 | "vite": "^5.4.10", 65 | "vitest": "^2.1.5" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /apps/frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /apps/frontend/public/BooQuizFavicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web08-BooQuiz/2d1fc99ea736831c7828a769574289f5df31bb53/apps/frontend/public/BooQuizFavicon.png -------------------------------------------------------------------------------- /apps/frontend/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/frontend/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /apps/frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import Router from './router/router'; 2 | 3 | function App() { 4 | return ; 5 | } 6 | 7 | export default App; 8 | -------------------------------------------------------------------------------- /apps/frontend/src/blocks/CreateQuizZone/CandidateQuizzes.tsx: -------------------------------------------------------------------------------- 1 | import Typography from '@/components/common/Typogrpahy'; 2 | import { Quiz } from '@/types/quizZone.types.ts'; 3 | import { Trash2 } from 'lucide-react'; 4 | 5 | interface CandidateQuizProps { 6 | quizzes: Quiz[]; 7 | removeQuiz: (quiz: Quiz) => void; 8 | } 9 | 10 | const CandidateQuizzes = ({ quizzes, removeQuiz }: CandidateQuizProps) => { 11 | // 퀴즈 타입을 한글로 변환하는 함수 12 | const getQuizTypeText = (type: string) => { 13 | return type === 'SHORT' ? '단답형' : type; 14 | }; 15 | 16 | return ( 17 |
    18 | {quizzes.map((quiz, i) => ( 19 |
  • 23 | {/* 상단 정보 영역 */} 24 |
    25 |
    26 | {/* 퀴즈 타입 뱃지 */} 27 | 28 | {getQuizTypeText(quiz.quizType ?? '')} 29 | 30 | {/* 시간 뱃지 */} 31 | 32 | {quiz.playTime}초 33 | 34 |
    35 | {/* 삭제 버튼 */} 36 | 43 |
    44 | 45 | {/* 문제 내용 */} 46 |
    47 | 48 | 49 |
    50 | 51 | {/* 정답 */} 52 |
    53 | 54 | 55 |
    56 |
  • 57 | ))} 58 |
59 | ); 60 | }; 61 | 62 | export default CandidateQuizzes; 63 | -------------------------------------------------------------------------------- /apps/frontend/src/blocks/CreateQuizZone/SearchQuizSet.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, useEffect, useState } from 'react'; 2 | import SearchQuizSetResults from '@/blocks/CreateQuizZone/SearchQuizSetResults.tsx'; 3 | import { requestSearchQuizSets } from '@/utils/requests.ts'; 4 | import { ResponseQuizSet } from '@/types/create-quiz-zone.types.ts'; 5 | import Input from '@/components/common/Input'; 6 | import { Search } from 'lucide-react'; 7 | import { Button } from '@/components/ui/button'; 8 | 9 | interface SearchQuizSetProps { 10 | selectQuizSet: (quizSet: ResponseQuizSet) => void; 11 | } 12 | 13 | const SearchQuizSet = ({ selectQuizSet }: SearchQuizSetProps) => { 14 | const [searchKeyword, setSearchKeyword] = useState(''); 15 | const [currentPage] = useState(1); 16 | const [pageSize] = useState(10); 17 | 18 | const [resultCount, setResultCount] = useState(0); 19 | const [quizSets, setQuizSets] = useState([]); 20 | 21 | const [isLoading, setIsLoading] = useState(true); 22 | 23 | const updateSearchQuizSet = async () => { 24 | try { 25 | setIsLoading(true); 26 | 27 | const { quizSets, totalQuizSetCount } = await requestSearchQuizSets({ 28 | name: searchKeyword, 29 | page: currentPage.toString(), 30 | size: pageSize.toString(), 31 | }); 32 | 33 | setQuizSets(quizSets); 34 | setResultCount(totalQuizSetCount); 35 | } catch (error) { 36 | // 얼럿 추가 37 | console.log(error); 38 | console.log('퀴즈셋 검색 처리중 오류가 발생하였습니다.'); 39 | } finally { 40 | setIsLoading(false); 41 | } 42 | }; 43 | 44 | useEffect(() => { 45 | updateSearchQuizSet(); 46 | }, []); 47 | 48 | function handleChangeSearchKeyword(event: ChangeEvent) { 49 | const value = event.target.value; 50 | setSearchKeyword(value); 51 | } 52 | 53 | return ( 54 |
55 |
56 | 66 | 67 | 75 |
76 | {isLoading ? ( 77 |
Loading...
78 | ) : ( 79 | 84 | )} 85 |
86 | ); 87 | }; 88 | 89 | export default SearchQuizSet; 90 | -------------------------------------------------------------------------------- /apps/frontend/src/blocks/CreateQuizZone/SearchQuizSetResults.tsx: -------------------------------------------------------------------------------- 1 | import { ResponseQuizSet } from '@/types/create-quiz-zone.types.ts'; 2 | import { Button } from '@/components/ui/button'; 3 | import { ChevronRight } from 'lucide-react'; 4 | 5 | interface SearchQuizSetResultsProp { 6 | results: ResponseQuizSet[]; 7 | selectQuizSet: (quizSet: ResponseQuizSet) => void; 8 | total: number; 9 | } 10 | 11 | const SearchQuizSetResults = ({ results, selectQuizSet, total }: SearchQuizSetResultsProp) => { 12 | return ( 13 |
14 |
15 | 검색 결과 {total}건 16 |
17 | 18 | {results.length <= 0 ? ( 19 |
20 | 검색 결과가 없습니다. 새로운 퀴즈셋을 만들어보세요. 21 |
22 | ) : ( 23 |
24 |

퀴즈셋을 선택해주세요.

25 |
    26 | {results.map((result) => ( 27 |
  • 31 | {result.name} 32 | 40 |
  • 41 | ))} 42 |
43 |
44 | )} 45 |
46 | ); 47 | }; 48 | 49 | export default SearchQuizSetResults; 50 | -------------------------------------------------------------------------------- /apps/frontend/src/blocks/QuizZone/QuizCompleted.tsx: -------------------------------------------------------------------------------- 1 | import ContentBox from '@/components/common/ContentBox'; 2 | import Typography from '@/components/common/Typogrpahy'; 3 | import { useTimer } from '@/hook/useTimer'; 4 | import { useEffect } from 'react'; 5 | import { CurrentQuizResult } from '@/types/quizZone.types.ts'; 6 | import { Player } from '@/types/quizZone.types'; 7 | import PodiumPlayers from '@/components/common/PodiumPlayers'; 8 | 9 | interface QuizCompletedProps { 10 | currentPlayer: Player; 11 | isLastQuiz: boolean; 12 | deadlineTime: number; 13 | currentQuizResult?: CurrentQuizResult; 14 | } 15 | 16 | const QuizCompleted = ({ 17 | currentPlayer, 18 | isLastQuiz, 19 | deadlineTime, 20 | currentQuizResult, 21 | }: QuizCompletedProps) => { 22 | const currentTime = new Date().getTime(); 23 | const remainingPrepTime = Math.max(0, deadlineTime - currentTime) / 1000; 24 | 25 | const { fastestPlayers, submittedCount } = currentQuizResult ?? {}; 26 | const { start, time } = useTimer({ 27 | initialTime: remainingPrepTime, 28 | onComplete: () => {}, 29 | }); 30 | 31 | useEffect(() => { 32 | start(); 33 | }, []); 34 | 35 | return ( 36 |
37 | 38 | 44 | 45 | {!isLastQuiz && time !== null && ( 46 |
47 | 53 | 59 |
60 | )} 61 |
62 | {currentQuizResult && ( 63 | 64 | 70 | 76 | 77 | )} 78 |
79 | ); 80 | }; 81 | 82 | export default QuizCompleted; 83 | -------------------------------------------------------------------------------- /apps/frontend/src/blocks/QuizZone/QuizInProgress.tsx: -------------------------------------------------------------------------------- 1 | import CommonButton from '@/components/common/CommonButton'; 2 | import ContentBox from '@/components/common/ContentBox'; 3 | import Input from '@/components/common/Input'; 4 | import ProgressBar from '@/components/common/ProgressBar'; 5 | import Typography from '@/components/common/Typogrpahy'; 6 | import { useTimer } from '@/hook/useTimer'; 7 | import { CurrentQuiz } from '@/types/quizZone.types'; 8 | import { useEffect, useState } from 'react'; 9 | 10 | interface QuizInProgressProps { 11 | playTime: number | null; 12 | currentQuiz: CurrentQuiz; 13 | submitAnswer: (e: any) => void; 14 | } 15 | 16 | const QuizInProgress = ({ currentQuiz, submitAnswer }: QuizInProgressProps) => { 17 | const [answer, setAnswer] = useState(''); 18 | 19 | const MAX_TEXT_LENGTH = 100; 20 | const MIN_TEXT_LENGTH = 1; 21 | 22 | const now = new Date().getTime(); 23 | const { playTime, deadlineTime } = currentQuiz; 24 | 25 | const { start, time } = useTimer({ 26 | initialTime: (deadlineTime - now) / 1000, 27 | onComplete: () => {}, 28 | }); 29 | 30 | useEffect(() => { 31 | start(); 32 | }, []); 33 | 34 | const handleSubmitAnswer = () => { 35 | if (answer.length >= MIN_TEXT_LENGTH && answer.length <= MAX_TEXT_LENGTH) { 36 | submitAnswer(answer); 37 | } 38 | }; 39 | 40 | return ( 41 |
42 | {}} /> 43 | 44 | 50 | 51 | setAnswer(e.target.value)} 53 | name="quizAnswer" 54 | value={answer} 55 | placeholder={'정답을 입력해주세요'} 56 | onKeyDown={(e) => { 57 | if (e.key === 'Enter') { 58 | handleSubmitAnswer(); 59 | } 60 | }} 61 | height="h-14" 62 | isBorder={true} 63 | /> 64 | 65 | { 68 | handleSubmitAnswer(); 69 | }} 70 | /> 71 | 72 |
73 | ); 74 | }; 75 | 76 | export default QuizInProgress; 77 | -------------------------------------------------------------------------------- /apps/frontend/src/blocks/QuizZone/QuizWaiting.tsx: -------------------------------------------------------------------------------- 1 | import ContentBox from '@/components/common/ContentBox'; 2 | import Typography from '@/components/common/Typogrpahy'; 3 | import { useTimer } from '@/hook/useTimer'; 4 | import { useEffect } from 'react'; 5 | 6 | interface QuizWaitingProps { 7 | startTime: number; 8 | playQuiz: () => void; 9 | currentQuizSummary?: { 10 | answer?: string; 11 | correctPlayerCount?: number; 12 | totalPlayerCount?: number; 13 | }; 14 | } 15 | 16 | const QuizWaiting = ({ playQuiz, startTime, currentQuizSummary }: QuizWaitingProps) => { 17 | const currentTime = new Date().getTime(); 18 | const remainingPrepTime = Math.max(0, startTime - currentTime) / 1000; 19 | const { answer, correctPlayerCount, totalPlayerCount } = currentQuizSummary ?? {}; 20 | 21 | const isVisibleSummary = 22 | answer !== undefined && 23 | totalPlayerCount !== undefined && 24 | totalPlayerCount > 0 && 25 | correctPlayerCount !== undefined; 26 | 27 | const { start, time } = useTimer({ 28 | initialTime: remainingPrepTime, 29 | onComplete: () => { 30 | playQuiz(); 31 | }, 32 | }); 33 | 34 | useEffect(() => { 35 | start(); 36 | }, []); 37 | 38 | return ( 39 |
40 | 41 | 42 | 43 | 44 | {time !== null && ( 45 |
46 | 52 | 53 |
54 | )} 55 |
56 | {isVisibleSummary && ( 57 | 58 | 59 | 60 | 66 | 67 | )} 68 |
69 | ); 70 | }; 71 | 72 | export default QuizWaiting; 73 | -------------------------------------------------------------------------------- /apps/frontend/src/blocks/QuizZone/QuizZoneInProgress.tsx: -------------------------------------------------------------------------------- 1 | import { QuizZone } from '@/types/quizZone.types'; 2 | import QuizWaiting from './QuizWaiting'; 3 | import QuizInProgress from './QuizInProgress'; 4 | import QuizCompleted from './QuizCompleted'; 5 | 6 | interface QuizZoneInProgressProps { 7 | quizZoneState: QuizZone; 8 | submitAnswer: (answer: string) => void; 9 | playQuiz: () => void; 10 | } 11 | 12 | const QuizZoneInProgress = ({ quizZoneState, submitAnswer, playQuiz }: QuizZoneInProgressProps) => { 13 | const { currentPlayer, currentQuiz, currentQuizResult } = quizZoneState; 14 | const { state } = currentPlayer; 15 | const { playTime, startTime } = currentQuiz ?? {}; 16 | 17 | switch (state) { 18 | case 'WAIT': 19 | return ( 20 | 25 | ); 26 | case 'PLAY': 27 | return ( 28 | 33 | ); 34 | case 'SUBMIT': 35 | return ( 36 | 42 | ); 43 | default: 44 | return null; 45 | } 46 | }; 47 | 48 | export default QuizZoneInProgress; 49 | -------------------------------------------------------------------------------- /apps/frontend/src/blocks/QuizZone/QuizZoneLoading.tsx: -------------------------------------------------------------------------------- 1 | import ContentBox from '@/components/common/ContentBox'; 2 | import Typography from '@/components/common/Typogrpahy'; 3 | 4 | interface QuizZoneLoadingProps { 5 | title?: string; 6 | description?: string; 7 | } 8 | 9 | const QuizZoneLoading = ({ 10 | title = '결과를 정산하고 있습니다...', 11 | description = '잠시만 기다려주세요', 12 | }: QuizZoneLoadingProps) => { 13 | return ( 14 |
15 | 16 | 17 | 18 | 19 |
20 | ); 21 | }; 22 | export default QuizZoneLoading; 23 | -------------------------------------------------------------------------------- /apps/frontend/src/components/boundary/AsyncBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | import { ErrorBoundary } from './ErrorBoundary'; 3 | 4 | export interface AsyncBoundaryProps { 5 | children: React.ReactNode; 6 | pending: React.ReactNode; 7 | rejected?: (args: { error: unknown; retry: () => void }) => React.ReactNode; 8 | handleError?: (error: unknown) => void; 9 | onReset?: () => void; 10 | } 11 | 12 | /** 13 | * AsyncBoundary는 비동기 작업의 로딩 상태와 에러를 처리하는 컴포넌트입니다. 14 | */ 15 | export function AsyncBoundary(props: AsyncBoundaryProps) { 16 | const { children, pending, rejected, handleError, onReset } = props; 17 | 18 | return ( 19 | rejected({ error, retry: reset }))} 21 | handleError={handleError} 22 | onReset={onReset} 23 | > 24 | {children} 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /apps/frontend/src/components/boundary/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from 'react'; 2 | import CustomAlertDialog from '@/components/common/CustomAlertDialog'; 3 | import { QuizZoneErrorType, quizZoneErrorMessages } from '@/types/error.types'; 4 | import { getQuizZoneErrorType } from '@/utils/errorUtils'; 5 | 6 | export interface ErrorBoundaryProps { 7 | children: React.ReactNode; 8 | fallback?: (args: { error: unknown; reset: () => void }) => React.ReactNode; 9 | handleError?: (error: unknown) => void; 10 | onReset?: () => void; 11 | } 12 | 13 | interface ErrorBoundaryState { 14 | hasError: boolean; 15 | error: unknown; 16 | } 17 | 18 | /** 19 | * ErrorBoundary는 자식 컴포넌트 트리에서 JavaScript 에러를 감지하고 처리하는 React 컴포넌트입니다. 20 | */ 21 | export class ErrorBoundary extends Component { 22 | state: ErrorBoundaryState = { 23 | hasError: false, 24 | error: null, 25 | }; 26 | 27 | static getDerivedStateFromError(error: unknown): ErrorBoundaryState { 28 | return { hasError: true, error }; 29 | } 30 | 31 | componentDidCatch(error: unknown) { 32 | this.props.handleError?.(error); 33 | } 34 | 35 | private resetError = () => { 36 | this.setState({ 37 | hasError: false, 38 | error: null, 39 | }); 40 | this.props.onReset?.(); 41 | }; 42 | 43 | render() { 44 | const { hasError, error } = this.state; 45 | const { children, fallback } = this.props; 46 | 47 | if (hasError) { 48 | if (fallback) { 49 | return fallback({ error, reset: this.resetError }); 50 | } 51 | 52 | const errorType = getQuizZoneErrorType(error); 53 | const errorMessage = 54 | quizZoneErrorMessages[errorType as Exclude]; 55 | 56 | return ( 57 | this.resetError()} 60 | onConfirm={this.resetError} 61 | title={errorMessage.title} 62 | description={errorMessage.description} 63 | confirmText="확인" 64 | /> 65 | ); 66 | } 67 | 68 | return children; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/CommonButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import CommonButton from './CommonButton'; 3 | import type { CommonButtonProps } from './CommonButton'; 4 | 5 | const meta = { 6 | title: 'Components/Common/CommonButton', 7 | component: CommonButton, 8 | parameters: { 9 | layout: 'centered', 10 | }, 11 | tags: ['autodocs'], 12 | argTypes: { 13 | text: { 14 | control: 'text', 15 | description: '버튼에 표시될 텍스트', 16 | }, 17 | isFilled: { 18 | control: 'boolean', 19 | description: '버튼의 스타일을 결정하는 플래그', 20 | }, 21 | clickEvent: { 22 | action: 'clicked', 23 | description: '버튼 클릭 시 실행될 이벤트 핸들러', 24 | }, 25 | width: { 26 | control: 'text', 27 | description: '버튼의 너비', 28 | }, 29 | height: { 30 | control: 'text', 31 | description: '버튼의 높이', 32 | }, 33 | }, 34 | } satisfies Meta; 35 | 36 | export default meta; 37 | type Story = StoryObj; 38 | 39 | // 기본 버튼 40 | export const Default: Story = { 41 | args: { 42 | text: '기본 버튼', 43 | isFilled: false, 44 | clickEvent: () => { 45 | alert('버튼이 클릭되었습니다!'); 46 | }, 47 | }, 48 | }; 49 | 50 | // 활성화된 버튼 51 | export const Fulfilled: Story = { 52 | args: { 53 | text: '활성화 버튼', 54 | isFilled: true, 55 | clickEvent: () => { 56 | alert('버튼이 클릭되었습니다!'); 57 | }, 58 | }, 59 | }; 60 | 61 | // 커스텀 크기 버튼 62 | export const CustomSize: Story = { 63 | args: { 64 | text: '커스텀 크기 버튼', 65 | isFilled: true, 66 | width: '300px', 67 | height: '40px', 68 | clickEvent: () => { 69 | alert('버튼이 클릭되었습니다!'); 70 | }, 71 | }, 72 | }; 73 | 74 | // 버튼 클릭 이벤트 75 | export const ClickEvent: Story = { 76 | args: { 77 | text: '클릭해보세요', 78 | isFilled: true, 79 | clickEvent: () => { 80 | alert('버튼이 클릭되었습니다!'); 81 | }, 82 | }, 83 | }; 84 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/CommonButton.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonHTMLAttributes, forwardRef } from 'react'; 2 | 3 | export interface CommonButtonProps extends ButtonHTMLAttributes { 4 | text?: string; 5 | isFilled?: boolean; // isFulfill -> isFilled로 변경 6 | clickEvent: () => void; 7 | width?: string; 8 | height?: string; 9 | } 10 | 11 | const CommonButton = forwardRef( 12 | ( 13 | { 14 | text, 15 | isFilled = false, // isFulfill -> isFilled로 변경 16 | clickEvent, 17 | disabled = false, 18 | className = '', 19 | type = 'button', 20 | ...props 21 | }, 22 | ref, 23 | ) => { 24 | // 기본 스타일 클래스들 25 | const baseStyles = [ 26 | 'rounded-lg', 27 | 'border-2', 28 | 'px-4', 29 | 'py-2', 30 | 'font-medium', 31 | 'transition-all', 32 | 'duration-200', 33 | 'focus:outline-none', 34 | 'focus:ring-2', 35 | 'focus:ring-blue-600', 36 | 'focus:ring-offset-2', 37 | ]; 38 | 39 | // 조건부 스타일 클래스들 40 | const conditionalStyles = isFilled 41 | ? [ 42 | 'border-blue-600', 43 | 'bg-blue-600', 44 | 'text-white', 45 | 'hover:bg-blue-700', 46 | 'hover:border-blue-700', 47 | disabled && 'opacity-50 hover:bg-blue-600 hover:border-blue-600', 48 | ] 49 | : [ 50 | 'border-blue-600', 51 | 'bg-white', 52 | 'text-blue-600', 53 | 'hover:bg-blue-50', 54 | disabled && 'opacity-50 hover:bg-white', 55 | ]; 56 | 57 | // 사용자 정의 클래스와 기본 클래스 결합 58 | const combinedClassName = [ 59 | ...baseStyles, 60 | ...conditionalStyles, 61 | disabled && 'cursor-not-allowed', 62 | className, 63 | ] 64 | .filter(Boolean) 65 | .join(' '); 66 | 67 | return ( 68 | 79 | ); 80 | }, 81 | ); 82 | 83 | CommonButton.displayName = 'CommonButton'; 84 | 85 | export default CommonButton; 86 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/ContentBox.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | import ContentBox from './ContentBox'; 3 | 4 | const meta: Meta = { 5 | title: 'Components/ContentBox', 6 | component: ContentBox, 7 | tags: ['autodocs'], 8 | }; 9 | 10 | export default meta; 11 | 12 | type Story = StoryObj; 13 | 14 | export const Default: Story = { 15 | args: { 16 | children:

이것은 ContentBox 안에 있는 기본 내용입니다.

, 17 | }, 18 | }; 19 | 20 | export const WithMultipleChildren: Story = { 21 | args: { 22 | children: ( 23 | <> 24 |

ContentBox 제목

25 |

여러 자식 요소를 포함한 ContentBox 예시입니다.

26 | 27 | 28 | ), 29 | }, 30 | }; 31 | 32 | export const WithLongContent: Story = { 33 | args: { 34 | children: ( 35 |

36 | 이것은 긴 내용을 가진 ContentBox 예시입니다. ContentBox는 내용의 길이에 따라 37 | 자동으로 크기가 조절됩니다. 이를 통해 다양한 길이의 콘텐츠를 유연하게 표시할 수 38 | 있습니다. 39 |

40 | ), 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/ContentBox.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @description 3 | * 자식 요소들을 스타일이 적용된 div로 감싸는 컴포넌트입니다. 4 | * 5 | * @example 6 | * ```tsx 7 | * 8 | *

이것은 ContentBox 안에 있는 내용입니다.

9 | *
10 | * ``` 11 | * 12 | * @param {ContentBoxProps} props - ContentBox 컴포넌트의 props입니다. 13 | * @param {ReactNode} props.children - ContentBox 안에 감싸질 내용입니다. 14 | * 15 | * @returns {JSX.Element} 자식 요소들을 포함하는 스타일이 적용된 div를 반환합니다. 16 | */ 17 | import { ReactNode } from 'react'; 18 | 19 | interface ContentBoxProps { 20 | children: ReactNode; 21 | className?: string; 22 | } 23 | 24 | const ContentBox = ({ children, className }: ContentBoxProps) => { 25 | return ( 26 |
29 | {children} 30 |
31 | ); 32 | }; 33 | export default ContentBox; 34 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/CustomAlertDialog.tsx: -------------------------------------------------------------------------------- 1 | import { AlertDialog } from '../ui/alert-dialog'; 2 | import CustomAlertDialogContent from './CustomAlertDialogContent'; 3 | 4 | interface CustomAlertDialogProps { 5 | showError: boolean; 6 | setShowError: (show: boolean) => void; 7 | title: string; 8 | description?: string; 9 | onConfirm: () => void; 10 | onCancel?: () => void; 11 | confirmText?: string; 12 | cancelText?: string; 13 | } 14 | 15 | const CustomAlertDialog = ({ 16 | showError, 17 | setShowError, 18 | onConfirm, 19 | onCancel = () => {}, 20 | title, 21 | description, 22 | confirmText = '확인', 23 | cancelText = '취소', 24 | }: CustomAlertDialogProps) => { 25 | return ( 26 | 27 | 36 | 37 | ); 38 | }; 39 | 40 | export default CustomAlertDialog; 41 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/CustomAlertDialogContent.tsx: -------------------------------------------------------------------------------- 1 | import { AlertCircle, AlertTriangle, CheckCircle2, Info } from 'lucide-react'; 2 | import { 3 | AlertDialogAction, 4 | AlertDialogCancel, 5 | AlertDialogContent, 6 | AlertDialogDescription, 7 | AlertDialogFooter, 8 | AlertDialogHeader, 9 | AlertDialogTitle, 10 | } from '@/components/ui/alert-dialog.tsx'; 11 | 12 | interface CustomAlertContentDialogProps { 13 | title: string; 14 | description?: string; 15 | type?: 'info' | 'success' | 'warning' | 'error'; 16 | cancelText?: string; 17 | confirmText?: string; 18 | handleConfirm?: () => void; 19 | handleCancel?: () => void; 20 | } 21 | 22 | const getAlertIcon = (type: string) => { 23 | switch (type) { 24 | case 'success': 25 | return ; 26 | case 'warning': 27 | return ; 28 | case 'error': 29 | return ; 30 | default: 31 | return ; 32 | } 33 | }; 34 | const getAlertStyle = (type: string) => { 35 | switch (type) { 36 | case 'success': 37 | return 'border-[#22c55e]/20 '; 38 | case 'warning': 39 | return 'border-[#eab308]/20'; 40 | case 'error': 41 | return 'border-[#ef4444]/20 '; 42 | default: 43 | return 'border-[#2563eb]/20 '; 44 | } 45 | }; 46 | 47 | const CustomAlertDialogContent = ({ 48 | title, 49 | description, 50 | type, 51 | confirmText, 52 | cancelText, 53 | handleConfirm, 54 | handleCancel, 55 | }: CustomAlertContentDialogProps) => { 56 | return ( 57 | 58 | 59 |
60 | {getAlertIcon(type ?? 'info')} 61 | {title} 62 |
63 | {description && {description}} 64 |
65 | 66 | 67 | {cancelText ?? '취소'} 68 | 69 | {confirmText ?? '확인'} 70 | 71 | 72 |
73 | ); 74 | }; 75 | 76 | export default CustomAlertDialogContent; 77 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/ui/button'; 2 | import TooltipWrapper from './TooltipWrapper'; 3 | import { useNavigate } from 'react-router-dom'; 4 | import Logo from './Logo'; 5 | 6 | const Navbar = () => { 7 | const navigate = useNavigate(); 8 | return ( 9 | 24 | ); 25 | }; 26 | 27 | export default Navbar; 28 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/ParticipantGrid.tsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web08-BooQuiz/2d1fc99ea736831c7828a769574289f5df31bb53/apps/frontend/src/components/common/ParticipantGrid.tsx -------------------------------------------------------------------------------- /apps/frontend/src/components/common/PlayersGrid.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; 2 | import { Crown } from 'lucide-react'; 3 | import Typography from '@/components/common/Typogrpahy'; 4 | import { Player } from '@/types/quizZone.types'; 5 | 6 | // 개별 플레이어 카드 컴포넌트 7 | interface PlayerCardProps { 8 | isCurrentPlayer?: boolean; 9 | player: Player; 10 | isHost: boolean; 11 | } 12 | 13 | const PlayerCard = ({ isCurrentPlayer = false, player, isHost }: PlayerCardProps) => { 14 | const initials = player.nickname 15 | .split(' ') 16 | .map((word) => word[0]) 17 | .join('') 18 | .toUpperCase() 19 | .slice(0, 2); 20 | 21 | return ( 22 |
27 |
28 | 29 | 33 | 34 | {initials} 35 | 36 | 37 | {isHost && ( 38 | 44 | )} 45 |
46 |
47 | {isCurrentPlayer ? ( 48 | 49 | ) : ( 50 | 51 | )} 52 |
53 |
54 | ); 55 | }; 56 | 57 | interface PlayersGridProps { 58 | currentPlayer?: Player; 59 | players: Player[]; 60 | hostId: string; 61 | className?: string; 62 | } 63 | 64 | const PlayersGrid = ({ currentPlayer, players, hostId, className = '' }: PlayersGridProps) => { 65 | if (!players.length) { 66 | return ( 67 |
68 | 69 |
70 | ); 71 | } 72 | 73 | return ( 74 |
79 | {players.map((player) => ( 80 | 86 | ))} 87 |
88 | ); 89 | }; 90 | 91 | export default PlayersGrid; 92 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/ProgressBar.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | import ProgressBar from './ProgressBar'; 3 | 4 | const meta: Meta = { 5 | title: 'Components/ProgressBar', 6 | component: ProgressBar, 7 | tags: ['autodocs'], 8 | }; 9 | 10 | export default meta; 11 | 12 | type Story = StoryObj; 13 | 14 | export const Default: Story = { 15 | args: { 16 | playTime: 30000, 17 | time: Date.now(), 18 | onTimeEnd: () => { 19 | alert('time end'); 20 | }, 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/ProgressBar.tsx: -------------------------------------------------------------------------------- 1 | import { Progress } from '@/components/ui/progress'; 2 | import Typography from './Typogrpahy'; 3 | 4 | export interface ProgressBarProps { 5 | playTime: number; 6 | time: number; 7 | onTimeEnd?: () => void; 8 | } 9 | 10 | /** 11 | * @description 12 | * ProgressBar 컴포넌트는 주어진 플레이 시간과 현재 시간을 기반으로 진행도를 시각적으로 표시합니다. 13 | * 14 | * @example 15 | * console.log('Time ended!')} 19 | * /> 20 | */ 21 | const ProgressBar = ({ playTime, time, onTimeEnd }: ProgressBarProps) => { 22 | // 진행도 계산 (남은 시간 / 전체 시간 * 100) 23 | const progress = Math.max(0, Math.min(100, (time / playTime) * 100000)); 24 | 25 | // 시간이 다 되었을 때 콜백 실행 26 | if (time <= 0) { 27 | onTimeEnd?.(); 28 | } 29 | 30 | return ( 31 |
32 | 33 | 39 |
40 | ); 41 | }; 42 | 43 | export default ProgressBar; 44 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/TextCopy.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | import TextCopy from './TextCopy'; 3 | import type { TextCopyProps } from './TextCopy'; 4 | 5 | const meta: Meta = { 6 | title: 'Components/TextCopy', 7 | component: TextCopy, 8 | tags: ['autodocs'], 9 | args: { 10 | text: '이것은 복사할 텍스트입니다.', 11 | }, 12 | } satisfies Meta; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | 18 | export const Default: Story = { 19 | args: { 20 | text: '이것은 복사할 텍스트입니다.', 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/TextCopy.tsx: -------------------------------------------------------------------------------- 1 | import Typography, { TypographyProps } from './Typogrpahy'; 2 | import { Copy } from 'lucide-react'; 3 | 4 | export interface TextCopyProps { 5 | text: string; 6 | size?: TypographyProps['size']; 7 | bold?: TypographyProps['bold']; 8 | } 9 | 10 | /** 11 | * @description 12 | * 주어진 텍스트를 복사할 수 있는 컴포넌트입니다. 13 | * 14 | * @example 15 | * ```tsx 16 | * 17 | * ``` 18 | * 19 | * @param text - 복사할 텍스트입니다. 20 | * @param size - 텍스트의 크기를 지정합니다. 기본값은 '4xl'입니다. 21 | * @returns 주어진 텍스트와 복사 아이콘을 포함하는 컴포넌트를 반환합니다. 22 | */ 23 | const TextCopy = ({ text, size = '4xl', bold = false }: TextCopyProps) => { 24 | const handleCopy = () => { 25 | navigator.clipboard.writeText(text); 26 | }; 27 | 28 | return ( 29 |
30 | 31 | 35 |
36 | ); 37 | }; 38 | 39 | export default TextCopy; 40 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/TimerDisplay.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | import TimerDisplay from './TimerDisplay'; 3 | 4 | const meta: Meta = { 5 | title: 'Components/TimerDisplay', 6 | component: TimerDisplay, 7 | tags: ['autodocs'], 8 | }; 9 | 10 | export default meta; 11 | 12 | type Story = StoryObj; 13 | 14 | export const Default: Story = { 15 | args: { 16 | time: 10, 17 | isFulfill: false, 18 | onTimeEnd: () => { 19 | alert('time end'); 20 | }, 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/TimerDisplay.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import Typography from './Typogrpahy'; 3 | 4 | interface TimerDisplayProps { 5 | time?: number; 6 | isFulfill: boolean; 7 | width?: string; 8 | height?: string; 9 | onTimeEnd?: () => void; 10 | } 11 | 12 | /** 13 | * @description 14 | * 이 컴포넌트는 주어진 시간에서 시작하여 매 초마다 감소하는 카운트다운 타이머를 표시합니다. 15 | * 타이머가 0에 도달하면 `onTimeEnd` 콜백 함수를 호출합니다. 16 | * 타이머의 배경색과 호버 효과는 `isFulfill` 속성에 따라 사용자 정의할 수 있습니다. 17 | * 18 | * @example 19 | * ```tsx 20 | * console.log('시간 종료!')} /> 21 | * ``` 22 | * 23 | * @param {TimerDisplayProps} props - TimerDisplay 컴포넌트의 속성. 24 | * @param {number} [props.time=3] - 카운트다운 타이머의 초기 시간 값. 25 | * @param {boolean} props.isFulfill - 타이머의 배경색과 호버 효과를 결정합니다. 26 | * @param {string} [props.width] - 타이머 디스플레이의 너비. 27 | * @param {string} [props.height] - 타이머 디스플레이의 높이. 28 | * @param {() => void} props.onTimeEnd - 타이머가 0에 도달했을 때 호출되는 콜백 함수. 29 | * 30 | * @returns {JSX.Element} TimerDisplay 컴포넌트. 31 | */ 32 | const TimerDisplay = ({ time = 3, isFulfill = true, onTimeEnd }: TimerDisplayProps) => { 33 | const backgroundColorClass = isFulfill ? 'bg-gray300' : 'bg-white'; 34 | const textColorClass = 'black'; 35 | const INTERVAL_SECOND = 1000; 36 | const hoverBackgroundColorClass = isFulfill ? 'hover:bg-gray500' : 'hover:bg-[#f5f5f5]'; 37 | 38 | const [timeValue, setTimeValue] = useState(time); 39 | 40 | useEffect(() => { 41 | if (timeValue === 0) { 42 | if (onTimeEnd) { 43 | onTimeEnd(); 44 | } 45 | } 46 | 47 | const interval = setInterval(() => { 48 | setTimeValue(timeValue - 1); 49 | }, INTERVAL_SECOND); 50 | 51 | return () => clearInterval(interval); 52 | }, [timeValue, onTimeEnd]); 53 | return ( 54 |
57 | 58 |
59 | ); 60 | }; 61 | 62 | export default TimerDisplay; 63 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/TooltipWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; 2 | 3 | interface TooltipWrapperProps { 4 | content: string; 5 | children: React.ReactNode; 6 | delayDuration?: number; 7 | side?: 'top' | 'right' | 'bottom' | 'left'; 8 | align?: 'start' | 'center' | 'end'; 9 | className?: string; 10 | type?: 'button' | 'submit' | 'reset' | undefined; 11 | } 12 | 13 | const TooltipWrapper = ({ 14 | content, 15 | children, 16 | delayDuration = 200, 17 | side = 'top', 18 | align = 'center', 19 | type = undefined, 20 | className = '', 21 | }: TooltipWrapperProps) => { 22 | return ( 23 | 24 | 25 | 26 |
{children}
27 |
28 | 33 | {content} 34 | 35 |
36 |
37 | ); 38 | }; 39 | 40 | export default TooltipWrapper; 41 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/Typography.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import Typography from './Typogrpahy'; 3 | 4 | /** 5 | * Typography 컴포넌트는 일관된 텍스트 스타일링을 위한 기본 컴포넌트입니다. 6 | * 다양한 크기와 색상 옵션을 제공합니다. 7 | */ 8 | const meta: Meta = { 9 | title: 'Components/Typography', 10 | component: Typography, 11 | tags: ['autodocs'], 12 | parameters: { 13 | layout: 'centered', 14 | }, 15 | argTypes: { 16 | text: { 17 | description: '표시할 텍스트 내용', 18 | control: { type: 'text' }, 19 | }, 20 | size: { 21 | description: '텍스트의 크기', 22 | control: { 23 | type: 'select', 24 | }, 25 | options: ['xs', 'sm', 'base', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl'], 26 | }, 27 | color: { 28 | description: '텍스트의 색상', 29 | control: { 30 | type: 'select', 31 | }, 32 | options: ['gray', 'red', 'black'], 33 | }, 34 | }, 35 | } satisfies Meta; 36 | 37 | export default meta; 38 | 39 | type Story = StoryObj; 40 | 41 | /** 42 | * 기본 Typography 스타일입니다. 43 | */ 44 | export const Default: Story = { 45 | args: { 46 | text: 'Hello World', 47 | size: 'base', 48 | color: 'black', 49 | }, 50 | }; 51 | 52 | /** 53 | * 가장 작은 크기의 텍스트입니다. 54 | */ 55 | export const ExtraSmall: Story = { 56 | args: { 57 | text: 'Extra Small Text', 58 | size: 'xs', 59 | color: 'black', 60 | }, 61 | }; 62 | 63 | /** 64 | * 작은 크기의 텍스트입니다. 65 | */ 66 | export const Small: Story = { 67 | args: { 68 | text: 'Small Text', 69 | size: 'sm', 70 | color: 'black', 71 | }, 72 | }; 73 | 74 | /** 75 | * 큰 크기의 텍스트입니다. 76 | */ 77 | export const Large: Story = { 78 | args: { 79 | text: 'Large Text', 80 | size: 'lg', 81 | color: 'black', 82 | }, 83 | }; 84 | 85 | /** 86 | * 회색 텍스트 스타일입니다. 87 | */ 88 | export const GrayText: Story = { 89 | args: { 90 | text: 'Gray Colored Text', 91 | size: 'base', 92 | color: 'gray', 93 | }, 94 | }; 95 | 96 | /** 97 | * 빨간색 텍스트 스타일입니다. 98 | */ 99 | export const RedText: Story = { 100 | args: { 101 | text: 'Red Colored Text', 102 | size: 'base', 103 | color: 'red', 104 | }, 105 | }; 106 | 107 | /** 108 | * 모든 크기를 한번에 보여주는 예시입니다. 109 | */ 110 | export const AllSizes: Story = { 111 | render: () => ( 112 |
113 | {(['xs', 'sm', 'base', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl'] as const).map( 114 | (size) => ( 115 | 116 | ), 117 | )} 118 |
119 | ), 120 | }; 121 | 122 | /** 123 | * 모든 색상을 한번에 보여주는 예시입니다. 124 | */ 125 | export const AllColors: Story = { 126 | render: () => ( 127 |
128 | {(['black', 'gray', 'red'] as const).map((color) => ( 129 | 130 | ))} 131 |
132 | ), 133 | }; 134 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/Typogrpahy.tsx: -------------------------------------------------------------------------------- 1 | export interface TypographyProps { 2 | text: string; 3 | size: 'xs' | 'sm' | 'base' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl'; 4 | color: 'gray' | 'red' | 'black' | 'blue'; 5 | bold?: boolean; 6 | } 7 | 8 | /** 9 | * @description 10 | * `Typography` 컴포넌트는 Tailwind CSS를 사용하여 텍스트의 크기와 색상을 조절할 수 있도록 합니다. 11 | * 12 | * @component 13 | * @example 14 | * ```tsx 15 | * 16 | * ``` 17 | * 18 | * @param {TypographyProps} props - 컴포넌트에 전달되는 props 19 | * @param {string} props.text - 표시할 텍스트 20 | * @param {'xs' | 'sm' | 'base' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl'} props.size - 텍스트의 크기 21 | * @param {'gray' | 'red' | 'black'} props.color - 텍스트의 색상 22 | * 23 | * @returns {JSX.Element} Tailwind CSS 클래스를 적용한 텍스트를 포함하는 `

` 요소 24 | */ 25 | 26 | const Typography = ({ text, size, color, bold = false }: TypographyProps) => { 27 | const sizeClasses = { 28 | xs: 'text-xs', 29 | sm: 'text-sm', 30 | base: 'text-base', 31 | lg: 'text-lg', 32 | xl: 'text-xl', 33 | '2xl': 'text-2xl', 34 | '3xl': 'text-3xl', 35 | '4xl': 'text-4xl', 36 | '5xl': 'text-5xl', 37 | '6xl': 'text-6xl', 38 | }; 39 | 40 | const colorClasses = { 41 | gray: 'text-gray-400', 42 | red: 'text-red-600', 43 | blue: 'text-blue-600', 44 | black: 'text-black', 45 | }; 46 | 47 | const classes = `break-all ${sizeClasses[size] || sizeClasses.base} ${colorClasses[color] || colorClasses.black} ${bold ? 'font-bold' : ''}`; 48 | return

{text}

; 49 | }; 50 | 51 | export default Typography; 52 | -------------------------------------------------------------------------------- /apps/frontend/src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Avatar = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | )) 19 | Avatar.displayName = AvatarPrimitive.Root.displayName 20 | 21 | const AvatarImage = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, ...props }, ref) => ( 25 | 30 | )) 31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 32 | 33 | const AvatarFallback = React.forwardRef< 34 | React.ElementRef, 35 | React.ComponentPropsWithoutRef 36 | >(({ className, ...props }, ref) => ( 37 | 45 | )) 46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 47 | 48 | export { Avatar, AvatarImage, AvatarFallback } 49 | -------------------------------------------------------------------------------- /apps/frontend/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /apps/frontend/src/components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ProgressPrimitive from '@radix-ui/react-progress'; 3 | 4 | import { cn } from '@/lib/utils'; 5 | 6 | // 새로운 인터페이스를 정의하여 time prop을 추가합니다. 7 | interface ProgressProps extends React.ComponentPropsWithoutRef { 8 | time?: number; 9 | } 10 | 11 | const Progress = React.forwardRef, ProgressProps>( 12 | ({ className, value, time = 33, ...props }, ref) => ( 13 | 21 | time ? 'bg-[#2563eb]' : 'bg-red-600'} transition-all`} 23 | style={{ transform: `translateX(-${100 - (value || 0)}%)` }} 24 | /> 25 | 26 | ), 27 | ); 28 | Progress.displayName = ProgressPrimitive.Root.displayName; 29 | 30 | export { Progress }; 31 | -------------------------------------------------------------------------------- /apps/frontend/src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const TooltipProvider = TooltipPrimitive.Provider 7 | 8 | const Tooltip = TooltipPrimitive.Root 9 | 10 | const TooltipTrigger = TooltipPrimitive.Trigger 11 | 12 | const TooltipContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, sideOffset = 4, ...props }, ref) => ( 16 | 17 | 26 | 27 | )) 28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 29 | 30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 31 | -------------------------------------------------------------------------------- /apps/frontend/src/constants/quiz-set.constants.ts: -------------------------------------------------------------------------------- 1 | export const QUIZ_LIMIT_COUNT = 10; 2 | -------------------------------------------------------------------------------- /apps/frontend/src/hook/quizZone/useQuizZone.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook, act } from '@testing-library/react'; 2 | import { vi, describe, it, expect, beforeEach } from 'vitest'; 3 | import { QuizZone } from '@/types/quizZone.types'; 4 | import useQuizZone from './useQuizZone'; 5 | 6 | // useWebSocket 모킹 7 | vi.mock('@/hook/useWebSocket', () => ({ 8 | default: () => ({ 9 | beginConnection: vi.fn(), 10 | sendMessage: vi.fn(), 11 | closeConnection: vi.fn(), 12 | messageHandler: vi.fn(), 13 | }), 14 | })); 15 | 16 | // env 모킹 17 | vi.mock('@/utils/atob', () => ({ 18 | default: vi.fn((str) => str), 19 | })); 20 | 21 | describe('useQuizZone', () => { 22 | const mockQuizZoneId = 'test-quiz-zone-id'; 23 | 24 | beforeEach(() => { 25 | vi.clearAllMocks(); 26 | }); 27 | 28 | it('초기 상태가 올바르게 설정되어야 합니다', () => { 29 | const { result } = renderHook(() => useQuizZone(mockQuizZoneId)); 30 | 31 | expect(result.current.quizZoneState).toEqual({ 32 | stage: 'LOBBY', 33 | currentPlayer: { 34 | id: '', 35 | nickname: '', 36 | }, 37 | title: '', 38 | description: '', 39 | hostId: '', 40 | quizCount: 0, 41 | players: [], 42 | score: 0, 43 | submits: [], 44 | quizzes: [], 45 | chatMessages: [], 46 | maxPlayers: 0, 47 | offset: 0, 48 | serverTime: 0, 49 | }); 50 | }); 51 | 52 | it('playQuiz 액션이 상태를 올바르게 업데이트해야 합니다', () => { 53 | const { result } = renderHook(() => useQuizZone(mockQuizZoneId)); 54 | 55 | act(() => { 56 | result.current.playQuiz(); 57 | }); 58 | 59 | expect(result.current.quizZoneState.stage).toBe('IN_PROGRESS'); 60 | expect(result.current.quizZoneState.currentPlayer.state).toBe('PLAY'); 61 | }); 62 | 63 | it('init 액션이 상태를 올바르게 업데이트해야 합니다', () => { 64 | const { result } = renderHook(() => useQuizZone(mockQuizZoneId)); 65 | 66 | const mockQuizZone: Partial = { 67 | stage: 'LOBBY', 68 | currentPlayer: { 69 | id: 'player1', 70 | nickname: 'Player 1', 71 | state: 'WAIT', 72 | }, 73 | title: 'Test Quiz', 74 | description: 'Test Description', 75 | hostId: 'host1', 76 | quizCount: 5, 77 | serverTime: Date.now(), 78 | }; 79 | 80 | act(() => { 81 | result.current.initQuizZoneData(mockQuizZone as QuizZone, Date.now()); 82 | }); 83 | 84 | expect(result.current.quizZoneState.title).toBe('Test Quiz'); 85 | expect(result.current.quizZoneState.currentPlayer.id).toBe('player1'); 86 | expect(result.current.quizZoneState.quizCount).toBe(5); 87 | }); 88 | 89 | describe('state transitions', () => { 90 | it('LOBBY에서 IN_PROGRESS로 상태 전환이 올바르게 되어야 합니다', () => { 91 | const { result } = renderHook(() => useQuizZone(mockQuizZoneId)); 92 | 93 | expect(result.current.quizZoneState.stage).toBe('LOBBY'); 94 | 95 | act(() => { 96 | result.current.playQuiz(); 97 | }); 98 | 99 | expect(result.current.quizZoneState.stage).toBe('IN_PROGRESS'); 100 | }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /apps/frontend/src/hook/useAsyncError.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react'; 2 | 3 | export const useAsyncError = () => { 4 | const [, setError] = useState(); 5 | 6 | return useCallback((error: unknown) => { 7 | setError(() => { 8 | throw error; 9 | }); 10 | }, []); 11 | }; 12 | -------------------------------------------------------------------------------- /apps/frontend/src/hook/useTimer.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from 'react'; 2 | import TimerWorker from '@/workers/timer.worker?worker'; 3 | 4 | interface TimerConfig { 5 | initialTime: number; 6 | onComplete?: () => void; 7 | autoStart?: boolean; 8 | } 9 | 10 | /** 11 | * Web Worker를 활용한 정확한 카운트다운 타이머 커스텀 훅 12 | * 13 | * @param {TimerConfig} config - 타이머 설정 객체 14 | * @returns {object} 타이머 상태와 컨트롤 함수들 15 | */ 16 | export const useTimer = ({ initialTime, onComplete }: TimerConfig) => { 17 | const [time, setTime] = useState(initialTime); 18 | const [isRunning, setIsRunning] = useState(false); 19 | const workerRef = useRef(null); 20 | 21 | useEffect(() => { 22 | // Worker가 이미 존재하면 종료 23 | if (workerRef.current) { 24 | workerRef.current.terminate(); 25 | } 26 | 27 | // 새 Worker 생성 28 | workerRef.current = new TimerWorker(); 29 | 30 | // Worker 메시지 핸들러 31 | workerRef.current.onmessage = (event) => { 32 | const { type, payload } = event.data; 33 | // console.log('Received from worker:', type, payload); // 디버깅용 34 | 35 | switch (type) { 36 | case 'TICK': 37 | setTime(payload.time); 38 | break; 39 | case 'COMPLETE': 40 | setTime(0); 41 | setIsRunning(false); 42 | onComplete?.(); 43 | break; 44 | } 45 | }; 46 | 47 | // Clean up 48 | return () => { 49 | workerRef.current?.terminate(); 50 | }; 51 | }, []); 52 | 53 | // 타이머 시작 54 | const start = useCallback(() => { 55 | if (isRunning || !workerRef.current) return; 56 | 57 | workerRef.current.postMessage({ 58 | type: 'START', 59 | payload: { 60 | duration: initialTime, 61 | serverTime: Date.now(), 62 | }, 63 | }); 64 | 65 | setIsRunning(true); 66 | }, [isRunning, initialTime]); 67 | 68 | // 타이머 정지 69 | const stop = useCallback(() => { 70 | if (!isRunning || !workerRef.current) return; 71 | 72 | workerRef.current.postMessage({ type: 'STOP' }); 73 | setIsRunning(false); 74 | }, [isRunning]); 75 | 76 | // 타이머 리셋 77 | const reset = useCallback(() => { 78 | if (!workerRef.current) return; 79 | 80 | workerRef.current.postMessage({ type: 'RESET' }); 81 | setTime(initialTime); 82 | setIsRunning(false); 83 | }, [initialTime]); 84 | 85 | return { 86 | time, 87 | isRunning, 88 | start, 89 | stop, 90 | reset, 91 | }; 92 | }; 93 | -------------------------------------------------------------------------------- /apps/frontend/src/hook/useValidInput.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | const useValidState = ( 4 | initialState: T, 5 | validator: (state: T) => string | void, 6 | ): [T, string, (state: T) => void, boolean] => { 7 | const msg = validator(initialState) ?? ''; 8 | 9 | const [state, setState] = useState(initialState); 10 | const [isInvalid, setIsInvalid] = useState(msg.length !== 0); 11 | const [InvalidMessage, setInvalidMessage] = useState(msg); 12 | 13 | const setValidateValue = (newState: T) => { 14 | const message = validator(newState); 15 | 16 | setState(newState); 17 | 18 | if (!message) { 19 | setInvalidMessage(''); 20 | setIsInvalid(false); 21 | } else { 22 | setInvalidMessage(message); 23 | setIsInvalid(true); 24 | } 25 | }; 26 | 27 | return [state, InvalidMessage, setValidateValue, isInvalid]; 28 | }; 29 | 30 | export default useValidState; 31 | -------------------------------------------------------------------------------- /apps/frontend/src/hook/useWebSocket.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | 3 | interface WebSocketConfig { 4 | wsUrl: string; 5 | messageHandler: (event: MessageEvent) => void; 6 | handleFinish?: () => void; 7 | handleReconnect?: () => void; 8 | } 9 | 10 | const useWebSocket = ({ 11 | wsUrl, 12 | messageHandler, 13 | handleFinish, 14 | handleReconnect, 15 | }: WebSocketConfig) => { 16 | const ws = useRef(null); 17 | const messageQueue = useRef([]); 18 | 19 | const beginConnection = () => { 20 | if (ws.current !== null) { 21 | return; 22 | } 23 | 24 | ws.current = new WebSocket(wsUrl); 25 | 26 | ws.current.onopen = () => { 27 | while (messageQueue.current.length > 0) { 28 | const message = messageQueue.current.shift()!; 29 | sendMessage(message); 30 | } 31 | }; 32 | 33 | ws.current.onclose = (ev: CloseEvent) => { 34 | const { wasClean, reason } = ev; 35 | 36 | ws.current = null; 37 | 38 | if (reason == 'finish') { 39 | handleFinish?.(); 40 | } 41 | 42 | if (!wasClean) { 43 | handleReconnect?.(); 44 | } 45 | }; 46 | 47 | ws.current.onerror = (error) => { 48 | console.error('WebSocket error:', error); 49 | }; 50 | 51 | ws.current.onmessage = messageHandler; 52 | }; 53 | 54 | const sendMessage = (message: string) => { 55 | if (ws.current?.readyState === WebSocket.OPEN) { 56 | ws.current.send(message); 57 | } else { 58 | console.warn('WebSocket is not connected. Message not sent:', message); 59 | messageQueue.current.push(message); 60 | } 61 | }; 62 | 63 | const closeConnection = () => { 64 | ws.current?.close(); 65 | }; 66 | 67 | return { beginConnection, sendMessage, closeConnection }; 68 | }; 69 | 70 | export default useWebSocket; 71 | -------------------------------------------------------------------------------- /apps/frontend/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | @layer base { 5 | :root { 6 | --background: 0 0% 100%; 7 | --foreground: 240 10% 3.9%; 8 | --card: 0 0% 100%; 9 | --card-foreground: 240 10% 3.9%; 10 | --popover: 0 0% 100%; 11 | --popover-foreground: 240 10% 3.9%; 12 | --primary: 240 5.9% 10%; 13 | --primary-foreground: 0 0% 98%; 14 | --secondary: 240 4.8% 95.9%; 15 | --secondary-foreground: 240 5.9% 10%; 16 | --muted: 240 4.8% 95.9%; 17 | --muted-foreground: 240 3.8% 46.1%; 18 | --accent: 240 4.8% 95.9%; 19 | --accent-foreground: 240 5.9% 10%; 20 | --destructive: 0 84.2% 60.2%; 21 | --destructive-foreground: 0 0% 98%; 22 | --border: 240 5.9% 90%; 23 | --input: 240 5.9% 90%; 24 | --ring: 240 10% 3.9%; 25 | --chart-1: 12 76% 61%; 26 | --chart-2: 173 58% 39%; 27 | --chart-3: 197 37% 24%; 28 | --chart-4: 43 74% 66%; 29 | --chart-5: 27 87% 67%; 30 | --radius: 0.5rem 31 | } 32 | .dark { 33 | --background: 240 10% 3.9%; 34 | --foreground: 0 0% 98%; 35 | --card: 240 10% 3.9%; 36 | --card-foreground: 0 0% 98%; 37 | --popover: 240 10% 3.9%; 38 | --popover-foreground: 0 0% 98%; 39 | --primary: 0 0% 98%; 40 | --primary-foreground: 240 5.9% 10%; 41 | --secondary: 240 3.7% 15.9%; 42 | --secondary-foreground: 0 0% 98%; 43 | --muted: 240 3.7% 15.9%; 44 | --muted-foreground: 240 5% 64.9%; 45 | --accent: 240 3.7% 15.9%; 46 | --accent-foreground: 0 0% 98%; 47 | --destructive: 0 62.8% 30.6%; 48 | --destructive-foreground: 0 0% 98%; 49 | --border: 240 3.7% 15.9%; 50 | --input: 240 3.7% 15.9%; 51 | --ring: 240 4.9% 83.9%; 52 | --chart-1: 220 70% 50%; 53 | --chart-2: 160 60% 45%; 54 | --chart-3: 30 80% 55%; 55 | --chart-4: 280 65% 60%; 56 | --chart-5: 340 75% 55% 57 | } 58 | } 59 | @layer base { 60 | * { 61 | @apply border-border; 62 | } 63 | body { 64 | @apply bg-background text-foreground; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /apps/frontend/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /apps/frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import './index.css'; 3 | import App from './App.tsx'; 4 | import { BrowserRouter } from 'react-router-dom'; 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/CreateQuizZonePage.tsx: -------------------------------------------------------------------------------- 1 | import CreateQuizZoneBasic from '@/blocks/CreateQuizZone/CreateQuizZoneBasic.tsx'; 2 | import CreateQuizSet from '@/blocks/CreateQuizZone/CreateQuizSet.tsx'; 3 | import { useReducer, useState } from 'react'; 4 | import { 5 | CreateQuizZone, 6 | CreateQuizZoneReducerAction, 7 | CreateQuizZoneReducerActions, 8 | CreateQuizZoneStage, 9 | } from '@/types/create-quiz-zone.types.ts'; 10 | import Typography from '@/components/common/Typogrpahy'; 11 | 12 | const CreateQuizZoneReducer = (state: CreateQuizZone, action: CreateQuizZoneReducerAction) => { 13 | const { type, payload } = action; 14 | 15 | switch (type) { 16 | case 'QUIZ_ZONE_ID': 17 | return { ...state, quizZoneId: payload }; 18 | case 'TITLE': 19 | return { ...state, title: payload }; 20 | case 'DESC': 21 | return { ...state, description: payload }; 22 | case 'LIMIT': 23 | return { ...state, limitPlayerCount: parseInt(payload) }; 24 | case 'QUIZ_SET_ID': 25 | return { ...state, quizSetId: payload }; 26 | case 'QUIZ_SET_NAME': 27 | return { ...state, quizSetName: payload }; 28 | } 29 | }; 30 | 31 | const CreateQuizZonePage = () => { 32 | const [stage, setState] = useState('QUIZ_ZONE'); 33 | const [quizZone, dispatch] = useReducer(CreateQuizZoneReducer, { 34 | quizZoneId: '', 35 | title: '', 36 | description: '', 37 | limitPlayerCount: 10, 38 | quizSetId: '', 39 | quizSetName: '', 40 | }); 41 | 42 | const moveStage = (stage: CreateQuizZoneStage) => { 43 | setState(stage); 44 | }; 45 | 46 | const updateQuizZoneBasic = (payload: string, type: CreateQuizZoneReducerActions) => { 47 | dispatch({ type, payload }); 48 | }; 49 | 50 | const updateQuizSet = (quizSetId: string, quizSetName: string) => { 51 | updateQuizZoneBasic(quizSetId, 'QUIZ_SET_ID'); 52 | updateQuizZoneBasic(quizSetName, 'QUIZ_SET_NAME'); 53 | }; 54 | 55 | const getCreateQuizZoneStageBlock = (stage: CreateQuizZoneStage) => { 56 | switch (stage) { 57 | case 'QUIZ_ZONE': 58 | return ( 59 | 64 | ); 65 | case 'QUIZ_SET': 66 | return ( 67 | moveStage('QUIZ_ZONE')} 69 | updateQuizSet={updateQuizSet} 70 | /> 71 | ); 72 | } 73 | }; 74 | 75 | return ( 76 |
77 | 78 | {getCreateQuizZoneStageBlock(stage)} 79 |
80 | ); 81 | }; 82 | 83 | export default CreateQuizZonePage; 84 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/NotFoundPage.tsx: -------------------------------------------------------------------------------- 1 | import CommonButton from '@/components/common/CommonButton'; 2 | import ContentBox from '@/components/common/ContentBox'; 3 | import Typography from '@/components/common/Typogrpahy'; 4 | import TooltipWrapper from '@/components/common/TooltipWrapper'; 5 | import { useNavigate } from 'react-router-dom'; 6 | import Logo from '@/components/common/Logo'; 7 | 8 | const NotFound = () => { 9 | const navigate = useNavigate(); 10 | const handleMoveMainPage = () => { 11 | navigate(`/`); 12 | }; 13 | 14 | return ( 15 |
16 | 17 | 18 | 19 | 20 | 26 | 27 | 28 | 33 | 34 | 39 | 44 | 45 | 46 |
47 | ); 48 | }; 49 | 50 | export default NotFound; 51 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/RootLayout.tsx: -------------------------------------------------------------------------------- 1 | import Navbar from '@/components/common/NavBar'; 2 | import { Outlet } from 'react-router-dom'; 3 | 4 | const RootLayout = () => { 5 | return ( 6 |
7 | 8 |
9 |
10 | 11 |
12 |
13 |
14 | ); 15 | }; 16 | 17 | export default RootLayout; 18 | -------------------------------------------------------------------------------- /apps/frontend/src/router/router.tsx: -------------------------------------------------------------------------------- 1 | import { Route, Routes } from 'react-router-dom'; 2 | import MainPage from '@/pages/MainPage'; 3 | import RootLayout from '../pages/RootLayout'; 4 | import QuizZonePage from '@/pages/QuizZonePage'; 5 | import CreateQuizZonePage from '@/pages/CreateQuizZonePage.tsx'; 6 | import NotFound from '@/pages/NotFoundPage'; 7 | 8 | function Router() { 9 | return ( 10 | 11 | }> 12 | } /> 13 | } /> 14 | } /> 15 | } /> 16 | 17 | 18 | ); 19 | } 20 | 21 | export default Router; 22 | -------------------------------------------------------------------------------- /apps/frontend/src/test/setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { afterEach } from 'vitest'; 3 | import { cleanup } from '@testing-library/react'; 4 | // 각 테스트 후 cleanup 5 | afterEach(() => { 6 | cleanup(); 7 | }); 8 | -------------------------------------------------------------------------------- /apps/frontend/src/types/create-quiz-zone.types.ts: -------------------------------------------------------------------------------- 1 | export type CreateQuizZoneStage = 'QUIZ_ZONE' | 'QUIZ_SET' | 'SUMMARY'; 2 | 3 | export interface CreateQuizZone { 4 | quizZoneId: string; 5 | title: string; 6 | description: string; 7 | limitPlayerCount: number; 8 | quizSetId: string; 9 | quizSetName: string; 10 | } 11 | 12 | export type CreateQuizZoneReducerActions = 13 | | 'QUIZ_ZONE_ID' 14 | | 'TITLE' 15 | | 'DESC' 16 | | 'LIMIT' 17 | | 'QUIZ_SET_ID' 18 | | 'QUIZ_SET_NAME'; 19 | 20 | export type CreateQuizZoneReducerAction = { type: CreateQuizZoneReducerActions; payload: string }; 21 | 22 | export interface ResponseQuizSet { 23 | id: string; 24 | name: string; 25 | } 26 | 27 | export interface ResponseSearchQuizSets { 28 | quizSetDetails: ResponseQuizSet[]; 29 | currentPage: number; 30 | total: number; 31 | } 32 | -------------------------------------------------------------------------------- /apps/frontend/src/types/error.types.ts: -------------------------------------------------------------------------------- 1 | export type QuizZoneErrorType = 2 | | 'NOT_FOUND' 3 | | 'ALREADY_STARTED' 4 | | 'ROOM_FULL' 5 | | 'ALREADY_ENDED' 6 | | 'NOT_AUTHORIZED' 7 | | 'SESSION_ERROR' 8 | | 'QUIZ_COMPLETE' 9 | | 'VALIDATION_ERROR' 10 | | null; 11 | 12 | export class ValidationError extends Error { 13 | constructor(message: string) { 14 | super(message); 15 | this.name = 'ValidationError'; 16 | } 17 | } 18 | 19 | export const quizZoneErrorMessages: Record< 20 | Exclude, 21 | { title: string; description: string } 22 | > = { 23 | NOT_FOUND: { 24 | title: '찾을 수 없는 퀴즈존이에요', 25 | description: '퀴즈존이 없어졌거나 다른 곳으로 이동했어요', 26 | }, 27 | ALREADY_STARTED: { 28 | title: '이미 시작된 퀴즈존이에요', 29 | description: '다음 퀴즈존을 기다려주세요', 30 | }, 31 | ROOM_FULL: { 32 | title: '정원이 가득 찼어요', 33 | description: '다른 퀴즈존을 둘러보시는 건 어떨까요?', 34 | }, 35 | ALREADY_ENDED: { 36 | title: '종료된 퀴즈존이에요', 37 | description: '새로운 퀴즈존에 도전해보세요', 38 | }, 39 | NOT_AUTHORIZED: { 40 | title: '참여할 수 없는 퀴즈존이에요', 41 | description: '퀴즈존에 참여하지 않은 사용자예요', 42 | }, 43 | SESSION_ERROR: { 44 | title: '일시적인 오류가 발생했어요', 45 | description: '잠시 후 다시 시도해주세요', 46 | }, 47 | QUIZ_COMPLETE: { 48 | title: '퀴즈가 끝났어요', 49 | description: '모든 문제가 출제되었어요', 50 | }, 51 | VALIDATION_ERROR: { 52 | title: '입력값이 올바르지 않아요', 53 | description: '입력 조건을 확인해주세요', 54 | }, 55 | }; 56 | -------------------------------------------------------------------------------- /apps/frontend/src/types/quizZone.types.ts: -------------------------------------------------------------------------------- 1 | export type QuizZoneStage = 'LOBBY' | 'IN_PROGRESS' | 'RESULT'; 2 | export type PlayerState = 'WAIT' | 'PLAY' | 'SUBMIT'; 3 | export type ProblemType = 'SHORT'; 4 | 5 | export interface Player { 6 | id: string; 7 | nickname: string; 8 | state?: PlayerState; 9 | } 10 | 11 | export interface QuizZone { 12 | stage: QuizZoneStage; 13 | currentPlayer: Player; 14 | title: string; 15 | description: string; 16 | hostId: string; 17 | quizCount: number; 18 | players?: Player[]; 19 | maxPlayers?: number; 20 | currentQuiz?: CurrentQuiz; 21 | currentQuizResult?: CurrentQuizResult; 22 | score?: number; 23 | quizzes?: Quiz[]; 24 | submits?: SubmittedQuiz[]; 25 | ranks?: Rank[]; 26 | isLastQuiz?: boolean; 27 | chatMessages?: ChatMessage[]; 28 | isQuizZoneEnd?: boolean; 29 | endSocketTime?: number; 30 | serverTime: number; 31 | offset: number; 32 | } 33 | 34 | export interface Rank { 35 | id: string; 36 | nickname: string; 37 | score: number; 38 | ranking: number; 39 | } 40 | 41 | export interface QuizZoneLobbyState { 42 | stage: QuizZoneStage; 43 | title: string; 44 | description: string; 45 | quizCount: number; 46 | hostId: string; 47 | players: Player[]; 48 | currentPlayer: Player; 49 | } 50 | 51 | export interface CurrentQuiz { 52 | question: string; 53 | currentIndex: number; 54 | playTime: number; 55 | startTime: number; 56 | deadlineTime: number; 57 | type?: ProblemType; 58 | } 59 | 60 | export interface QuizZoneProgressState { 61 | currentPlayer: Player; 62 | currentQuiz: CurrentQuiz; 63 | } 64 | 65 | export interface Quiz { 66 | question: string; 67 | answer: string; 68 | playTime: number; 69 | quizType?: ProblemType; 70 | } 71 | 72 | export interface SubmittedQuiz { 73 | index: number; 74 | answer: string; 75 | submittedAt: number; 76 | receivedAt: number; 77 | submitRank?: number; 78 | } 79 | 80 | export interface QuizZoneResultState { 81 | score: number; 82 | submits: SubmittedQuiz[]; 83 | quizzes: Quiz[]; 84 | ranks: Rank[]; 85 | endSocketTime: number; 86 | } 87 | 88 | export interface SubmitResponse { 89 | fastestPlayerIds: string[]; 90 | submittedCount: number; 91 | totalPlayerCount: number; 92 | chatMessages: ChatMessage[]; 93 | } 94 | 95 | export interface SomeoneSubmitResponse { 96 | clientId: string; 97 | submittedCount: number; 98 | } 99 | 100 | export interface CurrentQuizResult { 101 | answer?: string; 102 | correctPlayerCount?: number; 103 | totalPlayerCount: number; 104 | submittedCount: number; 105 | fastestPlayers: Player[]; 106 | } 107 | 108 | export interface NextQuizResponse { 109 | nextQuiz: CurrentQuiz; 110 | currentQuizResult: CurrentQuizResult; 111 | } 112 | 113 | export interface QuizSet { 114 | quizSetId?: string; 115 | quizSetName: string; 116 | quizzes: Quiz[]; 117 | } 118 | 119 | export interface ChatMessage { 120 | clientId: string; 121 | nickname: string; 122 | message: string; 123 | } 124 | 125 | export interface InitQuizZoneResponse { 126 | quizZone: QuizZone; 127 | now: number; 128 | } 129 | -------------------------------------------------------------------------------- /apps/frontend/src/types/timer.types.ts: -------------------------------------------------------------------------------- 1 | export interface TimerMessage { 2 | type: 'START' | 'STOP' | 'RESET'; 3 | payload?: { 4 | duration: number; 5 | serverTime?: number; 6 | }; 7 | } 8 | 9 | export interface TimerResponse { 10 | type: 'TICK' | 'COMPLETE'; 11 | payload?: { 12 | time: number; 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/atob.ts: -------------------------------------------------------------------------------- 1 | function atob(encodedString: string): string { 2 | try { 3 | // 브라우저 native atob 사용 4 | return decodeURIComponent(escape(window.atob(encodedString))); 5 | } catch (error) { 6 | console.error('Base64 디코딩 실패:', error); 7 | return encodedString; // 실패 시 원본 문자열 반환 8 | } 9 | } 10 | 11 | export default atob; 12 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/errorUtils.ts: -------------------------------------------------------------------------------- 1 | import { QuizZoneErrorType, ValidationError } from '@/types/error.types'; 2 | 3 | export const getQuizZoneErrorType = (error: unknown): QuizZoneErrorType => { 4 | if (error instanceof ValidationError) { 5 | return 'VALIDATION_ERROR'; 6 | } 7 | if (error instanceof Response) { 8 | switch (error.status) { 9 | case 404: 10 | return 'NOT_FOUND'; 11 | case 409: 12 | return 'ALREADY_STARTED'; 13 | case 400: 14 | return 'NOT_AUTHORIZED'; 15 | default: 16 | return 'SESSION_ERROR'; 17 | } 18 | } 19 | 20 | if (error instanceof Error) { 21 | switch (error.message) { 22 | case '퀴즈존 정원이 초과되었습니다.': 23 | return 'ROOM_FULL'; 24 | case '이미 종료된 퀴즈존입니다.': 25 | return 'ALREADY_ENDED'; 26 | case '퀴즈가 모두 종료되었습니다.': 27 | return 'QUIZ_COMPLETE'; 28 | default: 29 | return 'SESSION_ERROR'; 30 | } 31 | } 32 | 33 | return 'SESSION_ERROR'; 34 | }; 35 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/requests.ts: -------------------------------------------------------------------------------- 1 | import { QuizSet, QuizZone } from '@/types/quizZone.types.ts'; 2 | import { CreateQuizZone, ResponseSearchQuizSets } from '@/types/create-quiz-zone.types.ts'; 3 | 4 | export const requestCreateQuizZone = async (quizZone: CreateQuizZone) => { 5 | const response = await fetch('api/quiz-zone', { 6 | method: 'POST', 7 | headers: { 8 | 'Content-Type': 'application/json', 9 | }, 10 | body: JSON.stringify(quizZone), 11 | }); 12 | 13 | if (!response.ok) { 14 | throw new Error('퀴즈존 생성 처리중 오류가 발생하였습니다.'); 15 | } 16 | }; 17 | export const requestCreateQuizSet = async (quizSet: QuizSet) => { 18 | const { quizSetName, quizzes } = quizSet; 19 | 20 | const response = await fetch('api/quiz-set', { 21 | method: 'POST', 22 | headers: { 'Content-Type': 'application/json' }, 23 | body: JSON.stringify({ 24 | quizSetName, 25 | quizDetails: quizzes, 26 | }), 27 | }); 28 | 29 | if (!response.ok) { 30 | throw Error(); 31 | } 32 | 33 | return (await response.json()) as string; 34 | }; 35 | 36 | export const requestSearchQuizSets = async (params: Record) => { 37 | const searchParams = new URLSearchParams(params); 38 | const url = `api/quiz-set?${searchParams.toString()}`; 39 | 40 | const response = await fetch(url, { 41 | method: 'GET', 42 | }); 43 | 44 | if (!response.ok) { 45 | console.log(response.status); 46 | } 47 | 48 | const { quizSetDetails, total, currentPage } = 49 | (await response.json()) as ResponseSearchQuizSets; 50 | 51 | return { 52 | quizSets: quizSetDetails, 53 | totalQuizSetCount: total, 54 | currentPage: currentPage, 55 | }; 56 | }; 57 | export const requestQuizZone = async (quizZoneId: string) => { 58 | const response = await fetch(`/api/quiz-zone/${quizZoneId}`, { method: 'GET' }); 59 | 60 | if (!response.ok) { 61 | throw response; 62 | } 63 | 64 | return (await response.json()) as QuizZone; 65 | }; 66 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/validators.ts: -------------------------------------------------------------------------------- 1 | import { Quiz } from '@/types/quizZone.types.ts'; 2 | import { QUIZ_LIMIT_COUNT } from '@/constants/quiz-set.constants.ts'; 3 | 4 | export const validQuizSetName = (name: string) => { 5 | if (name.length <= 0) return '퀴즈존 이름 입력을 확인하세요'; 6 | if (name.length > 100) return '퀴즈존 이름 길이를 확인하세요. (최대 100자)'; 7 | }; 8 | export const validQuizzes = (quizzes: Quiz[]) => { 9 | if (quizzes.length === 0) return '퀴즈를 1개 이상 등록해야합니다.'; 10 | if (quizzes.length > QUIZ_LIMIT_COUNT) return '퀴즈는 최대 10개만 등록할 수 있습니다.'; 11 | }; 12 | export const validQuestion = (question: string) => { 13 | if (question.length <= 0) return '문제를 입력해주세요.'; 14 | if (question.length > 200) return '문제 길이를 초과하였습니다. 최대 200자'; 15 | }; 16 | export const validAnswer = (answer: string) => { 17 | if (answer.length <= 0) return '답안을 입력해주세요.'; 18 | if (answer.length > 50) return '답안 길이를 초과하였습니다. 최대 50자'; 19 | }; 20 | export const validTime = (time: number) => { 21 | if (time <= 0) return '제한시간은 0초 보다 커야합니다.'; 22 | if (time > 60) return '제한시간은 60초를 초과할 수 없습니다.'; 23 | }; 24 | 25 | //QuizZone 생성 관련 유효성 검사 26 | 27 | //퀴즈존 이름 유효성 검사 28 | export const validateQuizZoneSetName = (name: string) => { 29 | if (name.length <= 0) return '제목을 입력해주세요.'; 30 | if (name.length > 100) return '100자 이하로 입력해주세요.'; 31 | }; 32 | 33 | //퀴즈존 설명 유효성 검사 34 | export const validateQuizZoneSetDescription = (description: string) => { 35 | if (description.length > 300) return '300자 이하로 입력해주세요.'; 36 | }; 37 | 38 | //퀴즈존 입장 코드 유효성 검사 39 | export const validateQuizZoneSetCode = (code: string) => { 40 | if (code.length < 5) return '5자 이상 입력해주세요.'; 41 | if (code.length > 10) return '10자 이하로 입력해주세요.'; 42 | if (!/^[a-zA-Z0-9]*$/g.test(code)) return '숫자와 알파벳 조합만 가능합니다.'; 43 | }; 44 | 45 | //입장 인원 제한 유효성 검사 46 | export const validateQuizZoneSetLimit = (limit: number) => { 47 | if (limit < 1) return '최소 1명 이상 지정해주세요.'; 48 | if (limit > 300) return '최대 인원은 300명입니다.'; 49 | }; 50 | -------------------------------------------------------------------------------- /apps/frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare module '*?worker' { 3 | const workerConstructor: { 4 | new (): Worker; 5 | }; 6 | export default workerConstructor; 7 | } 8 | -------------------------------------------------------------------------------- /apps/frontend/src/workers/timer.worker.ts: -------------------------------------------------------------------------------- 1 | let timerId: ReturnType | null = null; 2 | let startTime: number | null = null; 3 | let duration: number | null = null; 4 | let timeOffset: number = 0; 5 | let pausedTimeRemaining: number | null = null; 6 | 7 | self.onmessage = (event: MessageEvent) => { 8 | const { type, payload } = event.data; 9 | 10 | switch (type) { 11 | case 'START': 12 | if (!payload?.duration) return; 13 | 14 | if (pausedTimeRemaining !== null) { 15 | startTimer(pausedTimeRemaining, self.postMessage); 16 | pausedTimeRemaining = null; 17 | } else { 18 | if (payload.serverTime) { 19 | timeOffset = Date.now() - payload.serverTime; 20 | } 21 | startTimer(payload.duration, self.postMessage); 22 | } 23 | break; 24 | 25 | case 'STOP': 26 | if (timerId !== null && startTime !== null && duration !== null) { 27 | const currentTime = Date.now() - timeOffset; 28 | const elapsed = (currentTime - startTime) / 1000; 29 | pausedTimeRemaining = Math.max(0, duration - elapsed); 30 | } 31 | stopTimer(); 32 | break; 33 | 34 | case 'RESET': 35 | resetTimer(); 36 | break; 37 | } 38 | }; 39 | 40 | function startTimer( 41 | newDuration: number, 42 | postMessage: { 43 | (message: any, targetOrigin: string, transfer?: Transferable[]): void; 44 | (message: any, options?: WindowPostMessageOptions): void; 45 | }, 46 | ) { 47 | stopTimer(); // 기존 타이머가 있다면 정리 48 | 49 | duration = newDuration; 50 | startTime = Date.now() - timeOffset; 51 | 52 | timerId = setInterval(() => { 53 | if (!startTime || !duration) return; 54 | 55 | const currentTime = Date.now() - timeOffset; 56 | const elapsed = (currentTime - startTime) / 1000; 57 | const remaining = Math.max(0, duration - elapsed); 58 | const roundedRemaining = Math.round(remaining * 10) / 10; 59 | 60 | if (roundedRemaining <= 0) { 61 | postMessage({ type: 'COMPLETE' }); 62 | stopTimer(); 63 | } else { 64 | postMessage({ 65 | type: 'TICK', 66 | payload: { time: roundedRemaining }, 67 | }); 68 | } 69 | }, 100); 70 | } 71 | 72 | function stopTimer() { 73 | if (timerId !== null) { 74 | clearInterval(timerId); 75 | timerId = null; 76 | } 77 | } 78 | 79 | function resetTimer() { 80 | stopTimer(); 81 | startTime = null; 82 | duration = null; 83 | timeOffset = 0; 84 | pausedTimeRemaining = null; 85 | } 86 | -------------------------------------------------------------------------------- /apps/frontend/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | 3 | export default { 4 | darkMode: ["class"], 5 | content: [ 6 | "./src/**/*.{js,jsx,ts,tsx}", 7 | ], 8 | theme: { 9 | extend: { 10 | borderRadius: { 11 | lg: 'var(--radius)', 12 | md: 'calc(var(--radius) - 2px)', 13 | sm: 'calc(var(--radius) - 4px)' 14 | }, 15 | colors: { 16 | background: 'hsl(var(--background))', 17 | foreground: 'hsl(var(--foreground))', 18 | card: { 19 | DEFAULT: 'hsl(var(--card))', 20 | foreground: 'hsl(var(--card-foreground))' 21 | }, 22 | popover: { 23 | DEFAULT: 'hsl(var(--popover))', 24 | foreground: 'hsl(var(--popover-foreground))' 25 | }, 26 | primary: { 27 | DEFAULT: 'hsl(var(--primary))', 28 | foreground: 'hsl(var(--primary-foreground))' 29 | }, 30 | secondary: { 31 | DEFAULT: 'hsl(var(--secondary))', 32 | foreground: 'hsl(var(--secondary-foreground))' 33 | }, 34 | muted: { 35 | DEFAULT: 'hsl(var(--muted))', 36 | foreground: 'hsl(var(--muted-foreground))' 37 | }, 38 | accent: { 39 | DEFAULT: 'hsl(var(--accent))', 40 | foreground: 'hsl(var(--accent-foreground))' 41 | }, 42 | destructive: { 43 | DEFAULT: 'hsl(var(--destructive))', 44 | foreground: 'hsl(var(--destructive-foreground))' 45 | }, 46 | border: 'hsl(var(--border))', 47 | input: 'hsl(var(--input))', 48 | ring: 'hsl(var(--ring))', 49 | chart: { 50 | '1': 'hsl(var(--chart-1))', 51 | '2': 'hsl(var(--chart-2))', 52 | '3': 'hsl(var(--chart-3))', 53 | '4': 'hsl(var(--chart-4))', 54 | '5': 'hsl(var(--chart-5))' 55 | } 56 | } 57 | } 58 | }, 59 | plugins: [require("tailwindcss-animate")], 60 | } -------------------------------------------------------------------------------- /apps/frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 6 | "target": "ES2020", 7 | "useDefineForClassFields": true, 8 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 9 | "module": "ESNext", 10 | "skipLibCheck": true, 11 | "baseUrl": ".", 12 | "paths": { 13 | "@/*": [ 14 | "./src/*" 15 | ] 16 | }, 17 | 18 | /* Bundler mode */ 19 | "moduleResolution": "Bundler", 20 | "allowImportingTsExtensions": true, 21 | "isolatedModules": true, 22 | "moduleDetection": "force", 23 | "emitDeclarationOnly": true, 24 | "jsx": "react-jsx", 25 | 26 | /* Linting */ 27 | "strict": true, 28 | "noUnusedLocals": true, 29 | "noUnusedParameters": true, 30 | "noFallthroughCasesInSwitch": true, 31 | "noUncheckedSideEffectImports": true 32 | }, 33 | "include": ["src"] 34 | } 35 | -------------------------------------------------------------------------------- /apps/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "jsx": "react", 5 | "baseUrl": ".", 6 | "paths": { 7 | "@/*": ["./src/*"] 8 | }, 9 | "types": ["vitest/globals", "@testing-library/jest-dom"], 10 | "lib": ["webworker", "es2015"] 11 | }, 12 | "files": [], 13 | "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] 14 | } 15 | -------------------------------------------------------------------------------- /apps/frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 5 | "target": "ES2022", 6 | "lib": ["ES2023"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "Bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "emitDeclarationOnly": true, 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUncheckedSideEffectImports": true 23 | }, 24 | "include": ["vite.config.ts"] 25 | } 26 | -------------------------------------------------------------------------------- /apps/frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import react from '@vitejs/plugin-react'; 3 | import { defineConfig, loadEnv } from 'vite'; 4 | 5 | export default defineConfig(({ mode }) => { 6 | const env = loadEnv(mode, process.cwd()); 7 | 8 | return { 9 | plugins: [react()], 10 | worker: { 11 | format: 'es', 12 | }, 13 | resolve: { 14 | alias: { 15 | '@': path.resolve(__dirname, './src'), 16 | }, 17 | }, 18 | server: { 19 | proxy: { 20 | '/api': { 21 | target: env.VITE_API_URL, 22 | changeOrigin: true, 23 | secure: false, 24 | rewrite: (path) => path.replace(/^\/api/, ''), // /api prefix 제거 25 | }, 26 | }, 27 | }, 28 | build: { 29 | clean: true, // 빌드 전에 outDir을 청소합니다 30 | rollupOptions: { 31 | output: { 32 | manualChunks(id) { 33 | if (id.includes('timer.worker')) { 34 | return 'worker'; 35 | } 36 | }, 37 | // 캐시 무효화를 위한 더 안전한 방법 38 | entryFileNames: `assets/[name].[hash].js`, 39 | chunkFileNames: `assets/[name].[hash].js`, 40 | assetFileNames: `assets/[name].[hash].[ext]`, 41 | }, 42 | }, 43 | // 캐시 설정 44 | manifest: true, // manifest 파일 생성 45 | sourcemap: true, 46 | }, 47 | test: { 48 | environment: 'jsdom', 49 | globals: true, 50 | setupFiles: './src/test/setup.ts', 51 | }, 52 | }; 53 | }); 54 | -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'type-case': [2, 'always', 'lower-case'], 5 | 'type-enum': [ 6 | 2, 7 | 'always', 8 | [ 9 | 'feat', 10 | 'fix', 11 | 'design', 12 | '!breaking change', 13 | '!hotfix', 14 | 'style', 15 | 'refactor', 16 | 'comment', 17 | 'docs', 18 | 'test', 19 | 'chore', 20 | 'rename', 21 | 'remove', 22 | ], 23 | ], 24 | 'subject-empty': [2, 'never'], 25 | 'subject-case': [0, 'never'], // 대소문자 제한 해제 26 | 'subject-full-stop': [2, 'never', '.'], 27 | 'body-leading-blank': [2, 'always'], 28 | 'footer-leading-blank': [2, 'always'], 29 | }, 30 | parserPreset: { 31 | parserOpts: { 32 | issuePrefixes: ['EPIC: #', 'Story: #', 'Task: #'], 33 | }, 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | **web08-booquiz v1.0.0** • [**Docs**](modules.md) 2 | 3 | *** 4 | 5 | # web08-BooQuiz 6 | 7 |

8 | 9 |

10 | 11 | [팀 노션](https://www.notion.so/127f1897cdf5809c8a44d54384683bc6?pvs=21) | [백로그](https://github.com/orgs/boostcampwm-2024/projects/11) | [그라운드 룰](https://github.com/boostcampwm-2024/web08-BooQuiz/wiki/%EA%B7%B8%EB%9D%BC%EC%9A%B4%EB%93%9C-%EB%A3%B0) 12 | 13 | ## 프로젝트 소개 14 | 15 | 300명 이상의 부스트 캠퍼를 감당 할 수 있는 실시간 퀴즈 플랫폼 16 | 17 | ## 팀 소개 18 | 19 | | [J004 강준현](https://github.com/JunhyunKang) | [J074 김현우](https://github.com/krokerdile) | [J086 도선빈](https://github.com/typingmistake) | [J175 이동현](https://github.com/codemario318) | [J217 전현민](https://github.com/joyjhm) | 20 | | --- | --- | --- | --- | --- | 21 | |![](https://avatars.githubusercontent.com/u/72436328?v=4)|![](https://avatars.githubusercontent.com/u/39644976?v=4)|![](https://avatars.githubusercontent.com/u/102957984?v=4)|![](https://avatars.githubusercontent.com/u/130330767?v=4)|![](https://avatars.githubusercontent.com/u/77275989?v=4)| 22 | -------------------------------------------------------------------------------- /docs/backend/src/app.controller/README.md: -------------------------------------------------------------------------------- 1 | [**web08-booquiz v1.0.0**](../../../README.md) • **Docs** 2 | 3 | *** 4 | 5 | [web08-booquiz v1.0.0](../../../modules.md) / backend/src/app.controller 6 | 7 | # backend/src/app.controller 8 | 9 | ## Index 10 | 11 | ### Classes 12 | 13 | - [AppController](classes/AppController.md) 14 | -------------------------------------------------------------------------------- /docs/backend/src/app.controller/classes/AppController.md: -------------------------------------------------------------------------------- 1 | [**web08-booquiz v1.0.0**](../../../../README.md) • **Docs** 2 | 3 | *** 4 | 5 | [web08-booquiz v1.0.0](../../../../modules.md) / [backend/src/app.controller](../README.md) / AppController 6 | 7 | # Class: AppController 8 | 9 | ## Constructors 10 | 11 | ### new AppController() 12 | 13 | > **new AppController**(`appService`): [`AppController`](AppController.md) 14 | 15 | #### Parameters 16 | 17 | • **appService**: [`AppService`](../../app.service/classes/AppService.md) 18 | 19 | #### Returns 20 | 21 | [`AppController`](AppController.md) 22 | 23 | #### Defined in 24 | 25 | app.controller.ts:6 26 | 27 | ## Methods 28 | 29 | ### getHello() 30 | 31 | > **getHello**(): `string` 32 | 33 | #### Returns 34 | 35 | `string` 36 | 37 | #### Defined in 38 | 39 | app.controller.ts:9 40 | -------------------------------------------------------------------------------- /docs/backend/src/app.module/README.md: -------------------------------------------------------------------------------- 1 | [**web08-booquiz v1.0.0**](../../../README.md) • **Docs** 2 | 3 | *** 4 | 5 | [web08-booquiz v1.0.0](../../../modules.md) / backend/src/app.module 6 | 7 | # backend/src/app.module 8 | 9 | ## Index 10 | 11 | ### Classes 12 | 13 | - [AppModule](classes/AppModule.md) 14 | -------------------------------------------------------------------------------- /docs/backend/src/app.module/classes/AppModule.md: -------------------------------------------------------------------------------- 1 | [**web08-booquiz v1.0.0**](../../../../README.md) • **Docs** 2 | 3 | *** 4 | 5 | [web08-booquiz v1.0.0](../../../../modules.md) / [backend/src/app.module](../README.md) / AppModule 6 | 7 | # Class: AppModule 8 | 9 | ## Constructors 10 | 11 | ### new AppModule() 12 | 13 | > **new AppModule**(): [`AppModule`](AppModule.md) 14 | 15 | #### Returns 16 | 17 | [`AppModule`](AppModule.md) 18 | -------------------------------------------------------------------------------- /docs/backend/src/app.service/README.md: -------------------------------------------------------------------------------- 1 | [**web08-booquiz v1.0.0**](../../../README.md) • **Docs** 2 | 3 | *** 4 | 5 | [web08-booquiz v1.0.0](../../../modules.md) / backend/src/app.service 6 | 7 | # backend/src/app.service 8 | 9 | ## Index 10 | 11 | ### Classes 12 | 13 | - [AppService](classes/AppService.md) 14 | -------------------------------------------------------------------------------- /docs/backend/src/app.service/classes/AppService.md: -------------------------------------------------------------------------------- 1 | [**web08-booquiz v1.0.0**](../../../../README.md) • **Docs** 2 | 3 | *** 4 | 5 | [web08-booquiz v1.0.0](../../../../modules.md) / [backend/src/app.service](../README.md) / AppService 6 | 7 | # Class: AppService 8 | 9 | ## Constructors 10 | 11 | ### new AppService() 12 | 13 | > **new AppService**(): [`AppService`](AppService.md) 14 | 15 | #### Returns 16 | 17 | [`AppService`](AppService.md) 18 | 19 | ## Methods 20 | 21 | ### getHello() 22 | 23 | > **getHello**(): `string` 24 | 25 | #### Returns 26 | 27 | `string` 28 | 29 | #### Defined in 30 | 31 | app.service.ts:5 32 | -------------------------------------------------------------------------------- /docs/backend/src/main/README.md: -------------------------------------------------------------------------------- 1 | [**web08-booquiz v1.0.0**](../../../README.md) • **Docs** 2 | 3 | *** 4 | 5 | [web08-booquiz v1.0.0](../../../modules.md) / backend/src/main 6 | 7 | # backend/src/main 8 | -------------------------------------------------------------------------------- /docs/modules.md: -------------------------------------------------------------------------------- 1 | [**web08-booquiz v1.0.0**](README.md) • **Docs** 2 | 3 | *** 4 | 5 | # web08-booquiz v1.0.0 6 | 7 | ## Modules 8 | 9 | - [backend/src/app.controller](backend/src/app.controller/README.md) 10 | - [backend/src/app.module](backend/src/app.module/README.md) 11 | - [backend/src/app.service](backend/src/app.service/README.md) 12 | - [backend/src/main](backend/src/main/README.md) 13 | -------------------------------------------------------------------------------- /ecosystem.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: "my-app", 5 | script: "./dist/main.js", // 애플리케이션 시작 파일 6 | instances: "max", // 모든 CPU 코어를 사용할 경우 'max' 7 | exec_mode: "cluster", // 클러스터 모드로 실행 8 | }, 9 | ], 10 | }; 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@nestjs/mapped-types": "*" 4 | }, 5 | "name": "web08-booquiz", 6 | "private": true, 7 | "version": "1.0.0", 8 | "workspaces": [ 9 | "apps/*", 10 | "packages/*" 11 | ], 12 | "description": "300명 이상의 부스트캠퍼를 감당할 수 있는 퀴즈 플랫폼", 13 | "main": "index.js", 14 | "type": "module", 15 | "scripts": { 16 | "build": "pnpm run build -w apps/backend && pnpm run build -w apps/frontend", 17 | "dev": "pnpm run dev -w apps/backend & pnpm run dev -w apps/frontend", 18 | "docs:backend": "typedoc --tsconfig apps/backend/tsconfig.json --entryPointStrategy expand --out docs/backend", 19 | "docs:frontend": "typedoc --tsconfig apps/frontend/tsconfig.json --entryPointStrategy expand --out docs/frontend", 20 | "docs:shared": "typedoc --tsconfig packages/shared/tsconfig.json --entryPointStrategy expand --out docs/shared", 21 | "docs": "pnpm run docs:backend && pnpm run docs:frontend", 22 | "prepare": "husky", 23 | "preinstall": "npx only-allow pnpm", 24 | "start": "pnpm --stream -r start" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "git+https://github.com/boostcampwm-2024/web08-BooQuiz.git" 29 | }, 30 | "keywords": [ 31 | "real-time", 32 | "quiz", 33 | "game", 34 | "websocket" 35 | ], 36 | "author": "", 37 | "license": "ISC", 38 | "bugs": { 39 | "url": "https://github.com/boostcampwm-2024/web08-BooQuiz/issues" 40 | }, 41 | "homepage": "https://github.com/boostcampwm-2024/web08-BooQuiz#readme", 42 | "devDependencies": { 43 | "@commitlint/cli": "^19.5.0", 44 | "@commitlint/config-conventional": "^19.5.0", 45 | "husky": "^9.1.6", 46 | "prettier": "^3.3.3", 47 | "typedoc": "^0.26.11", 48 | "typescript": "^5.x.x" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@shared/utils", 3 | "version": "1.0.0", 4 | "main": "dist/index.js", 5 | "scripts": { 6 | "build": "tsc" 7 | }, 8 | "devDependencies": { 9 | "typescript": "^5.x.x" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/shared/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "include": ["src/**/*.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | # include packages in subfolders (e.g. apps/ and packages/) 3 | - "apps/**" 4 | - 'packages/**' 5 | # if required, exclude some directories 6 | - '!**/test/**' -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "CommonJS", 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "skipLibCheck": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "baseUrl": ".", 10 | "paths": { 11 | "@shared/*": ["packages/shared/src/*"] 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": [ 3 | "apps/backend/src", 4 | "apps/frontend/src", 5 | "packages/shared/src" 6 | ], 7 | "entryPointStrategy": "expand", 8 | "out": "docs", 9 | "includeVersion": true, 10 | "exclude": [ 11 | "**/*.spec.ts", 12 | "**/node_modules/**" 13 | ], 14 | "excludeExternals": true, 15 | "excludePrivate": true, 16 | "excludeProtected": true, 17 | "readme": "README.md" 18 | } 19 | --------------------------------------------------------------------------------