├── .artillery ├── data.csv ├── grade-test.json ├── grade-test.yaml ├── report_local_grade_function.json └── report_local_grade_function.json.html ├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── backend-ci.yml │ ├── deployment.yml │ └── frontend-ci.yml ├── .gitignore ├── README.md ├── backend ├── .env.vault ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── README.md ├── ecosystem.config.js ├── nest-cli.json ├── package-lock.json ├── package.json ├── src │ ├── app.controller.spec.ts │ ├── app.controller.ts │ ├── app.module.ts │ ├── app.service.ts │ ├── auth │ │ ├── auth.controller.spec.ts │ │ ├── auth.controller.ts │ │ ├── auth.module.ts │ │ ├── auth.service.spec.ts │ │ ├── auth.service.ts │ │ └── jwt.strategy.ts │ ├── commons │ │ └── entities │ │ │ └── baseTime.entity.ts │ ├── config │ │ └── typeorm.config.ts │ ├── main.ts │ ├── mock │ │ ├── common.mock.ts │ │ ├── problem.mock.ts │ │ ├── solved.mock.ts │ │ ├── test-case.mock.ts │ │ └── user.mock.ts │ ├── problem │ │ ├── dto │ │ │ ├── create-problem.dto.ts │ │ │ ├── findAllWithPaging.interface.ts │ │ │ ├── simple-problem.dto.ts │ │ │ └── update-problem.dto.ts │ │ ├── entities │ │ │ ├── ProblemLevel.enum.ts │ │ │ └── problem.entity.ts │ │ ├── problem.controller.spec.ts │ │ ├── problem.controller.ts │ │ ├── problem.module.ts │ │ ├── problem.service.spec.ts │ │ └── problem.service.ts │ ├── rank │ │ ├── dto │ │ │ └── simple-rank.dto.ts │ │ ├── rank.controller.spec.ts │ │ ├── rank.controller.ts │ │ ├── rank.module.ts │ │ ├── rank.service.spec.ts │ │ └── rank.service.ts │ ├── solved │ │ ├── dto │ │ │ ├── create-solved.dto.ts │ │ │ ├── findSolvedByOpt.interface.ts │ │ │ ├── grade-result-solved.dto.ts │ │ │ ├── grade-solved.dto.ts │ │ │ ├── simple-solved.dto.ts │ │ │ └── update-solved.dto.ts │ │ ├── entities │ │ │ ├── ProgrammingLanguage.enum.ts │ │ │ ├── SolvedResult.enum.ts │ │ │ └── solved.entity.ts │ │ ├── solved.controller.spec.ts │ │ ├── solved.controller.ts │ │ ├── solved.module.ts │ │ ├── solved.service.spec.ts │ │ └── solved.service.ts │ ├── test-case │ │ ├── dto │ │ │ ├── create-test-case.dto.ts │ │ │ ├── findTestCaseOption.interface.ts │ │ │ ├── simple-testCase.dto.ts │ │ │ └── update-test-case.dto.ts │ │ ├── entities │ │ │ └── test-case.entity.ts │ │ ├── test-case.controller.spec.ts │ │ ├── test-case.controller.ts │ │ ├── test-case.module.ts │ │ ├── test-case.service.spec.ts │ │ └── test-case.service.ts │ ├── typeorm │ │ ├── typeorm-ex.decorator.ts │ │ └── typeorm-ex.module.ts │ ├── users │ │ ├── dto │ │ │ ├── auth-user.dto.ts │ │ │ ├── create-user.dto.ts │ │ │ ├── find-option-user.interface.ts │ │ │ ├── simple-user.dto.ts │ │ │ └── update-user.dto.ts │ │ ├── entities │ │ │ └── user.entity.ts │ │ ├── users.controller.spec.ts │ │ ├── users.controller.ts │ │ ├── users.module.ts │ │ ├── users.service.spec.ts │ │ └── users.service.ts │ └── utils │ │ ├── boolUtils.ts │ │ └── utils.test.ts ├── test │ ├── app.e2e-spec.ts │ └── jest-e2e.json ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock ├── frontend ├── .env.vault ├── .eslintrc.cjs ├── .gitignore ├── .prettierrc.cjs ├── babel.config.json ├── custom.d.ts ├── index.html ├── jest.config.json ├── package-lock.json ├── package.json ├── public │ ├── robots.txt │ └── vite.svg ├── src │ ├── App.tsx │ ├── assets │ │ ├── Greater.svg │ │ ├── copy-icon.svg │ │ ├── hamburger.svg │ │ ├── icons │ │ │ ├── Delete.png │ │ │ ├── RedDelete.svg │ │ │ ├── Refresh_icon.svg │ │ │ ├── SelectButton.svg │ │ │ ├── SliderLeft.svg │ │ │ ├── SliderRight.svg │ │ │ └── index.ts │ │ ├── images │ │ │ ├── Banner1.png │ │ │ ├── Banner1.webp │ │ │ ├── Banner2.png │ │ │ ├── Banner2.webp │ │ │ ├── Banner3.png │ │ │ ├── Banner3.webp │ │ │ └── index.ts │ │ ├── react.svg │ │ └── user.svg │ ├── components │ │ ├── Footer.tsx │ │ ├── Home │ │ │ ├── Banner │ │ │ │ ├── Banner.tsx │ │ │ │ ├── BannerContent.tsx │ │ │ │ ├── BannerController.tsx │ │ │ │ ├── BannerImage.tsx │ │ │ │ └── BannerText.tsx │ │ │ ├── ProblemList │ │ │ │ ├── List.tsx │ │ │ │ └── Problem.tsx │ │ │ └── index.ts │ │ ├── MainHeader.tsx │ │ ├── Problem │ │ │ ├── Buttons │ │ │ │ ├── PageButtons.tsx │ │ │ │ ├── ProblemButtons.tsx │ │ │ │ └── index.ts │ │ │ ├── Content.tsx │ │ │ ├── InviteModal.tsx │ │ │ ├── LanguageSelector.tsx │ │ │ ├── Result.tsx │ │ │ ├── Video.tsx │ │ │ ├── _Editor.tsx │ │ │ └── index.tsx │ │ ├── ProblemHeader.tsx │ │ ├── ProblemList │ │ │ ├── List │ │ │ │ ├── List.tsx │ │ │ │ ├── PageController.tsx │ │ │ │ ├── Problem.tsx │ │ │ │ └── SearchBox.tsx │ │ │ ├── SearchFilter │ │ │ │ ├── Filter.tsx │ │ │ │ ├── Modal.tsx │ │ │ │ ├── Search.tsx │ │ │ │ └── SearchFilter.tsx │ │ │ └── index.ts │ │ ├── Ranking │ │ │ ├── MyInfo.tsx │ │ │ ├── PageController.tsx │ │ │ ├── RankContainer.tsx │ │ │ └── RankTable.tsx │ │ ├── SignIn │ │ │ └── InputForm.tsx │ │ └── SignUp │ │ │ └── InputForm.tsx │ ├── hooks │ │ ├── useInput.ts │ │ ├── useModal.tsx │ │ └── useUserState.tsx │ ├── main.tsx │ ├── pages │ │ ├── Home.tsx │ │ ├── Problem.tsx │ │ ├── ProblemList.tsx │ │ ├── Profile.tsx │ │ ├── Ranking.tsx │ │ ├── Sign.tsx │ │ ├── SignIn.tsx │ │ ├── SignUp.tsx │ │ └── index.ts │ ├── recoils │ │ ├── editorState.tsx │ │ ├── filterState.ts │ │ ├── gradingState.tsx │ │ ├── index.ts │ │ ├── socketState.tsx │ │ └── userState.tsx │ ├── styles │ │ ├── Footer.style.tsx │ │ ├── GlobalStyle.tsx │ │ ├── MainHeader.style.tsx │ │ ├── ProblemHeader.style.tsx │ │ ├── SignIn.style.tsx │ │ └── SignUp.style.tsx │ ├── types │ │ ├── banner.d.ts │ │ ├── filter.d.ts │ │ └── problem.d.ts │ ├── utils │ │ ├── BannerInfo.ts │ │ ├── FiltersInfo.ts │ │ ├── ProblemsDummy.ts │ │ ├── ReservedWords.ts │ │ ├── cookie.tsx │ │ ├── defaultCode.ts │ │ ├── editorColors.ts │ │ └── userUtil.tsx │ └── vite-env.d.ts ├── test │ ├── App.test.tsx │ ├── SearchFilter.test.tsx │ └── setup.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── yarn.lock ├── grading ├── .env.vault ├── .gitignore ├── ecosystem.config.js ├── nodemon.json ├── package-lock.json ├── package.json ├── src │ ├── app.ts │ ├── controllers │ │ ├── demo │ │ │ ├── Dockerfile │ │ │ └── test.py │ │ ├── grade.controller.ts │ │ └── python.txt │ └── routes │ │ └── grade.route.ts └── tsconfig.json └── socket ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── nodemon.json ├── package-lock.json ├── package.json ├── src └── app.ts ├── tsconfig.json └── yarn.lock /.artillery/grade-test.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "target": "http://localhost:3000/api", 4 | "phases": [ 5 | { 6 | "duration": 60, 7 | "arrivalRate": 1, 8 | "name": "Warm up" 9 | }, 10 | { 11 | "duration": 120, 12 | "arrivalRate": 50, 13 | "name": "More Hard" 14 | }, 15 | { 16 | "duration": 600, 17 | "arrivalRate": 100, 18 | "name": "Extreme" 19 | } 20 | ], 21 | "defaults": { 22 | "headers": { 23 | "User-Agent": "Artillery" 24 | } 25 | }, 26 | "payload": { 27 | "path": "./data.csv", 28 | "fields": [ 29 | "problemId", 30 | "loginId", 31 | "userCode", 32 | "language" 33 | ] 34 | } 35 | }, 36 | "scenarios": [ 37 | { 38 | "name": "send user code to express grade server", 39 | "flow": [ 40 | { 41 | "post": { 42 | "url": "/solved", 43 | "json": { 44 | "problemId": "{{problemId}}", 45 | "loginId": "{{loginId}}", 46 | "userCode": "{{userCode}}", 47 | "language": "{{language}}" 48 | } 49 | } 50 | } 51 | ] 52 | } 53 | ] 54 | } -------------------------------------------------------------------------------- /.artillery/grade-test.yaml: -------------------------------------------------------------------------------- 1 | config: 2 | target: "http://localhost:3000/api" 3 | phases: 4 | - duration: 60 5 | arrivalRate: 1 6 | name: Warm up 7 | # - duration: 120 8 | # arrivalRate: 5 9 | # name: More Hard 10 | # - duration: 600 11 | # arrivalRate: 100 12 | # name: Extreme 13 | payload: 14 | path: "./data.csv" 15 | fields: 16 | - "problemId" 17 | - "loginId" 18 | - "userCode" 19 | - "language" 20 | scenarios: 21 | # We define one scenario: 22 | - name: "grade user code" 23 | flow: 24 | - post: 25 | url: "/solved" 26 | json: 27 | problemId: "{{problemId}}" 28 | loginId: "{{loginId}}" 29 | userCode: "{{userCode}}" 30 | language: "{{language}}" -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/Web31-CamperRank/67b4613e06831acf6bd85a4cfe967e4277db5864/.github/ISSUE_TEMPLATE.md -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## PR 타이틀 2 | 3 | ### PR 생성날짜 4 | 5 | * YYYY/MM/DD 6 | 7 | ### 작업내용 8 | 9 | 10 | ### 주요 변경점 11 | 12 | 13 | ### 리뷰요청 14 | 15 | 16 | ### 추가 코멘트 17 | 18 | -------------------------------------------------------------------------------- /.github/workflows/backend-ci.yml: -------------------------------------------------------------------------------- 1 | name: CI CamperRank Backend Source Code 2 | 3 | on: 4 | push: 5 | branches: [ main, dev ] 6 | pull_request: 7 | branches: [ main, dev ] 8 | 9 | jobs: 10 | ci: 11 | name: ci backend source code 12 | strategy: 13 | matrix: 14 | os: [ ubuntu-latest, windows-latest, macos-latest ] 15 | node-version: [ 16.x ] 16 | 17 | runs-on: ${{ matrix.os }} 18 | 19 | steps: 20 | - name: checkout source code 21 | uses: actions/checkout@v3 22 | with: 23 | ref: dev 24 | 25 | - name: Use Node.js ${{ matrix.node-version }} 26 | uses: actions/setup-node@v3 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | cache: "npm" 30 | cache-dependency-path: "./backend/package-lock.json" 31 | - run: | 32 | cd backend 33 | npm install 34 | npm test 35 | -------------------------------------------------------------------------------- /.github/workflows/deployment.yml: -------------------------------------------------------------------------------- 1 | name: Deploy CamperRank Server 2 | 3 | on: 4 | push: 5 | branches: [ main, dev ] 6 | pull_request: 7 | branches: [ main, dev ] 8 | 9 | jobs: 10 | build: 11 | name: deploy CamperRank 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: checkout source code 16 | uses: actions/checkout@v3 17 | with: 18 | ref: dev 19 | 20 | - name: executing remote ssh commands using password 21 | uses: appleboy/ssh-action@v0.1.4 22 | with: 23 | host: ${{ secrets.HOST }} 24 | username: ${{ secrets.USERNAME }} 25 | password: ${{ secrets.PASSWORD }} 26 | port: ${{ secrets.PORT }} 27 | script: | 28 | cd /home/code/Web31-CamperRank 29 | git pull origin dev 30 | cd /home/code/Web31-CamperRank/frontend 31 | yarn install 32 | yarn build 33 | cd /home/code/Web31-CamperRank/backend 34 | npm install 35 | cd /home/code/Web31-CamperRank/grading 36 | npm install 37 | cd /home/code/Web31-CamperRank/socket 38 | npm install 39 | sudo nginx -s reload 40 | pm2 reload all 41 | pm2 list 42 | -------------------------------------------------------------------------------- /.github/workflows/frontend-ci.yml: -------------------------------------------------------------------------------- 1 | name: CI CamperRank Frontend Source Code 2 | 3 | on: 4 | push: 5 | branches: [ main, dev ] 6 | pull_request: 7 | branches: [ main, dev ] 8 | 9 | jobs: 10 | ci: 11 | name: ci frontend source code 12 | strategy: 13 | matrix: 14 | os: [ ubuntu-latest, windows-latest, macos-latest ] 15 | node-version: [ 16.x ] 16 | 17 | runs-on: ${{ matrix.os }} 18 | 19 | steps: 20 | - name: checkout source code 21 | uses: actions/checkout@v3 22 | with: 23 | ref: dev 24 | 25 | - name: Cache yarn dependencies 26 | uses: actions/cache@v3 27 | id: yarn-cache 28 | with: 29 | path: node_modules 30 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock')}} 31 | - run: | 32 | cd frontend 33 | yarn install 34 | yarn test 35 | -------------------------------------------------------------------------------- /backend/.env.vault: -------------------------------------------------------------------------------- 1 | ################################################################################# 2 | # # 3 | # This file uniquely identifies your project in dotenv-vault. # 4 | # You SHOULD commit this file to source control. # 5 | # # 6 | # Generated with 'npx dotenv-vault new' # 7 | # # 8 | # Learn more at https://dotenv.org/env-vault # 9 | # # 10 | ################################################################################# 11 | 12 | DOTENV_VAULT=vlt_1ec64160ee48d99c87b788776a15fc31b5f998a60b9ad63139efc9a81a7859ba -------------------------------------------------------------------------------- /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 | 'prettier/prettier': ['error', { endOfLine: 'auto' }], 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /backend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 6 | [circleci-url]: https://circleci.com/gh/nestjs/nest 7 | 8 |

A progressive Node.js framework for building efficient and scalable server-side applications.

9 |

10 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Coverage 15 | Discord 16 | Backers on Open Collective 17 | Sponsors on Open Collective 18 | Donate us 19 | Support us 20 | Follow us on Twitter 21 |

22 | 24 | 25 | ## Description 26 | 27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 28 | 29 | ## Installation 30 | 31 | ```bash 32 | $ npm install 33 | ``` 34 | 35 | ## Running the app 36 | 37 | ```bash 38 | # development 39 | $ npm run start 40 | 41 | # watch mode 42 | $ npm run start:dev 43 | 44 | # production mode 45 | $ npm run start:prod 46 | ``` 47 | 48 | ## Test 49 | 50 | ```bash 51 | # unit tests 52 | $ npm run test 53 | 54 | # e2e tests 55 | $ npm run test:e2e 56 | 57 | # test coverage 58 | $ npm run test:cov 59 | ``` 60 | 61 | ## Support 62 | 63 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 64 | 65 | ## Stay in touch 66 | 67 | - Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec) 68 | - Website - [https://nestjs.com](https://nestjs.com/) 69 | - Twitter - [@nestframework](https://twitter.com/nestframework) 70 | 71 | ## License 72 | 73 | Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE). 74 | -------------------------------------------------------------------------------- /backend/ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: 'nestjs-api-server', // pm2 name 5 | script: './dist/main.js', // // 앱 실행 스크립트 6 | instances: 3, // 클러스터 모드 사용 시 생성할 인스턴스 수 7 | exec_mode: 'cluster', // fork, cluster 모드 중 선택 8 | merge_logs: true, // 클러스터 모드 사용 시 각 클러스터에서 생성되는 로그를 한 파일로 합쳐준다. 9 | autorestart: true, // 프로세스 실패 시 자동으로 재시작할지 선택 10 | watch: false, // 파일이 변경되었을 때 재시작 할지 선택 11 | // max_memory_restart: "512M", // 프로그램의 메모리 크기가 일정 크기 이상이 되면 재시작한다. 12 | env: { 13 | // 개발 환경설정 14 | NODE_ENV: 'development', 15 | }, 16 | env_production: { 17 | // 운영 환경설정 (--env production 옵션으로 지정할 수 있다.) 18 | NODE_ENV: 'production', 19 | }, 20 | }, 21 | ], 22 | }; 23 | -------------------------------------------------------------------------------- /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 | "assets": [ 8 | { 9 | "include": "./config/env/*.env" 10 | } 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nest-typescript-starter", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "Nest TypeScript starter repository", 6 | "license": "MIT", 7 | "scripts": { 8 | "build": "nest build", 9 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 10 | "start": "cross-env NODE_ENV=production nest start", 11 | "start:dev": "cross-env NODE_ENV=development nest start --watch", 12 | "start:debug": "cross-env NODE_ENV=development nest start --debug --watch", 13 | "start:prod": "pm2 start dist/main", 14 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 15 | "test": "jest", 16 | "test:watch": "jest --watch", 17 | "test:cov": "jest --coverage", 18 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 19 | "test:e2e": "jest --config ./test/jest-e2e.json" 20 | }, 21 | "dependencies": { 22 | "@nestjs/axios": "^1.0.0", 23 | "@nestjs/bull": "^0.6.2", 24 | "@nestjs/common": "^9.0.0", 25 | "@nestjs/config": "^2.2.0", 26 | "@nestjs/core": "^9.0.0", 27 | "@nestjs/jwt": "^9.0.0", 28 | "@nestjs/mapped-types": "*", 29 | "@nestjs/passport": "^9.0.0", 30 | "@nestjs/platform-express": "^9.0.0", 31 | "@nestjs/swagger": "^6.1.3", 32 | "@nestjs/typeorm": "^9.0.1", 33 | "@types/bcrypt": "^5.0.0", 34 | "@types/passport-jwt": "^3.0.7", 35 | "axios": "^1.1.0", 36 | "bcrypt": "^5.1.0", 37 | "bull": "^4.10.2", 38 | "class-transformer": "^0.5.1", 39 | "class-validator": "^0.13.2", 40 | "cross-env": "^7.0.3", 41 | "mysql": "^2.18.1", 42 | "mysql2": "^2.3.3", 43 | "passport": "^0.6.0", 44 | "passport-jwt": "^4.0.0", 45 | "reflect-metadata": "^0.1.13", 46 | "rxjs": "^7.5.5", 47 | "swagger-ui-express": "^4.6.0", 48 | "typeorm": "^0.3.10" 49 | }, 50 | "devDependencies": { 51 | "@nestjs/cli": "^9.0.0", 52 | "@nestjs/schematics": "^9.0.0", 53 | "@nestjs/testing": "^9.0.0", 54 | "@types/bull": "^4.10.0", 55 | "@types/express": "^4.17.13", 56 | "@types/jest": "^28.1.4", 57 | "@types/node": "^18.0.3", 58 | "@types/supertest": "^2.0.12", 59 | "@typescript-eslint/eslint-plugin": "^5.30.5", 60 | "@typescript-eslint/parser": "^5.30.5", 61 | "eslint": "^8.19.0", 62 | "eslint-config-prettier": "^8.5.0", 63 | "eslint-plugin-prettier": "^4.2.1", 64 | "jest": "^28.1.2", 65 | "prettier": "^2.7.1", 66 | "source-map-support": "^0.5.21", 67 | "supertest": "^6.2.4", 68 | "ts-jest": "^28.0.5", 69 | "ts-loader": "^9.3.1", 70 | "ts-node": "^10.8.2", 71 | "tsconfig-paths": "^4.0.0", 72 | "typescript": "^4.7.4" 73 | }, 74 | "jest": { 75 | "moduleFileExtensions": [ 76 | "js", 77 | "json", 78 | "ts" 79 | ], 80 | "rootDir": "src", 81 | "testRegex": ".*\\.spec\\.ts$", 82 | "transform": { 83 | "^.+\\.(t|j)s$": "ts-jest" 84 | }, 85 | "collectCoverageFrom": [ 86 | "**/*.(t|j)s" 87 | ], 88 | "coverageDirectory": "../coverage", 89 | "testEnvironment": "node" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /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 | import { UsersService } from './users/users.service'; 5 | import { ProblemService } from './problem/problem.service'; 6 | import { SolvedService } from './solved/solved.service'; 7 | import { TestCaseService } from './test-case/test-case.service'; 8 | import { getRepositoryToken } from '@nestjs/typeorm'; 9 | import { MockUserRepository } from './mock/user.mock'; 10 | import { User } from './users/entities/user.entity'; 11 | import { Solved } from './solved/entities/solved.entity'; 12 | import { Problem } from './problem/entities/problem.entity'; 13 | import { TestCase } from './test-case/entities/test-case.entity'; 14 | 15 | describe('AppController', () => { 16 | let app: TestingModule; 17 | 18 | beforeAll(async () => { 19 | app = await Test.createTestingModule({ 20 | controllers: [AppController], 21 | providers: [ 22 | AppService, 23 | UsersService, 24 | ProblemService, 25 | SolvedService, 26 | TestCaseService, 27 | { provide: getRepositoryToken(User), useValue: MockUserRepository() }, 28 | { provide: getRepositoryToken(Solved), useValue: MockUserRepository() }, 29 | { 30 | provide: getRepositoryToken(Problem), 31 | useValue: MockUserRepository(), 32 | }, 33 | { 34 | provide: getRepositoryToken(TestCase), 35 | useValue: MockUserRepository(), 36 | }, 37 | ], 38 | }).compile(); 39 | }); 40 | 41 | describe('getHello', () => { 42 | it('should return "Hello World!"', () => { 43 | const appController = app.get(AppController); 44 | expect(appController.getHello()).toBe('Hello World!'); 45 | expect(app).toBeDefined(); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /backend/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { UsersModule } from './users/users.module'; 6 | import { ProblemModule } from './problem/problem.module'; 7 | import { typeormConfig } from './config/typeorm.config'; 8 | import { TestCaseModule } from './test-case/test-case.module'; 9 | import { SolvedModule } from './solved/solved.module'; 10 | import { AuthModule } from './auth/auth.module'; 11 | import { ConfigModule, ConfigService } from '@nestjs/config'; 12 | import { User } from './users/entities/user.entity'; 13 | import { TestCase } from './test-case/entities/test-case.entity'; 14 | import { Solved } from './solved/entities/solved.entity'; 15 | import { Problem } from './problem/entities/problem.entity'; 16 | import { RankModule } from './rank/rank.module'; 17 | import * as process from 'process'; 18 | 19 | @Module({ 20 | imports: [ 21 | ConfigModule.forRoot({ 22 | // configuration 설정을 coifg module 불러 올 때 로드한다 23 | isGlobal: true, 24 | load: [typeormConfig], 25 | envFilePath: 26 | process.env.NODE_ENV === 'development' ? `.env` : '.env.production', 27 | }), 28 | TypeOrmModule.forRootAsync({ 29 | imports: [ConfigModule], 30 | inject: [ConfigService], 31 | useFactory: (configService: ConfigService) => ({ 32 | type: 'mysql', 33 | host: configService.get('database.host'), 34 | port: configService.get('database.port'), 35 | username: configService.get('database.username'), 36 | password: configService.get('database.password'), 37 | database: configService.get('database.database'), 38 | entities: [User, TestCase, Solved, Problem], 39 | dropSchema: false, 40 | synchronize: false, 41 | logging: false, 42 | }), 43 | }), 44 | 45 | UsersModule, 46 | ProblemModule, 47 | TestCaseModule, 48 | SolvedModule, 49 | AuthModule, 50 | RankModule, 51 | ], 52 | controllers: [AppController], 53 | providers: [AppService], 54 | }) 55 | export class AppModule {} 56 | -------------------------------------------------------------------------------- /backend/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | getHello(): string { 6 | return 'Hello World!'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /backend/src/auth/auth.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthController } from './auth.controller'; 3 | import { AuthService } from './auth.service'; 4 | import { JwtService } from '@nestjs/jwt'; 5 | import { PassportModule } from '@nestjs/passport'; 6 | import { getRepositoryToken } from '@nestjs/typeorm'; 7 | import { MockUserRepository } from '../mock/user.mock'; 8 | import { User } from '../users/entities/user.entity'; 9 | 10 | describe('AuthController', () => { 11 | let authController: AuthController; 12 | let authService: AuthService; 13 | 14 | beforeEach(async () => { 15 | const module: TestingModule = await Test.createTestingModule({ 16 | imports: [PassportModule.register({ defaultStrategy: 'jwt' })], 17 | controllers: [AuthController], 18 | providers: [ 19 | AuthService, 20 | { provide: getRepositoryToken(User), useValue: MockUserRepository() }, 21 | JwtService, 22 | ], 23 | }).compile(); 24 | 25 | authController = module.get(AuthController); 26 | authService = module.get(AuthService); 27 | }); 28 | 29 | it('should be defined', () => { 30 | expect(authService).toBeDefined(); 31 | expect(authController).toBeDefined(); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /backend/src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | Param, 7 | Patch, 8 | Post, 9 | UseGuards, 10 | Req, 11 | } from '@nestjs/common'; 12 | import { AuthService } from './auth.service'; 13 | import { AuthUserDto } from '../users/dto/auth-user.dto'; 14 | import { AuthGuard } from '@nestjs/passport'; 15 | 16 | @Controller('auth') 17 | export class AuthController { 18 | constructor(private readonly authService: AuthService) {} 19 | 20 | @Post('/signin') 21 | signIn(@Body() authUserDto: AuthUserDto) { 22 | return this.authService.login(authUserDto); 23 | } 24 | 25 | // jwt 인증을 위한 useGuards + AuthGuard .. passport 활용 26 | // 토큰이 없거나 일치하지 않으면 401 27 | @Post('/jwtLogin') 28 | @UseGuards(AuthGuard()) 29 | authTest(@Req() req) { 30 | const userId = req.user.loginId; 31 | return { userId: userId }; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /backend/src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AuthService } from './auth.service'; 3 | import { AuthController } from './auth.controller'; 4 | import { JwtModule } from '@nestjs/jwt'; 5 | import { PassportModule } from '@nestjs/passport'; 6 | import { JwtStrategy } from './jwt.strategy'; 7 | import { TypeOrmModule } from '@nestjs/typeorm'; 8 | import { User } from '../users/entities/user.entity'; 9 | 10 | @Module({ 11 | controllers: [AuthController], 12 | providers: [AuthService, JwtStrategy], 13 | imports: [ 14 | TypeOrmModule.forFeature([User]), 15 | PassportModule.register({ defaultStrategy: 'jwt' }), 16 | JwtModule.register({ 17 | secret: 'Secret1234', 18 | signOptions: { 19 | expiresIn: 86400, 20 | }, 21 | }), 22 | ], 23 | 24 | // 다른곳에서도 jwt 인증을 사용하기 위해 export 25 | exports: [JwtStrategy, PassportModule], 26 | }) 27 | export class AuthModule {} 28 | -------------------------------------------------------------------------------- /backend/src/auth/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthService } from './auth.service'; 3 | import { JwtService } from '@nestjs/jwt'; 4 | import { getRepositoryToken } from '@nestjs/typeorm'; 5 | import { MockUserRepository } from '../mock/user.mock'; 6 | import { User } from '../users/entities/user.entity'; 7 | import { MockRepository } from '../mock/common.mock'; 8 | 9 | describe('AuthService', () => { 10 | let authService: AuthService; 11 | let userRepository: MockRepository; 12 | 13 | beforeEach(async () => { 14 | const module: TestingModule = await Test.createTestingModule({ 15 | providers: [ 16 | AuthService, 17 | { provide: getRepositoryToken(User), useValue: MockUserRepository() }, 18 | JwtService, 19 | ], 20 | }).compile(); 21 | 22 | authService = module.get(AuthService); 23 | userRepository = module.get>(getRepositoryToken(User)); 24 | }); 25 | 26 | it('should be defined', () => { 27 | expect(userRepository).toBeDefined(); 28 | expect(authService).toBeDefined(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /backend/src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthUserDto } from '../users/dto/auth-user.dto'; 3 | import * as bcrypt from 'bcrypt'; 4 | import { JwtService } from '@nestjs/jwt'; 5 | import { Repository } from 'typeorm'; 6 | import { User } from '../users/entities/user.entity'; 7 | import { InjectRepository } from '@nestjs/typeorm'; 8 | 9 | @Injectable() 10 | export class AuthService { 11 | constructor( 12 | @InjectRepository(User) 13 | private userRepository: Repository, 14 | private jwtService: JwtService, 15 | ) {} 16 | 17 | async login(authUserDto: AuthUserDto) { 18 | const { loginId, password } = authUserDto; 19 | const user = await this.userRepository.findOneBy({ loginId: loginId }); 20 | 21 | if (user && (await bcrypt.compare(password, user.password))) { 22 | const payload = { loginId }; 23 | const accessToken = this.jwtService.sign(payload); 24 | 25 | return { 26 | userId: loginId, 27 | accessToken, 28 | effectiveTime: 86400000, 29 | msg: 'success', 30 | }; 31 | } 32 | return { 33 | msg: 'fail', 34 | }; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /backend/src/auth/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { ExtractJwt, Strategy } from 'passport-jwt'; 4 | import { User } from '../users/entities/user.entity'; 5 | import { InjectRepository } from '@nestjs/typeorm'; 6 | import { Repository } from 'typeorm'; 7 | 8 | @Injectable() 9 | export class JwtStrategy extends PassportStrategy(Strategy) { 10 | constructor( 11 | @InjectRepository(User) private userRepository: Repository, 12 | ) { 13 | super({ 14 | secretOrKey: process.env.JWT_SECRETKEY, 15 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 16 | }); 17 | } 18 | 19 | async validate(payload) { 20 | const { username } = payload; 21 | const user: User = await this.userRepository.findOneBy({ 22 | loginId: username, 23 | }); 24 | 25 | if (!user) { 26 | throw new UnauthorizedException(); 27 | } 28 | 29 | return user; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /backend/src/commons/entities/baseTime.entity.ts: -------------------------------------------------------------------------------- 1 | import { CreateDateColumn, UpdateDateColumn } from 'typeorm'; 2 | 3 | export abstract class BaseTimeEntity { 4 | @CreateDateColumn({ name: 'create_at', comment: '생성일' }) 5 | createdAt: Date; 6 | 7 | @UpdateDateColumn({ name: 'update_at', comment: '수정일' }) 8 | updatedAt: Date; 9 | } 10 | -------------------------------------------------------------------------------- /backend/src/config/typeorm.config.ts: -------------------------------------------------------------------------------- 1 | // export const typeormConfig: TypeOrmModuleOptions = { 2 | // type: 'mysql', 3 | // host: 'localhost', 4 | // port: 3306, 5 | // username: 'root', // MySQL ID 6 | // password: 'root', // MySQL password 7 | // database: 'camperRank', 8 | // entities: [User, Problem, TestCase, Solved], 9 | // synchronize: false, // synchronize 옵션을 true로 하면 서비스가 실행되고 데이터베이스가 연결될 때 항상 데이터베이스가 초기화 되므로 절대 프로덕션에는 false로 설정 10 | // }; 11 | 12 | export const typeormConfig = () => ({ 13 | database: { 14 | host: process.env.MYSQL_HOST || '', 15 | port: process.env.MYSQL_PORT || '', 16 | username: process.env.MYSQL_USERNAME || '', 17 | password: process.env.MYSQL_PASSWORD || '', 18 | database: process.env.MYSQL_DATABASE || '', 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /backend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 4 | 5 | async function bootstrap() { 6 | const app = await NestFactory.create(AppModule); 7 | app.enableCors(); 8 | app.setGlobalPrefix('api'); 9 | const swaggerDocumentBuilder = new DocumentBuilder() 10 | .setTitle('CamperRank APIs') 11 | .setDescription('CamperRank API 관련 문서입니다.') 12 | .setVersion('1.0') 13 | .build(); 14 | const swaggerDocument = SwaggerModule.createDocument( 15 | app, 16 | swaggerDocumentBuilder, 17 | ); 18 | SwaggerModule.setup('swagger', app, swaggerDocument); 19 | 20 | await app.listen(3000); 21 | } 22 | 23 | bootstrap(); 24 | -------------------------------------------------------------------------------- /backend/src/mock/common.mock.ts: -------------------------------------------------------------------------------- 1 | import { Repository } from 'typeorm'; 2 | 3 | export type MockRepository = Partial< 4 | Record, jest.Mock> 5 | >; 6 | -------------------------------------------------------------------------------- /backend/src/mock/problem.mock.ts: -------------------------------------------------------------------------------- 1 | export const MockProblemRepository = () => ({ 2 | save: jest.fn(), 3 | createQueryBuilder: jest.fn(), 4 | findOneBy: jest.fn(), 5 | remove: jest.fn(), 6 | }); 7 | -------------------------------------------------------------------------------- /backend/src/mock/solved.mock.ts: -------------------------------------------------------------------------------- 1 | export const MockSolvedRepository = () => ({ 2 | save: jest.fn(), 3 | createQueryBuilder: jest.fn(), 4 | find: jest.fn(), 5 | findOneBy: jest.fn(), 6 | remove: jest.fn(), 7 | }); 8 | -------------------------------------------------------------------------------- /backend/src/mock/test-case.mock.ts: -------------------------------------------------------------------------------- 1 | export const MockTestCaseRepository = () => ({ 2 | save: jest.fn(), 3 | find: jest.fn(), 4 | findOneBy: jest.fn(), 5 | remove: jest.fn(), 6 | }); 7 | -------------------------------------------------------------------------------- /backend/src/mock/user.mock.ts: -------------------------------------------------------------------------------- 1 | export const MockUserRepository = () => ({ 2 | save: jest.fn(), 3 | findOneBy: jest.fn(), 4 | }); 5 | -------------------------------------------------------------------------------- /backend/src/problem/dto/create-problem.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNotEmpty } from 'class-validator'; 3 | 4 | export class CreateProblemDto { 5 | @ApiProperty({ description: '문제 제목' }) 6 | @IsNotEmpty() 7 | title: string; 8 | 9 | @ApiProperty({ description: '문제 난이도' }) 10 | @IsNotEmpty() 11 | level: number; 12 | 13 | @ApiProperty({ description: '문제 설명 및 제한 사항 등등..' }) 14 | @IsNotEmpty() 15 | description: string; 16 | 17 | constructor(title, level, description) { 18 | this.title = title; 19 | this.level = level; 20 | this.description = description; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /backend/src/problem/dto/findAllWithPaging.interface.ts: -------------------------------------------------------------------------------- 1 | export interface IFindProblemOptions { 2 | loginId?: string; 3 | isRandom?: boolean; 4 | skip?: number; 5 | take?: number; 6 | } 7 | -------------------------------------------------------------------------------- /backend/src/problem/dto/simple-problem.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class SimpleProblemDto { 4 | @ApiProperty({ description: '문제 식별 아이디' }) 5 | problemId: number; 6 | 7 | @ApiProperty({ description: '문제 제목' }) 8 | title: string; 9 | 10 | @ApiProperty({ description: '문제 난이도' }) 11 | level: number; 12 | 13 | @ApiProperty({ description: '문제 설명 및 제한 사항 등등..' }) 14 | description: string; 15 | 16 | @ApiProperty({ description: '해결한 문제인지 확인' }) 17 | isSolved: boolean; 18 | 19 | @ApiProperty({ description: '문제 생성일' }) 20 | createdAt: Date; 21 | 22 | @ApiProperty({ description: '문제 수정일' }) 23 | updatedAt: Date; 24 | 25 | constructor(problem: any) { 26 | this.problemId = problem.id; 27 | this.title = problem.title; 28 | this.level = problem.level; 29 | this.description = problem.description; 30 | this.createdAt = problem.createdAt || problem.create_at; 31 | this.updatedAt = problem.updatedAt || problem.update_at; 32 | 33 | this.isSolved = problem?.isSolved === '1'; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /backend/src/problem/dto/update-problem.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/swagger'; 2 | import { CreateProblemDto } from './create-problem.dto'; 3 | 4 | export class UpdateProblemDto extends PartialType(CreateProblemDto) {} 5 | -------------------------------------------------------------------------------- /backend/src/problem/entities/ProblemLevel.enum.ts: -------------------------------------------------------------------------------- 1 | export enum ProblemLevel { 2 | Level1 = 1, 3 | Level2 = 2, 4 | Level3 = 3, 5 | } 6 | -------------------------------------------------------------------------------- /backend/src/problem/entities/problem.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 2 | import { BaseTimeEntity } from '../../commons/entities/baseTime.entity'; 3 | import { TestCase } from '../../test-case/entities/test-case.entity'; 4 | import { Solved } from '../../solved/entities/solved.entity'; 5 | 6 | @Entity() 7 | export class Problem extends BaseTimeEntity { 8 | @PrimaryGeneratedColumn() 9 | id: number; 10 | 11 | @Column({ 12 | nullable: false, 13 | }) 14 | title: string; 15 | 16 | @Column({ nullable: false }) 17 | level: number; 18 | 19 | @Column({ 20 | nullable: false, 21 | type: 'text', 22 | }) 23 | description: string; 24 | 25 | testcaseList: TestCase[]; 26 | 27 | solvedList: Solved[]; 28 | 29 | public static createProblem({ title, level, description }) { 30 | const problem = new Problem(); 31 | problem.title = title; 32 | problem.level = level; 33 | problem.description = description; 34 | return problem; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /backend/src/problem/problem.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ProblemController } from './problem.controller'; 3 | import { ProblemService } from './problem.service'; 4 | import { getRepositoryToken } from '@nestjs/typeorm'; 5 | import { Problem } from './entities/problem.entity'; 6 | import { MockProblemRepository } from '../mock/problem.mock'; 7 | import { Solved } from '../solved/entities/solved.entity'; 8 | import { User } from '../users/entities/user.entity'; 9 | 10 | describe('ProblemController', () => { 11 | let problemController: ProblemController; 12 | let problemService: ProblemService; 13 | 14 | beforeEach(async () => { 15 | const module: TestingModule = await Test.createTestingModule({ 16 | controllers: [ProblemController], 17 | providers: [ 18 | ProblemService, 19 | { 20 | provide: getRepositoryToken(Problem), 21 | useValue: MockProblemRepository(), 22 | }, 23 | { 24 | provide: getRepositoryToken(Solved), 25 | useValue: MockProblemRepository(), 26 | }, 27 | { 28 | provide: getRepositoryToken(User), 29 | useValue: MockProblemRepository(), 30 | }, 31 | ], 32 | }).compile(); 33 | 34 | problemController = module.get(ProblemController); 35 | problemService = module.get(ProblemService); 36 | }); 37 | 38 | it('should be defined', () => { 39 | expect(problemService).toBeDefined(); 40 | expect(problemController).toBeDefined(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /backend/src/problem/problem.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ProblemService } from './problem.service'; 3 | import { ProblemController } from './problem.controller'; 4 | import { Solved } from '../solved/entities/solved.entity'; 5 | import { User } from '../users/entities/user.entity'; 6 | import { Problem } from './entities/problem.entity'; 7 | import { TypeOrmModule } from '@nestjs/typeorm'; 8 | 9 | @Module({ 10 | imports: [TypeOrmModule.forFeature([Problem, Solved, User])], 11 | controllers: [ProblemController], 12 | providers: [ProblemService], 13 | exports: [ProblemService], 14 | }) 15 | export class ProblemModule {} 16 | -------------------------------------------------------------------------------- /backend/src/problem/problem.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ProblemService } from './problem.service'; 3 | import { MockRepository } from '../mock/common.mock'; 4 | import { Problem } from './entities/problem.entity'; 5 | import { User } from '../users/entities/user.entity'; 6 | import { Solved } from '../solved/entities/solved.entity'; 7 | import { getRepositoryToken } from '@nestjs/typeorm'; 8 | import { MockProblemRepository } from '../mock/problem.mock'; 9 | import { MockSolvedRepository } from '../mock/solved.mock'; 10 | import { MockUserRepository } from '../mock/user.mock'; 11 | 12 | describe('ProblemService', () => { 13 | let problemService: ProblemService; 14 | let problemRepository: MockRepository; 15 | let solvedRepository: MockRepository; 16 | let userRepository: MockRepository; 17 | 18 | beforeEach(async () => { 19 | const module: TestingModule = await Test.createTestingModule({ 20 | providers: [ 21 | ProblemService, 22 | { 23 | provide: getRepositoryToken(Problem), 24 | useValue: MockProblemRepository(), 25 | }, 26 | { 27 | provide: getRepositoryToken(Solved), 28 | useValue: MockSolvedRepository(), 29 | }, 30 | { 31 | provide: getRepositoryToken(User), 32 | useValue: MockUserRepository(), 33 | }, 34 | ], 35 | }).compile(); 36 | 37 | problemService = module.get(ProblemService); 38 | problemRepository = module.get>( 39 | getRepositoryToken(Problem), 40 | ); 41 | solvedRepository = module.get>( 42 | getRepositoryToken(Solved), 43 | ); 44 | userRepository = module.get>(getRepositoryToken(User)); 45 | }); 46 | 47 | it('should be defined', () => { 48 | expect(userRepository).toBeDefined(); 49 | expect(solvedRepository).toBeDefined(); 50 | expect(problemRepository).toBeDefined(); 51 | expect(problemService).toBeDefined(); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /backend/src/rank/dto/simple-rank.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class SimpleRankDto { 4 | @ApiProperty({ description: '사용자 식별 아이디' }) 5 | userId: number; 6 | 7 | @ApiProperty({ description: '사용자 로그인 아이디' }) 8 | loginId: string; 9 | 10 | @ApiProperty({ description: '해결한 문제 수량' }) 11 | solvedCount: number; 12 | 13 | constructor(userId, loginId, solvedCount) { 14 | this.userId = userId; 15 | this.loginId = loginId; 16 | this.solvedCount = solvedCount; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /backend/src/rank/rank.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { RankController } from './rank.controller'; 3 | import { RankService } from './rank.service'; 4 | import { getRepositoryToken } from '@nestjs/typeorm'; 5 | import { MockUserRepository } from '../mock/user.mock'; 6 | import { MockSolvedRepository } from '../mock/solved.mock'; 7 | import { Solved } from '../solved/entities/solved.entity'; 8 | import { User } from '../users/entities/user.entity'; 9 | 10 | describe('RankController', () => { 11 | let rankController: RankController; 12 | let rankService: RankService; 13 | 14 | beforeEach(async () => { 15 | const module: TestingModule = await Test.createTestingModule({ 16 | controllers: [RankController], 17 | providers: [ 18 | RankService, 19 | { 20 | provide: getRepositoryToken(User), 21 | useValue: MockUserRepository(), 22 | }, 23 | { 24 | provide: getRepositoryToken(Solved), 25 | useValue: MockSolvedRepository(), 26 | }, 27 | ], 28 | }).compile(); 29 | 30 | rankController = module.get(RankController); 31 | rankService = module.get(RankService); 32 | }); 33 | 34 | it('should be defined', () => { 35 | expect(rankService).toBeDefined(); 36 | expect(rankController).toBeDefined(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /backend/src/rank/rank.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | DefaultValuePipe, 4 | Get, 5 | HttpCode, 6 | HttpStatus, 7 | Query, 8 | } from '@nestjs/common'; 9 | import { RankService } from './rank.service'; 10 | import { ApiOperation, ApiResponse } from '@nestjs/swagger'; 11 | import { SimpleRankDto } from './dto/simple-rank.dto'; 12 | 13 | @Controller('rank') 14 | export class RankController { 15 | constructor(private readonly rankService: RankService) {} 16 | 17 | @Get() 18 | @HttpCode(HttpStatus.OK) 19 | @ApiOperation({ 20 | summary: '유저 랭킹 API', 21 | description: '유저들을 랭킹 순으로 반환한다.', 22 | }) 23 | @ApiResponse({ 24 | description: 25 | 'skip, take 를 받아서 페이징을 이용하여 받을 수 있고, 없다면 전체 사용자를 응답으로 보내준다.', 26 | status: HttpStatus.OK, 27 | type: SimpleRankDto, 28 | }) 29 | async getUserRank( 30 | @Query('skip', new DefaultValuePipe(0)) 31 | skip: number, 32 | @Query('take', new DefaultValuePipe(1000)) 33 | take: number, 34 | ) { 35 | const simpleRankDtoList = await this.rankService.getUserRanksV2(skip, take); 36 | 37 | return { ...simpleRankDtoList }; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /backend/src/rank/rank.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { RankController } from './rank.controller'; 3 | import { RankService } from './rank.service'; 4 | import { Solved } from '../solved/entities/solved.entity'; 5 | import { User } from '../users/entities/user.entity'; 6 | import { TypeOrmModule } from '@nestjs/typeorm'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([User, Solved])], 10 | controllers: [RankController], 11 | providers: [RankService], 12 | }) 13 | export class RankModule {} 14 | -------------------------------------------------------------------------------- /backend/src/rank/rank.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { RankService } from './rank.service'; 3 | import { MockRepository } from '../mock/common.mock'; 4 | import { User } from '../users/entities/user.entity'; 5 | import { Solved } from '../solved/entities/solved.entity'; 6 | import { getRepositoryToken } from '@nestjs/typeorm'; 7 | import { MockUserRepository } from '../mock/user.mock'; 8 | import { MockSolvedRepository } from '../mock/solved.mock'; 9 | 10 | describe('RankService', () => { 11 | let rankService: RankService; 12 | let userRepository: MockRepository; 13 | let solvedRepository: MockRepository; 14 | 15 | beforeEach(async () => { 16 | const module: TestingModule = await Test.createTestingModule({ 17 | providers: [ 18 | RankService, 19 | { 20 | provide: getRepositoryToken(User), 21 | useValue: MockUserRepository(), 22 | }, 23 | { 24 | provide: getRepositoryToken(Solved), 25 | useValue: MockSolvedRepository(), 26 | }, 27 | ], 28 | }).compile(); 29 | 30 | rankService = module.get(RankService); 31 | userRepository = module.get>(getRepositoryToken(User)); 32 | solvedRepository = module.get>( 33 | getRepositoryToken(Solved), 34 | ); 35 | }); 36 | 37 | it('should be defined', () => { 38 | expect(solvedRepository).toBeDefined(); 39 | expect(userRepository).toBeDefined(); 40 | expect(rankService).toBeDefined(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /backend/src/rank/rank.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { SimpleRankDto } from './dto/simple-rank.dto'; 3 | import { InjectRepository } from '@nestjs/typeorm'; 4 | import { Solved } from '../solved/entities/solved.entity'; 5 | import { Repository } from 'typeorm'; 6 | import { User } from '../users/entities/user.entity'; 7 | 8 | @Injectable() 9 | export class RankService { 10 | constructor( 11 | @InjectRepository(User) 12 | private readonly userRepository: Repository, 13 | @InjectRepository(Solved) 14 | private readonly solvedRepository: Repository, 15 | ) {} 16 | 17 | async getUserRanksV1(skip: number, take: number) { 18 | const solvedList = await this.solvedRepository 19 | .createQueryBuilder('solved') 20 | .select('solved.user.id as userId') 21 | .addSelect('COUNT(DISTINCT(solved.problem.id)) AS solvedCount') 22 | .groupBy('solved.user.id') 23 | .skip(skip && take ? skip : undefined) 24 | .take(skip && take ? skip : undefined) 25 | .getRawMany(); 26 | 27 | return solvedList.map((value) => { 28 | return new SimpleRankDto(value.userId, null, value.solvedCount); 29 | }); 30 | } 31 | 32 | async getUserRanksV2(skip: number, take: number) { 33 | const solvedList = await this.solvedRepository 34 | .createQueryBuilder('solved') 35 | .leftJoinAndSelect('solved.user', 'user') 36 | .select('user.id as userId') 37 | .addSelect('user.login_id as loginId') 38 | .addSelect('COUNT(DISTINCT(solved.problem.id)) AS solvedCount') 39 | .groupBy('solved.user.id') 40 | .skip(skip && take ? skip : undefined) 41 | .take(skip && take ? skip : undefined) 42 | .getRawMany(); 43 | 44 | return solvedList.map((value) => { 45 | return new SimpleRankDto(value.userId, value.loginId, value.solvedCount); 46 | }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /backend/src/solved/dto/create-solved.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { ProgrammingLanguage } from '../entities/ProgrammingLanguage.enum'; 3 | import { IsNotEmpty } from 'class-validator'; 4 | 5 | export class CreateSolvedDto { 6 | @ApiProperty({ description: '문제 식별 아이디' }) 7 | @IsNotEmpty() 8 | problemId: number; 9 | 10 | @ApiProperty({ description: '사용자 로그인 아이디' }) 11 | @IsNotEmpty() 12 | loginId: string; 13 | 14 | @ApiProperty({ description: '사용자 제출 코드' }) 15 | @IsNotEmpty() 16 | userCode: string; 17 | 18 | @ApiProperty({ description: '제출 코드 언어' }) 19 | @IsNotEmpty() 20 | language: ProgrammingLanguage; 21 | 22 | constructor(problemId, loginId, userCode, language) { 23 | this.problemId = problemId; 24 | this.loginId = loginId; 25 | this.userCode = userCode; 26 | this.language = language; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /backend/src/solved/dto/findSolvedByOpt.interface.ts: -------------------------------------------------------------------------------- 1 | export interface IFindSolvedByOpt { 2 | problemId?: number; 3 | loginId?: string; 4 | skip?: number; 5 | take?: number; 6 | } 7 | -------------------------------------------------------------------------------- /backend/src/solved/dto/grade-result-solved.dto.ts: -------------------------------------------------------------------------------- 1 | export class GradeResultSolvedDto { 2 | testCaseNumber: number; 3 | resultCode: number; 4 | 5 | constructor(solvedResult) { 6 | this.testCaseNumber = solvedResult.testCaseNumber; 7 | this.resultCode = solvedResult.resultCode; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /backend/src/solved/dto/grade-solved.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { ProgrammingLanguage } from '../entities/ProgrammingLanguage.enum'; 3 | import { Solved } from '../entities/solved.entity'; 4 | import { TestCase } from '../../test-case/entities/test-case.entity'; 5 | 6 | export class GradeSolvedDto { 7 | @ApiProperty({ description: '제출 답안 아이디' }) 8 | solvedId: number; 9 | 10 | @ApiProperty({ description: '문제 식별 아이디' }) 11 | problemId: number; 12 | 13 | @ApiProperty({ description: '사용자 식별 아이디' }) 14 | userId: number; 15 | 16 | @ApiProperty({ description: '사용자 제출 코드' }) 17 | userCode: string; 18 | 19 | @ApiProperty({ description: '제출 코드 언어' }) 20 | language: ProgrammingLanguage; 21 | 22 | @ApiProperty({ description: '테스트 케이스 아이디' }) 23 | testCaseId: number; 24 | 25 | @ApiProperty({ description: '테스트 케이스 번호' }) 26 | testCaseNumber: number; 27 | 28 | @ApiProperty({ description: '테스트 케이스 입력' }) 29 | testCaseInput: any; 30 | 31 | @ApiProperty({ description: '테스트 케이스 출력' }) 32 | testCaseOutput: any; 33 | 34 | constructor(solved: Solved, testCase: TestCase) { 35 | this.solvedId = solved?.id; 36 | this.problemId = solved.problem.id; 37 | this.userId = solved.user.id; 38 | this.userCode = solved.userCode; 39 | this.language = solved.language; 40 | 41 | this.testCaseId = testCase.id; 42 | this.testCaseNumber = testCase.caseNumber; 43 | this.testCaseInput = JSON.parse(testCase.testInput); 44 | this.testCaseOutput = JSON.parse(testCase.testOutput); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /backend/src/solved/dto/simple-solved.dto.ts: -------------------------------------------------------------------------------- 1 | import { Solved } from '../entities/solved.entity'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { ProgrammingLanguage } from '../entities/ProgrammingLanguage.enum'; 4 | import { SolvedResult } from '../entities/SolvedResult.enum'; 5 | 6 | export class SimpleSolvedDto { 7 | @ApiProperty({ description: '문제 식별 아이디' }) 8 | problemId: number; 9 | 10 | @ApiProperty({ description: '사용자 식별 아이디' }) 11 | userId: number; 12 | 13 | @ApiProperty({ description: '사용자 제출 코드' }) 14 | userCode: string; 15 | 16 | @ApiProperty({ description: '제출 코드 언어' }) 17 | language: ProgrammingLanguage; 18 | 19 | @ApiProperty({ description: '정답 제출 결과' }) 20 | result: SolvedResult; 21 | 22 | @ApiProperty({ description: '문제 풀이 제출 생성일' }) 23 | createdAt: Date; 24 | 25 | @ApiProperty({ description: '문제 풀이 제출 변경일' }) 26 | updatedAt: Date; 27 | 28 | constructor(solved: Solved) { 29 | this.problemId = solved.problem?.id; 30 | this.userId = solved.user?.id; 31 | this.userCode = solved.userCode; 32 | this.language = solved.language; 33 | this.result = solved.result; 34 | this.createdAt = solved.createdAt; 35 | this.updatedAt = solved.updatedAt; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /backend/src/solved/dto/update-solved.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, PartialType } from '@nestjs/swagger'; 2 | import { CreateSolvedDto } from './create-solved.dto'; 3 | import { SolvedResult } from '../entities/SolvedResult.enum'; 4 | import { IsNotEmpty } from 'class-validator'; 5 | 6 | export class UpdateSolvedDto extends PartialType(CreateSolvedDto) { 7 | @ApiProperty({ description: '정답 제출 결과' }) 8 | @IsNotEmpty() 9 | result: SolvedResult; 10 | } 11 | -------------------------------------------------------------------------------- /backend/src/solved/entities/ProgrammingLanguage.enum.ts: -------------------------------------------------------------------------------- 1 | export enum ProgrammingLanguage { 2 | JavaScript = 'JavaScript', 3 | Python = 'Python', 4 | } 5 | -------------------------------------------------------------------------------- /backend/src/solved/entities/SolvedResult.enum.ts: -------------------------------------------------------------------------------- 1 | export enum SolvedResult { 2 | Ready = 'Ready', 3 | Success = 'Success', 4 | Fail = 'Fail', 5 | } 6 | -------------------------------------------------------------------------------- /backend/src/solved/entities/solved.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; 2 | import { BaseTimeEntity } from '../../commons/entities/baseTime.entity'; 3 | import { Problem } from '../../problem/entities/problem.entity'; 4 | import { User } from '../../users/entities/user.entity'; 5 | import { ProgrammingLanguage } from './ProgrammingLanguage.enum'; 6 | import { SolvedResult } from './SolvedResult.enum'; 7 | 8 | @Entity() 9 | export class Solved extends BaseTimeEntity { 10 | @PrimaryGeneratedColumn() 11 | id: number; 12 | 13 | @ManyToOne(() => Problem, (problem) => problem.solvedList) 14 | problem: Problem; 15 | 16 | @ManyToOne(() => User, (user) => user.solvedList) 17 | user: User; 18 | 19 | @Column({ 20 | name: 'user_code', 21 | nullable: false, 22 | type: 'text', 23 | }) 24 | userCode: string; 25 | 26 | @Column({ 27 | type: 'enum', 28 | enum: ProgrammingLanguage, 29 | default: ProgrammingLanguage.JavaScript, 30 | }) 31 | language: ProgrammingLanguage; 32 | 33 | @Column({ type: 'enum', enum: SolvedResult, default: SolvedResult.Ready }) 34 | result: SolvedResult; 35 | 36 | public static createSolved({ problem, user, userCode, language, result }) { 37 | const solved = new Solved(); 38 | solved.problem = problem; 39 | solved.user = user; 40 | solved.userCode = userCode; 41 | solved.language = language; 42 | solved.result = result; 43 | return solved; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /backend/src/solved/solved.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { SolvedController } from './solved.controller'; 3 | import { SolvedService } from './solved.service'; 4 | import { HttpModule, HttpService } from '@nestjs/axios'; 5 | import { of } from 'rxjs'; 6 | import { getRepositoryToken } from '@nestjs/typeorm'; 7 | import { Solved } from './entities/solved.entity'; 8 | import { MockSolvedRepository } from '../mock/solved.mock'; 9 | import { Problem } from '../problem/entities/problem.entity'; 10 | import { TestCase } from '../test-case/entities/test-case.entity'; 11 | import { User } from '../users/entities/user.entity'; 12 | import { MockProblemRepository } from '../mock/problem.mock'; 13 | import { MockTestCaseRepository } from '../mock/test-case.mock'; 14 | import { MockUserRepository } from '../mock/user.mock'; 15 | 16 | describe('SolvedController', () => { 17 | let solvedController: SolvedController; 18 | let solvedService: SolvedService; 19 | 20 | beforeEach(async () => { 21 | const module: TestingModule = await Test.createTestingModule({ 22 | imports: [HttpModule], 23 | controllers: [SolvedController], 24 | providers: [ 25 | SolvedService, 26 | { 27 | provide: HttpService, 28 | useValue: { 29 | post: jest.fn(() => of({})), 30 | get: jest.fn(() => of({})), 31 | }, 32 | }, 33 | { 34 | provide: getRepositoryToken(Solved), 35 | useValue: MockSolvedRepository(), 36 | }, 37 | { 38 | provide: getRepositoryToken(User), 39 | useValue: MockUserRepository(), 40 | }, 41 | { 42 | provide: getRepositoryToken(Problem), 43 | useValue: MockProblemRepository(), 44 | }, 45 | { 46 | provide: getRepositoryToken(TestCase), 47 | useValue: MockTestCaseRepository(), 48 | }, 49 | ], 50 | }).compile(); 51 | 52 | solvedService = module.get(SolvedService); 53 | solvedController = module.get(SolvedController); 54 | }); 55 | 56 | it('should be defined', () => { 57 | expect(solvedService).toBeDefined(); 58 | expect(solvedController).toBeDefined(); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /backend/src/solved/solved.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { SolvedService } from './solved.service'; 3 | import { SolvedController } from './solved.controller'; 4 | import { HttpModule } from '@nestjs/axios'; 5 | import { TypeOrmModule } from '@nestjs/typeorm'; 6 | import { Solved } from './entities/solved.entity'; 7 | import { Problem } from '../problem/entities/problem.entity'; 8 | import { User } from '../users/entities/user.entity'; 9 | import { TestCase } from '../test-case/entities/test-case.entity'; 10 | 11 | @Module({ 12 | imports: [ 13 | TypeOrmModule.forFeature([Solved, Problem, User, TestCase]), 14 | HttpModule, 15 | // BullModule.forRoot({ 16 | // redis: { 17 | // host: 'localhost', 18 | // port: 6379, 19 | // }, 20 | // }), 21 | // BullModule.registerQueue({ 22 | // name: 'gradeQueue', 23 | // }), 24 | ], 25 | controllers: [SolvedController], 26 | providers: [SolvedService], 27 | exports: [SolvedService], 28 | }) 29 | export class SolvedModule {} 30 | -------------------------------------------------------------------------------- /backend/src/solved/solved.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { SolvedService } from './solved.service'; 3 | import { getRepositoryToken } from '@nestjs/typeorm'; 4 | import { Solved } from './entities/solved.entity'; 5 | import { MockSolvedRepository } from '../mock/solved.mock'; 6 | import { User } from '../users/entities/user.entity'; 7 | import { Problem } from '../problem/entities/problem.entity'; 8 | import { TestCase } from '../test-case/entities/test-case.entity'; 9 | import { MockUserRepository } from '../mock/user.mock'; 10 | import { MockProblemRepository } from '../mock/problem.mock'; 11 | import { MockTestCaseRepository } from '../mock/test-case.mock'; 12 | import { MockRepository } from '../mock/common.mock'; 13 | 14 | describe('SolvedService', () => { 15 | let solvedService: SolvedService; 16 | let solvedRepository: MockRepository; 17 | let userRepository: MockRepository; 18 | let problemRepository: MockRepository; 19 | let testCaseRepository: MockRepository; 20 | 21 | beforeEach(async () => { 22 | const module: TestingModule = await Test.createTestingModule({ 23 | providers: [ 24 | SolvedService, 25 | { 26 | provide: getRepositoryToken(Solved), 27 | useValue: MockSolvedRepository(), 28 | }, 29 | { 30 | provide: getRepositoryToken(User), 31 | useValue: MockUserRepository(), 32 | }, 33 | { 34 | provide: getRepositoryToken(Problem), 35 | useValue: MockProblemRepository(), 36 | }, 37 | { 38 | provide: getRepositoryToken(TestCase), 39 | useValue: MockTestCaseRepository(), 40 | }, 41 | ], 42 | }).compile(); 43 | 44 | solvedService = module.get(SolvedService); 45 | solvedRepository = module.get>( 46 | getRepositoryToken(Solved), 47 | ); 48 | userRepository = module.get>(getRepositoryToken(User)); 49 | problemRepository = module.get>( 50 | getRepositoryToken(Problem), 51 | ); 52 | testCaseRepository = module.get>( 53 | getRepositoryToken(TestCase), 54 | ); 55 | }); 56 | 57 | it('should be defined', () => { 58 | expect(testCaseRepository).toBeDefined(); 59 | expect(problemRepository).toBeDefined(); 60 | expect(userRepository).toBeDefined(); 61 | expect(solvedRepository).toBeDefined(); 62 | expect(solvedService).toBeDefined(); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /backend/src/test-case/dto/create-test-case.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNotEmpty } from 'class-validator'; 3 | 4 | export class CreateTestCaseDto { 5 | @ApiProperty({ description: '문제 식별 아이디' }) 6 | @IsNotEmpty() 7 | problemId: number; 8 | 9 | @ApiProperty({ description: '테스트 케이스 번호' }) 10 | @IsNotEmpty() 11 | caseNumber: number; 12 | 13 | @ApiProperty({ description: '테스트 케이스 입력' }) 14 | @IsNotEmpty() 15 | testInput: string; 16 | 17 | @ApiProperty({ description: '테스트 케이스 출력' }) 18 | @IsNotEmpty() 19 | testOutput: string; 20 | 21 | constructor(problemId, caseNumber, testInput, testOutput) { 22 | this.problemId = problemId; 23 | this.caseNumber = caseNumber; 24 | this.testInput = testInput; 25 | this.testOutput = testOutput; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /backend/src/test-case/dto/findTestCaseOption.interface.ts: -------------------------------------------------------------------------------- 1 | export interface IFindTestCaseOption { 2 | testCaseId?: number; 3 | problemId?: number; 4 | skip?: number; 5 | take?: number; 6 | } 7 | -------------------------------------------------------------------------------- /backend/src/test-case/dto/simple-testCase.dto.ts: -------------------------------------------------------------------------------- 1 | import { TestCase } from '../entities/test-case.entity'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class SimpleTestCaseDto { 5 | @ApiProperty({ description: '테스트 케이스 아이디' }) 6 | testCaseId: number; 7 | 8 | @ApiProperty({ description: '문제 식별 아이디' }) 9 | problemId: number; 10 | 11 | @ApiProperty({ description: '테스트 케이스 번호' }) 12 | caseNumber: number; 13 | 14 | @ApiProperty({ description: '테스트 케이스 입력' }) 15 | testInput: string; 16 | 17 | @ApiProperty({ description: '테스트 케이스 출력' }) 18 | testOutput: string; 19 | 20 | @ApiProperty({ description: '테스트 케이스 생성일' }) 21 | createdAt: Date; 22 | 23 | @ApiProperty({ description: '테스트 케이스 수정일' }) 24 | updatedAt: Date; 25 | 26 | constructor(testCase: TestCase) { 27 | this.testCaseId = testCase.id; 28 | this.problemId = testCase.problem?.id; 29 | this.caseNumber = testCase.caseNumber; 30 | this.testInput = testCase.testInput; 31 | this.testOutput = testCase.testOutput; 32 | this.createdAt = testCase.createdAt; 33 | this.updatedAt = testCase.updatedAt; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /backend/src/test-case/dto/update-test-case.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/swagger'; 2 | import { CreateTestCaseDto } from './create-test-case.dto'; 3 | 4 | export class UpdateTestCaseDto extends PartialType(CreateTestCaseDto) {} 5 | -------------------------------------------------------------------------------- /backend/src/test-case/entities/test-case.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; 2 | import { Problem } from '../../problem/entities/problem.entity'; 3 | import { BaseTimeEntity } from '../../commons/entities/baseTime.entity'; 4 | 5 | @Entity() 6 | export class TestCase extends BaseTimeEntity { 7 | @PrimaryGeneratedColumn() 8 | id: number; 9 | 10 | @ManyToOne(() => Problem, (problem) => problem.testcaseList) 11 | problem: Problem; 12 | 13 | @Column({ 14 | name: 'case_number', 15 | nullable: false, 16 | type: 'text', 17 | }) 18 | caseNumber: number; 19 | 20 | @Column({ 21 | name: 'test_input', 22 | nullable: false, 23 | type: 'text', 24 | }) 25 | testInput: string; 26 | 27 | @Column({ 28 | name: 'test_output', 29 | nullable: false, 30 | type: 'text', 31 | }) 32 | testOutput: string; 33 | 34 | public static createTestCase({ problem, caseNumber, testInput, testOutput }) { 35 | const testCase = new TestCase(); 36 | testCase.problem = problem; 37 | testCase.caseNumber = caseNumber; 38 | testCase.testInput = testInput; 39 | testCase.testOutput = testOutput; 40 | return testCase; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /backend/src/test-case/test-case.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { TestCaseController } from './test-case.controller'; 3 | import { TestCaseService } from './test-case.service'; 4 | import { getRepositoryToken } from '@nestjs/typeorm'; 5 | import { TestCase } from './entities/test-case.entity'; 6 | import { MockTestCaseRepository } from '../mock/test-case.mock'; 7 | import { MockProblemRepository } from '../mock/problem.mock'; 8 | import { Problem } from '../problem/entities/problem.entity'; 9 | 10 | describe('TestCaseController', () => { 11 | let testCaseController: TestCaseController; 12 | let testCaseService: TestCaseService; 13 | 14 | beforeEach(async () => { 15 | const module: TestingModule = await Test.createTestingModule({ 16 | controllers: [TestCaseController], 17 | providers: [ 18 | TestCaseService, 19 | { 20 | provide: getRepositoryToken(TestCase), 21 | useValue: MockTestCaseRepository(), 22 | }, 23 | { 24 | provide: getRepositoryToken(Problem), 25 | useValue: MockProblemRepository(), 26 | }, 27 | ], 28 | }).compile(); 29 | 30 | testCaseController = module.get(TestCaseController); 31 | testCaseService = module.get(TestCaseService); 32 | }); 33 | 34 | it('should be defined', () => { 35 | expect(testCaseService).toBeDefined(); 36 | expect(testCaseController).toBeDefined(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /backend/src/test-case/test-case.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TestCaseService } from './test-case.service'; 3 | import { TestCaseController } from './test-case.controller'; 4 | import { TestCase } from './entities/test-case.entity'; 5 | import { Problem } from '../problem/entities/problem.entity'; 6 | import { TypeOrmModule } from '@nestjs/typeorm'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([TestCase, Problem])], 10 | controllers: [TestCaseController], 11 | providers: [TestCaseService], 12 | exports: [TestCaseService], 13 | }) 14 | export class TestCaseModule {} 15 | -------------------------------------------------------------------------------- /backend/src/test-case/test-case.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { TestCaseService } from './test-case.service'; 3 | import { MockRepository } from '../mock/common.mock'; 4 | import { TestCase } from './entities/test-case.entity'; 5 | import { Problem } from '../problem/entities/problem.entity'; 6 | import { getRepositoryToken } from '@nestjs/typeorm'; 7 | import { MockTestCaseRepository } from '../mock/test-case.mock'; 8 | import { MockProblemRepository } from '../mock/problem.mock'; 9 | 10 | describe('TestCaseService', () => { 11 | let testCaseService: TestCaseService; 12 | let testCaseRepository: MockRepository; 13 | let problemRepository: MockRepository; 14 | 15 | beforeEach(async () => { 16 | const module: TestingModule = await Test.createTestingModule({ 17 | providers: [ 18 | TestCaseService, 19 | { 20 | provide: getRepositoryToken(TestCase), 21 | useValue: MockTestCaseRepository(), 22 | }, 23 | { 24 | provide: getRepositoryToken(Problem), 25 | useValue: MockProblemRepository(), 26 | }, 27 | ], 28 | }).compile(); 29 | 30 | testCaseService = module.get(TestCaseService); 31 | testCaseRepository = module.get>( 32 | getRepositoryToken(TestCase), 33 | ); 34 | problemRepository = module.get>( 35 | getRepositoryToken(Problem), 36 | ); 37 | }); 38 | 39 | it('should be defined', () => { 40 | expect(problemRepository).toBeDefined(); 41 | expect(testCaseRepository).toBeDefined(); 42 | expect(testCaseService).toBeDefined(); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /backend/src/typeorm/typeorm-ex.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | export const TYPEORM_EX_CUSTOM_REPOSITORY = 'TYPEORM_EX_CUSTOM_REPOSITORY'; 4 | 5 | export function CustomRepository(entity: Function): ClassDecorator { 6 | return SetMetadata(TYPEORM_EX_CUSTOM_REPOSITORY, entity); 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/typeorm/typeorm-ex.module.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Provider } from '@nestjs/common'; 2 | import { getDataSourceToken } from '@nestjs/typeorm'; 3 | import { DataSource } from 'typeorm'; 4 | import { TYPEORM_EX_CUSTOM_REPOSITORY } from './typeorm-ex.decorator'; 5 | 6 | export class TypeOrmExModule { 7 | public static forCustomRepository any>( 8 | repositories: T[], 9 | ): DynamicModule { 10 | const providers: Provider[] = []; 11 | 12 | for (const repository of repositories) { 13 | const entity = Reflect.getMetadata( 14 | TYPEORM_EX_CUSTOM_REPOSITORY, 15 | repository, 16 | ); 17 | 18 | if (!entity) { 19 | continue; 20 | } 21 | 22 | providers.push({ 23 | inject: [getDataSourceToken()], 24 | provide: repository, 25 | useFactory: (dataSource: DataSource): typeof repository => { 26 | const baseRepository = dataSource.getRepository(entity); 27 | return new repository( 28 | baseRepository.target, 29 | baseRepository.manager, 30 | baseRepository.queryRunner, 31 | ); 32 | }, 33 | }); 34 | } 35 | 36 | return { 37 | exports: providers, 38 | module: TypeOrmExModule, 39 | providers, 40 | }; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /backend/src/users/dto/auth-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class AuthUserDto { 4 | @ApiProperty({ description: '로그인 아이디' }) 5 | loginId: string; 6 | 7 | @ApiProperty({ description: '비밀번호' }) 8 | password: string; 9 | } 10 | -------------------------------------------------------------------------------- /backend/src/users/dto/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNotEmpty, Matches } from 'class-validator'; 3 | 4 | export class CreateUserDto { 5 | @ApiProperty({ description: '로그인 아이디' }) 6 | @Matches(/\w{6,20}/) 7 | @IsNotEmpty() 8 | loginId: string; 9 | 10 | @ApiProperty({ description: '비밀번호' }) 11 | @Matches(/[\w\[\]\/?.,;:|*~`!^\-_+<>@$%&\\]{8,20}/) 12 | @IsNotEmpty() 13 | password: string; 14 | 15 | constructor(loginId, password) { 16 | this.loginId = loginId; 17 | this.password = password; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /backend/src/users/dto/find-option-user.interface.ts: -------------------------------------------------------------------------------- 1 | export interface IFindUserOption { 2 | userId?: number; 3 | loginId?: string; 4 | } 5 | -------------------------------------------------------------------------------- /backend/src/users/dto/simple-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../entities/user.entity'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { IsNotEmpty, Length } from 'class-validator'; 4 | 5 | export class SimpleUserDto { 6 | @ApiProperty({ description: '사용자 식별 아이디' }) 7 | @IsNotEmpty() 8 | userId: number; 9 | 10 | @ApiProperty({ description: '로그인 아이디' }) 11 | @Length(6, 20) 12 | @IsNotEmpty() 13 | loginId: string; 14 | 15 | @ApiProperty({ description: '사용자 상태' }) 16 | @IsNotEmpty() 17 | userStatus: number; 18 | 19 | @ApiProperty({ description: '가입일' }) 20 | @IsNotEmpty() 21 | createdAt: Date; 22 | 23 | @ApiProperty({ description: '정보 수정일' }) 24 | @IsNotEmpty() 25 | updatedAt: Date; 26 | 27 | constructor(user: User) { 28 | this.userId = user.id; 29 | this.loginId = user.loginId; 30 | this.userStatus = user.userStatus; 31 | this.createdAt = user.createdAt; 32 | this.updatedAt = user.updatedAt; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /backend/src/users/dto/update-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/mapped-types'; 2 | import { CreateUserDto } from './create-user.dto'; 3 | 4 | export class UpdateUserDto extends PartialType(CreateUserDto) {} 5 | -------------------------------------------------------------------------------- /backend/src/users/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 2 | import { BaseTimeEntity } from '../../commons/entities/baseTime.entity'; 3 | import { Solved } from '../../solved/entities/solved.entity'; 4 | 5 | @Entity() 6 | export class User extends BaseTimeEntity { 7 | @PrimaryGeneratedColumn() 8 | id: number; 9 | 10 | @Column({ name: 'login_id', length: 20, nullable: false, unique: true }) 11 | loginId: string; 12 | 13 | @Column({ nullable: false }) 14 | password: string; 15 | 16 | @Column({ nullable: false, name: 'user_status' }) 17 | userStatus: number; 18 | 19 | solvedList: Solved[]; 20 | 21 | public static createUser({ loginId, password, userStatus }) { 22 | const user = new User(); 23 | user.loginId = loginId; 24 | user.password = password; 25 | user.userStatus = userStatus; 26 | return user; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /backend/src/users/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | HttpCode, 6 | HttpException, 7 | HttpStatus, 8 | Post, 9 | Query, 10 | UsePipes, 11 | ValidationPipe, 12 | } from '@nestjs/common'; 13 | import { UsersService } from './users.service'; 14 | import { CreateUserDto } from './dto/create-user.dto'; 15 | import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; 16 | import { SimpleUserDto } from './dto/simple-user.dto'; 17 | import { isFalsy } from '../utils/boolUtils'; 18 | 19 | @Controller('users') 20 | @ApiTags('사용자 API') 21 | export class UsersController { 22 | constructor(private readonly usersService: UsersService) {} 23 | 24 | @Post() 25 | @HttpCode(HttpStatus.CREATED) 26 | @ApiOperation({ summary: '유저 생성 API', description: '유저를 생성한다.' }) 27 | @ApiResponse({ description: '유저를 생성한다.', type: SimpleUserDto }) 28 | @UsePipes(ValidationPipe) 29 | async signupUser(@Body() createUserDto: CreateUserDto) { 30 | const simpleUserDto = await this.usersService.create(createUserDto); 31 | 32 | if (simpleUserDto !== null) { 33 | return { ...simpleUserDto }; 34 | } else { 35 | throw new HttpException( 36 | '회원가입을 진행할 수 없습니다.', 37 | HttpStatus.BAD_REQUEST, 38 | ); 39 | } 40 | } 41 | 42 | @Get() 43 | @HttpCode(HttpStatus.OK) 44 | @ApiOperation({ summary: '유저 검색 API', description: '유저를 검색한다.' }) 45 | @ApiResponse({ 46 | description: 47 | '사용자 식별 아이디(userId) 또는 로그인 아이디(loginId)를 받아 유저를 검색한다.', 48 | status: HttpStatus.OK, 49 | type: SimpleUserDto, 50 | }) 51 | async findUser(@Query('loginId') loginId: string) { 52 | if (isFalsy(loginId)) { 53 | throw new HttpException('잘못된 요청입니다.', HttpStatus.BAD_REQUEST); 54 | } 55 | 56 | const simpleUserDto = await this.usersService.findOneUser({ 57 | loginId: loginId, 58 | }); 59 | 60 | if (simpleUserDto !== null) { 61 | return { ...simpleUserDto, foundStatus: 1000 }; 62 | } else { 63 | return { foundStatus: 1001 }; 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /backend/src/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UsersService } from './users.service'; 3 | import { UsersController } from './users.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { User } from './entities/user.entity'; 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([User])], 9 | controllers: [UsersController], 10 | providers: [UsersService], 11 | exports: [UsersService], 12 | }) 13 | export class UsersModule {} 14 | -------------------------------------------------------------------------------- /backend/src/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { CreateUserDto } from './dto/create-user.dto'; 3 | import { User } from './entities/user.entity'; 4 | import * as bcrypt from 'bcrypt'; 5 | import { SimpleUserDto } from './dto/simple-user.dto'; 6 | import { IFindUserOption } from './dto/find-option-user.interface'; 7 | import { Repository } from 'typeorm'; 8 | import { InjectRepository } from '@nestjs/typeorm'; 9 | import { isFalsy } from '../utils/boolUtils'; 10 | 11 | @Injectable() 12 | export class UsersService { 13 | constructor( 14 | @InjectRepository(User) private readonly usersRepository: Repository, 15 | ) {} 16 | 17 | async create(createUserDto: CreateUserDto) { 18 | if (isFalsy(createUserDto.loginId) || isFalsy(createUserDto.password)) { 19 | return null; 20 | } 21 | 22 | const foundUsers = await this.usersRepository.findOneBy({ 23 | loginId: createUserDto.loginId, 24 | }); 25 | 26 | if (foundUsers !== null) { 27 | return null; 28 | } 29 | 30 | const savedUser = await this.usersRepository.save( 31 | User.createUser({ 32 | loginId: createUserDto.loginId, 33 | password: await bcrypt.hash(createUserDto.password, 10), 34 | userStatus: 0, 35 | }), 36 | ); 37 | 38 | return new SimpleUserDto(savedUser); 39 | } 40 | 41 | async findOneUser({ userId, loginId }: IFindUserOption) { 42 | if (isFalsy(userId) && isFalsy(loginId)) { 43 | return null; 44 | } 45 | 46 | const foundUsers = await this.usersRepository.findOneBy({ 47 | id: userId, 48 | loginId: loginId, 49 | }); 50 | 51 | return foundUsers !== null ? new SimpleUserDto(foundUsers) : null; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /backend/src/utils/boolUtils.ts: -------------------------------------------------------------------------------- 1 | export function isFalsy(value) { 2 | //TODO: 엄밀한 작성 필요 3 | return !value; 4 | } 5 | -------------------------------------------------------------------------------- /backend/src/utils/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../users/entities/user.entity'; 2 | import * as bcrypt from 'bcrypt'; 3 | 4 | export async function createTestUser( 5 | userId: number, 6 | loginId: string, 7 | password: string, 8 | ) { 9 | const user = User.createUser({ 10 | loginId: loginId, 11 | password: await bcrypt.hash(password, 10), 12 | userStatus: 0, 13 | }); 14 | 15 | user.id = userId; 16 | const now = new Date(); 17 | user.createdAt = now; 18 | user.updatedAt = now; 19 | 20 | return user; 21 | } 22 | -------------------------------------------------------------------------------- /backend/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import * as request from 'supertest'; 2 | import { Test } from '@nestjs/testing'; 3 | // import { AppModule } from './../src/app.module'; 4 | import { AppModule } from '../src/app.module'; 5 | import { INestApplication } from '@nestjs/common'; 6 | 7 | describe('AppController (e2e)', () => { 8 | let app: INestApplication; 9 | 10 | beforeAll(async () => { 11 | const moduleFixture = await Test.createTestingModule({ 12 | imports: [AppModule], 13 | }).compile(); 14 | 15 | app = moduleFixture.createNestApplication(); 16 | await app.init(); 17 | }); 18 | 19 | it('/ (GET)', () => { 20 | return request(app.getHttpServer()) 21 | .get('/') 22 | .expect(200) 23 | .expect('Hello World!'); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /backend/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "esModuleInterop": false 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /frontend/.env.vault: -------------------------------------------------------------------------------- 1 | ################################################################################# 2 | # # 3 | # This file uniquely identifies your project in dotenv-vault. # 4 | # You SHOULD commit this file to source control. # 5 | # # 6 | # Generated with 'npx dotenv-vault new' # 7 | # # 8 | # Learn more at https://dotenv.org/env-vault # 9 | # # 10 | ################################################################################# 11 | 12 | DOTENV_VAULT=vlt_4b94afb93309e30b310d04ecd2267acd2a2667b00c5e5012e4bb41149b6bfef2 -------------------------------------------------------------------------------- /frontend/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | settings: { 4 | react: { 5 | version: 'detect', 6 | }, 7 | 'import/resolver': { 8 | node: { 9 | paths: ['src'], 10 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 11 | }, 12 | }, 13 | }, 14 | parserOptions: { 15 | ecmaFeatures: { 16 | jsx: true, 17 | }, 18 | ecmaVersion: 13, 19 | sourceType: 'module', 20 | }, 21 | plugins: ['react', '@typescript-eslint'], 22 | extends: [ 23 | 'eslint:recommended', 24 | 'plugin:react/recommended', 25 | 'plugin:jsx-a11y/recommended', 26 | 'plugin:@typescript-eslint/recommended', 27 | 'plugin:prettier/recommended', 28 | ], 29 | ignorePatterns: ['.eslintrc.cjs', 'build', 'dist', 'public', '*.svg', '.lock'], 30 | env: { 31 | browser: true, 32 | es2021: true, 33 | }, 34 | root: true, 35 | rules: { 36 | '@typescript-eslint/interface-name-prefix': 'off', 37 | '@typescript-eslint/explicit-function-return-type': 'off', 38 | '@typescript-eslint/explicit-module-boundary-types': 'off', 39 | '@typescript-eslint/no-explicit-any': 'off', 40 | '@typescript-eslint/ban-ts-comment': 'off', 41 | 'react/react-in-jsx-scope': 'off', 42 | 'prettier/prettier': [ 43 | 'error', 44 | { 45 | endOfLine: 'auto', 46 | }, 47 | ], 48 | }, 49 | }; -------------------------------------------------------------------------------- /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 | .env.development 27 | 28 | .env* 29 | .flaskenv* 30 | !.env.project 31 | !.env.vault -------------------------------------------------------------------------------- /frontend/.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'singleQuote': true, 3 | 'trailingComma': 'all', 4 | 'semi': true, 5 | 'useTabs': false, 6 | 'tabWidth': 2, 7 | 'printWidth': 80, 8 | 'arrowParens': 'always', 9 | }; -------------------------------------------------------------------------------- /frontend/babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": "current" 8 | } 9 | } 10 | ], 11 | "@babel/preset-react", 12 | "@babel/preset-typescript" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /frontend/custom.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | import React = require('react'); 3 | export const ReactComponent: React.FC>; 4 | const src: string; 5 | export default src; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | CamperRank 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /frontend/jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "roots": ["/test/"], 3 | "testEnvironment": "jsdom", 4 | "moduleNameMapper": { 5 | "\\.(css|less|svg)$": "identity-obj-proxy" 6 | }, 7 | "setupFilesAfterEnv": ["/test/setup.ts"], 8 | "transform": { 9 | "^.+\\.tsx?$": "ts-jest", 10 | "^.+\\.js$": "babel-jest", 11 | ".+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$": "jest-transform-stub" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /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 && vite build", 9 | "preview": "vite preview", 10 | "prebuild": "npm run test", 11 | "test": "jest" 12 | }, 13 | "dependencies": { 14 | "@codemirror/commands": "^6.1.2", 15 | "@codemirror/lang-javascript": "^6.1.1", 16 | "@codemirror/lang-python": "^6.1.0", 17 | "@codemirror/language": "^6.3.1", 18 | "@codemirror/state": "^6.1.4", 19 | "@codemirror/view": "^6.6.0", 20 | "base-64": "^1.0.0", 21 | "codemirror": "^6.0.1", 22 | "jest-dom": "^4.0.0", 23 | "lib0": "^0.2.54", 24 | "peer": "^0.6.1", 25 | "react": "^18.2.0", 26 | "react-beforeunload": "^2.5.3", 27 | "react-cookie": "^4.1.1", 28 | "react-dom": "^18.2.0", 29 | "react-router-dom": "^6.4.3", 30 | "react-table": "^7.8.0", 31 | "react-testing-library": "^8.0.1", 32 | "react-uuid": "^2.0.0", 33 | "recoil": "^0.7.6", 34 | "socket.io-client": "^4.5.4", 35 | "styled-components": "^5.3.6", 36 | "vite-plugin-svgr": "^2.2.2", 37 | "y-codemirror.next": "^0.3.2", 38 | "y-indexeddb": "^9.0.9", 39 | "y-webrtc": "^10.2.3", 40 | "y-websocket": "^1.4.5", 41 | "yjs": "^13.5.42" 42 | }, 43 | "devDependencies": { 44 | "@babel/preset-env": "^7.20.2", 45 | "@babel/preset-react": "^7.18.6", 46 | "@babel/preset-typescript": "^7.18.6", 47 | "@testing-library/jest-dom": "^5.16.5", 48 | "@testing-library/react": "^13.4.0", 49 | "@testing-library/user-event": "^14.4.3", 50 | "@types/codemirror": "^5.60.5", 51 | "@types/jest": "^29.2.3", 52 | "@types/peerjs": "^1.1.0", 53 | "@types/react": "^18.0.24", 54 | "@types/react-beforeunload": "^2.1.1", 55 | "@types/react-dom": "^18.0.8", 56 | "@types/react-table": "^7.7.12", 57 | "@types/styled-components": "^5.1.26", 58 | "@typescript-eslint/eslint-plugin": "^5.45.1", 59 | "@typescript-eslint/parser": "^5.45.1", 60 | "@vitejs/plugin-react": "^2.2.0", 61 | "eslint": "^8.29.0", 62 | "eslint-config-airbnb": "^19.0.4", 63 | "eslint-config-prettier": "^8.5.0", 64 | "eslint-config-react-app": "^7.0.1", 65 | "eslint-plugin-import": "^2.26.0", 66 | "eslint-plugin-jsx-a11y": "^6.6.1", 67 | "eslint-plugin-prettier": "^4.2.1", 68 | "eslint-plugin-react": "^7.31.11", 69 | "eslint-plugin-react-hooks": "^4.6.0", 70 | "identity-obj-proxy": "^3.0.0", 71 | "jest": "^29.3.1", 72 | "jest-environment-jsdom": "^29.3.1", 73 | "jest-transform-stub": "^2.0.0", 74 | "prettier": "^2.8.0", 75 | "ts-jest": "^29.0.3", 76 | "typescript": "^4.6.4", 77 | "vite": "^3.2.3", 78 | "vite-plugin-compression2": "^0.4.1", 79 | "vite-tsconfig-paths": "^4.0.0" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / -------------------------------------------------------------------------------- /frontend/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Route, Routes, BrowserRouter, Navigate } from 'react-router-dom'; 2 | import { Home, ProblemList, Problem, Ranking, Profile, Sign } from './pages'; 3 | import { useUserState } from './hooks/useUserState'; 4 | import { useMemo } from 'react'; 5 | 6 | const App = () => { 7 | const { user } = useUserState(); 8 | const { isLoggedIn } = useMemo(() => user, [user, user.isLoggedIn]); 9 | return ( 10 | 11 | 12 | } /> 13 | {!isLoggedIn && } />} 14 | {!isLoggedIn && } />} 15 | } /> 16 | {isLoggedIn ? ( 17 | } /> 18 | ) : ( 19 | } /> 20 | )} 21 | {isLoggedIn ? ( 22 | } 25 | /> 26 | ) : ( 27 | } /> 28 | )} 29 | } /> 30 | {isLoggedIn && } />} 31 | } /> 32 | 33 | 34 | ); 35 | }; 36 | 37 | export default App; 38 | -------------------------------------------------------------------------------- /frontend/src/assets/Greater.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/copy-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/hamburger.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/Delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/Web31-CamperRank/67b4613e06831acf6bd85a4cfe967e4277db5864/frontend/src/assets/icons/Delete.png -------------------------------------------------------------------------------- /frontend/src/assets/icons/RedDelete.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/Refresh_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 19 | 22 | 25 | 28 | 31 | 33 | 35 | image/svg+xmlOpenclipartRefresh Icon2013-08-22T04:53:13A refresh iconhttps://openclipart.org/detail/182094/refresh-icon-by-pianobrad-182094pianoBraddesigniconrefreshweb 83 | 86 | 93 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/SelectButton.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/SliderLeft.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/SliderRight.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/index.ts: -------------------------------------------------------------------------------- 1 | import SliderLeft from './SliderLeft.svg'; 2 | import SliderRight from './SliderRight.svg'; 3 | import SelectButton from './SelectButton.svg'; 4 | import RedDelete from './RedDelete.svg'; 5 | import Delete from './RedDelete.svg'; 6 | 7 | export { SliderLeft, SliderRight, SelectButton, RedDelete, Delete }; 8 | -------------------------------------------------------------------------------- /frontend/src/assets/images/Banner1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/Web31-CamperRank/67b4613e06831acf6bd85a4cfe967e4277db5864/frontend/src/assets/images/Banner1.png -------------------------------------------------------------------------------- /frontend/src/assets/images/Banner1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/Web31-CamperRank/67b4613e06831acf6bd85a4cfe967e4277db5864/frontend/src/assets/images/Banner1.webp -------------------------------------------------------------------------------- /frontend/src/assets/images/Banner2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/Web31-CamperRank/67b4613e06831acf6bd85a4cfe967e4277db5864/frontend/src/assets/images/Banner2.png -------------------------------------------------------------------------------- /frontend/src/assets/images/Banner2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/Web31-CamperRank/67b4613e06831acf6bd85a4cfe967e4277db5864/frontend/src/assets/images/Banner2.webp -------------------------------------------------------------------------------- /frontend/src/assets/images/Banner3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/Web31-CamperRank/67b4613e06831acf6bd85a4cfe967e4277db5864/frontend/src/assets/images/Banner3.png -------------------------------------------------------------------------------- /frontend/src/assets/images/Banner3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/Web31-CamperRank/67b4613e06831acf6bd85a4cfe967e4277db5864/frontend/src/assets/images/Banner3.webp -------------------------------------------------------------------------------- /frontend/src/assets/images/index.ts: -------------------------------------------------------------------------------- 1 | import Banner1 from './Banner1.webp'; 2 | import Banner2 from './Banner2.webp'; 3 | import Banner3 from './Banner3.webp'; 4 | 5 | export { Banner1, Banner2, Banner3 }; 6 | -------------------------------------------------------------------------------- /frontend/src/assets/user.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /frontend/src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ButtonContainer, 3 | FooterContainer, 4 | KeyPhrase, 5 | MainText, 6 | } from '../styles/Footer.style'; 7 | 8 | export const Footer = () => { 9 | return ( 10 | 11 | Concurrent. Chat. Enjoy. 12 | 13 | We want you to enjoy algorithmic problem solving. 14 |
15 | Try to solve a problem with your friend. 16 |
17 | 18 | 19 | 👀 Team 20 | 21 | 22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /frontend/src/components/Home/Banner/Banner.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useMemo, useCallback } from 'react'; 2 | import styled, { css } from 'styled-components'; 3 | import BannerContent from './BannerContent'; 4 | import BannerController from './BannerController'; 5 | import BannerInfo from '../../../utils/BannerInfo'; 6 | 7 | type BannerType = { 8 | page: number; 9 | width: number; 10 | }; 11 | 12 | const BannerContainer = styled.div` 13 | height: 100%; 14 | display: flex; 15 | ${(props) => 16 | css` 17 | width: ${props.width * 3}px; 18 | transform: translate(${props.page * -props.width}px, 0); 19 | transition: all 0.4s ease-in-out; 20 | `} 21 | `; 22 | 23 | const Banner = () => { 24 | const [page, setPage] = useState(0); 25 | const [width, setWidth] = useState(window.innerWidth); 26 | const [throttle, setThrottle] = useState(false); 27 | 28 | useEffect(() => { 29 | const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); 30 | if (width < 80 * rem) setWidth(80 * rem); 31 | }, [width]); 32 | 33 | const handleResize = () => { 34 | if (throttle) return; 35 | setThrottle(true); 36 | setTimeout(() => { 37 | setWidth(window.innerWidth); 38 | setThrottle(false); 39 | }, 300); 40 | }; 41 | 42 | useEffect(() => { 43 | window.addEventListener('resize', handleResize); 44 | return () => { 45 | window.removeEventListener('resize', handleResize); 46 | }; 47 | }); 48 | 49 | const handleButtonClick = (num: number) => setPage(num); 50 | 51 | useEffect(() => { 52 | const pageChange = setInterval(() => setPage((page + 1) % 3), 4000); 53 | return () => clearInterval(pageChange); 54 | }); 55 | 56 | return ( 57 | <> 58 | 59 | {BannerInfo.map((content, idx) => ( 60 | 61 | ))} 62 | 63 | 64 | 65 | ); 66 | }; 67 | 68 | export default Banner; 69 | -------------------------------------------------------------------------------- /frontend/src/components/Home/Banner/BannerContent.tsx: -------------------------------------------------------------------------------- 1 | import BannerText from './BannerText'; 2 | import BannerImage from './BannerImage'; 3 | import styled, { css } from 'styled-components'; 4 | import { useNavigate } from 'react-router-dom'; 5 | import { BannerType } from '@types'; 6 | 7 | type BannerWrapperProp = { 8 | color: string; 9 | }; 10 | 11 | const BannerContainer = styled.div` 12 | width: 100%; 13 | height: 100%; 14 | display: flex; 15 | position: relative; 16 | min-width: 80rem; 17 | `; 18 | 19 | const BannerWrapper = styled.div` 20 | width: 80rem 21 | height: 100%; 22 | display: flex; 23 | margin: 0 auto; 24 | position: relative; 25 | min-width: 80rem; 26 | border-radius: 10px; 27 | cursor: pointer; 28 | ${(props) => 29 | css` 30 | background-color: ${props.color}; 31 | `} 32 | `; 33 | 34 | const BannerContent = ({ content }: BannerType) => { 35 | const navigate = useNavigate(); 36 | return ( 37 | <> 38 | 39 | navigate('/problems')} 42 | > 43 | 44 | 45 | 46 | 47 | 48 | ); 49 | }; 50 | 51 | export default BannerContent; 52 | -------------------------------------------------------------------------------- /frontend/src/components/Home/Banner/BannerController.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { SliderLeft, SliderRight } from '../../../assets/icons'; 3 | import styled, { css } from 'styled-components'; 4 | 5 | type BannerControllerType = { 6 | pageNum: number; 7 | onClickButton: (num: number) => void; 8 | }; 9 | 10 | type WrapperProp = { 11 | left: number; 12 | }; 13 | const ControllerWrapper = styled.div` 14 | position: absolute; 15 | bottom: 0.5rem; 16 | ${(props) => 17 | props.left && 18 | css` 19 | left: ${props.left}px; 20 | `} 21 | display: flex; 22 | line-height: 1rem; 23 | `; 24 | 25 | const SliderController = styled.img` 26 | height: 1.3rem; 27 | width: 0.8rem; 28 | cursor: pointer; 29 | `; 30 | 31 | const SliderPage = styled.div` 32 | height: 1.8rem; 33 | width: 7rem; 34 | text-align: center; 35 | color: black; 36 | font-weight: 500; 37 | font-size: 1.2rem; 38 | `; 39 | 40 | const BannerController = ({ pageNum, onClickButton }: BannerControllerType) => { 41 | const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); 42 | const [left, setLeft] = useState(0); 43 | const [throttle, setThrottle] = useState(false); 44 | 45 | const handleResize = () => { 46 | if (throttle) return; 47 | setThrottle(true); 48 | setTimeout(() => { 49 | const windowLen = Math.max(80 * rem, window.innerWidth); 50 | const len = (windowLen - 80 * rem) / 2; 51 | setLeft(len + 2 * rem); 52 | }, 300); 53 | }; 54 | 55 | useEffect(() => { 56 | handleResize(); 57 | window.addEventListener('resize', handleResize); 58 | return () => { 59 | window.removeEventListener('resize', handleResize); 60 | }; 61 | }, []); 62 | 63 | const handleLeftClick = () => { 64 | if (pageNum == 0) onClickButton(2); 65 | else onClickButton(pageNum - 1); 66 | }; 67 | 68 | const handleRightClick = () => { 69 | if (pageNum == 2) onClickButton(0); 70 | else onClickButton(pageNum + 1); 71 | }; 72 | 73 | return ( 74 | 75 | {left > 0 && ( 76 | <> 77 | 82 | 0{pageNum + 1} | 03 83 | 88 | 89 | )} 90 | 91 | ); 92 | }; 93 | 94 | export default BannerController; 95 | -------------------------------------------------------------------------------- /frontend/src/components/Home/Banner/BannerImage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | type BannerImageType = { 5 | source: string; 6 | }; 7 | 8 | const SliderImage = styled.img` 9 | height: 13rem; 10 | width: 16%; 11 | right: 25%; 12 | top: 1.2rem; 13 | position: absolute; 14 | border-radius: 1rem; 15 | object-fit: cover; 16 | `; 17 | 18 | const BannerImage = ({ source }: BannerImageType) => { 19 | return ; 20 | }; 21 | 22 | export default BannerImage; 23 | -------------------------------------------------------------------------------- /frontend/src/components/Home/Banner/BannerText.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | type BannerTextType = { 5 | text: string; 6 | }; 7 | 8 | const TextWrapper = styled.div` 9 | width: 30rem; 10 | left: 15%; 11 | top: 2.5rem; 12 | height: fit-content; 13 | position: absolute; 14 | font-weight: 400; 15 | font-size: 2.4rem; 16 | text-align: center; 17 | white-space: pre-wrap; 18 | line-height: 4.5rem; 19 | color: #333333; 20 | `; 21 | 22 | const BannerText = ({ text }: BannerTextType) => { 23 | return {text}; 24 | }; 25 | 26 | export default BannerText; 27 | -------------------------------------------------------------------------------- /frontend/src/components/Home/ProblemList/List.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import styled from 'styled-components'; 3 | import Problem from './Problem'; 4 | import { userState } from '../../../recoils'; 5 | import { useRecoilState } from 'recoil'; 6 | import { ProblemInfo } from '@types'; 7 | 8 | const URL = import.meta.env.VITE_SERVER_URL; 9 | 10 | const ListWrapper = styled.div` 11 | width: 100%; 12 | height: 100%; 13 | display: flex; 14 | justify-content: center; 15 | position: relative; 16 | margin-top: 1rem; 17 | `; 18 | 19 | const ListTitle = styled.div` 20 | font-weight: 500; 21 | font-size: 2.2rem; 22 | line-height: 2.9rem; 23 | text-align: center; 24 | position: absolute; 25 | left: 3.5rem; 26 | top: 3rem; 27 | color: #555555; 28 | cursor: pointer; 29 | `; 30 | 31 | const ProblemListWrapper = styled.div` 32 | position: absolute; 33 | top: 9.5rem; 34 | display: grid; 35 | grid-template-columns: 37rem 37rem; 36 | grid-template-rows: 12.2rem 12.2rem 12.2rem; 37 | column-gap: 3.5rem; 38 | row-gap: 3.5rem; 39 | width: fit-content; 40 | `; 41 | 42 | const List = () => { 43 | const [user] = useRecoilState(userState); 44 | const [problems, setProblems] = useState([]); 45 | useEffect(() => { 46 | const { ID } = user; 47 | const fetchURL = ID 48 | ? `${URL}/problem/random?loginId=${ID}` 49 | : `${URL}/problem/random?loginId=0`; 50 | fetch(fetchURL) 51 | .then((res) => res.json()) 52 | .then((res) => { 53 | setProblems(Object.values(res)); 54 | }); 55 | }, [user]); 56 | return ( 57 | 58 | 오늘의 랜덤 문제 59 | 60 | {problems.map((elem, idx) => ( 61 | 62 | ))} 63 | 64 | 65 | ); 66 | }; 67 | 68 | export default List; 69 | -------------------------------------------------------------------------------- /frontend/src/components/Home/ProblemList/Problem.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useState } from 'react'; 2 | import styled, { css } from 'styled-components'; 3 | import { ProblemType } from '@types'; 4 | import { Link } from 'react-router-dom'; 5 | 6 | type Prop = { 7 | active: boolean; 8 | }; 9 | 10 | const ProblemWrapper = styled.div` 11 | border-radius: 16px; 12 | position: relative; 13 | border: 2px solid #dddddd; 14 | min-width: 36rem; 15 | background: #eff9f2; 16 | &:hover { 17 | background: #c9e2d1; 18 | border: none; 19 | box-shadow: 3px 3px 3px 3px #b9b9b9; 20 | } 21 | `; 22 | 23 | const Level = styled.div` 24 | position: absolute; 25 | width: 20%; 26 | height: 2.5rem; 27 | left: 1rem; 28 | top: 1rem; 29 | border: 2px solid #34b35a; 30 | border-radius: 8px; 31 | text-align: center; 32 | font-weight: 600; 33 | font-size: 1.4rem; 34 | line-height: 2rem; 35 | ${(props) => 36 | props.active && 37 | css` 38 | background: #caf7d9; 39 | border: none; 40 | box-shadow: 1px 1px 3px 1px #b9b9b9; 41 | `} 42 | `; 43 | 44 | const Name = styled.div` 45 | position: absolute; 46 | width: fit-content; 47 | height: 3rem; 48 | left: 2rem; 49 | top: 4.5rem; 50 | font-weight: 600; 51 | font-size: 2.3rem; 52 | line-height: 3rem; 53 | text-align: center; 54 | color: #104e22; 55 | `; 56 | 57 | const Description = styled.div` 58 | position: absolute; 59 | height: 1.5rem; 60 | left: 2rem; 61 | bottom: 1rem; 62 | font-weight: 500; 63 | font-size: 1rem; 64 | `; 65 | 66 | const Button = styled.button` 67 | position: absolute; 68 | width: 27%; 69 | height: 3rem; 70 | right: 1rem; 71 | bottom: 1rem; 72 | border: 2px solid #afd5bb; 73 | border-radius: 8px; 74 | background: #fff; 75 | font-weight: 500; 76 | font-size: 1.45rem; 77 | line-height: 2.5rem; 78 | text-align: center; 79 | cursor: pointer; 80 | &:hover { 81 | background: #9fcdad; 82 | color: white; 83 | font-weight: bold; 84 | box-shadow: 2px 2px 3px 2px #b9b9b9; 85 | border: none; 86 | } 87 | `; 88 | 89 | const Mark = styled.div` 90 | position: absolute; 91 | left: -0.5rem; 92 | top: -5rem; 93 | text-align: center; 94 | font-size: 6rem; 95 | color: #e69c9f; 96 | font-weight: bold; 97 | z-index: 2; 98 | `; 99 | 100 | const Problem = ({ problem }: ProblemType) => { 101 | const { level, title, problemId, isSolved } = problem; 102 | const [active, setActive] = useState(false); 103 | const problemURL = `/problem/single/${problemId}`; 104 | return ( 105 | setActive(true)} 107 | onMouseOut={() => setActive(false)} 108 | > 109 | {isSolved && } 110 | LV. {level} 111 | {title} 112 | Lv{level}, Python, Javascript 113 | 114 | 115 | 116 | 117 | ); 118 | }; 119 | 120 | export default memo(Problem); 121 | -------------------------------------------------------------------------------- /frontend/src/components/Home/index.ts: -------------------------------------------------------------------------------- 1 | import Banner from './Banner/Banner'; 2 | import List from './ProblemList/List'; 3 | 4 | export { Banner, List }; 5 | -------------------------------------------------------------------------------- /frontend/src/components/MainHeader.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | MainHeaderContainer, 3 | AnchorLogo, 4 | GreenMark, 5 | MenuContainer, 6 | } from '../styles/MainHeader.style'; 7 | import { Link } from 'react-router-dom'; 8 | import { useRecoilState } from 'recoil'; 9 | import { userState } from '../recoils'; 10 | import React, { useCallback } from 'react'; 11 | import { useUserState } from '../hooks/useUserState'; 12 | 13 | export const MainHeader = () => { 14 | const [user, setUser] = useRecoilState(userState); 15 | const { logoutHandler } = useUserState(); 16 | const handleLogoutClick = useCallback( 17 | (/*e: React.MouseEvent*/) => { 18 | if (!user.isLoggedIn) { 19 | return; 20 | } 21 | logoutHandler(); 22 | }, 23 | [user, setUser, user.isLoggedIn], 24 | ); 25 | 26 | return ( 27 | 28 | 29 | CamperRank 30 | 31 | 41 | 42 | 43 | 46 | 47 | 48 | 51 | 52 | 53 | 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /frontend/src/components/Problem/Buttons/PageButtons.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { useParams } from 'react-router-dom'; 3 | import styled from 'styled-components'; 4 | import { InviteModal } from '../InviteModal'; 5 | import useModal from '../../../hooks/useModal'; 6 | 7 | type ButtonProp = { 8 | name: string; 9 | callback: any; 10 | }; 11 | 12 | const ButtonWrapper = styled.button` 13 | width: 100%; 14 | height: auto; 15 | padding: 1rem 0; 16 | background: #ffffff; 17 | border: 1px solid #888888; 18 | 19 | &:nth-child(1), 20 | &:hover { 21 | background: #eef5f0; 22 | font-weight: 600; 23 | border: none; 24 | } 25 | 26 | span { 27 | writing-mode: vertical-lr; 28 | font-size: 1rem; 29 | letter-spacing: 3px; 30 | } 31 | 32 | :active { 33 | background: #ffffff; 34 | box-shadow: 0 5px #666; 35 | transform: translateY(4px); 36 | } 37 | `; 38 | 39 | const Button = ({ name, callback }: ButtonProp) => { 40 | return ( 41 | 42 | {name} 43 | 44 | ); 45 | }; 46 | 47 | const PageButtons = () => { 48 | const { version } = useParams(); 49 | const { isShowing, toggle } = useModal(); 50 | 51 | const setProblem = useCallback(() => { 52 | return; 53 | }, []); 54 | 55 | const setQuestion = useCallback(() => { 56 | return; 57 | }, []); 58 | 59 | const setTestcase = useCallback(() => { 60 | return; 61 | }, []); 62 | 63 | const invite = useCallback(() => { 64 | toggle(); 65 | }, [isShowing, toggle]); 66 | 67 | const buttonNames = ['문제', '질문', '테스트케이스']; 68 | const callbackList = [setProblem, setQuestion, setTestcase]; 69 | if (version === 'multi') { 70 | buttonNames.push('초대'); 71 | callbackList.push(invite); 72 | } 73 | 74 | return ( 75 | <> 76 | {buttonNames.map((name, idx) => ( 77 | 69 | 70 | 71 | 74 | 75 | 76 | 77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /frontend/src/components/ProblemList/List/List.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import styled from 'styled-components'; 3 | import Problem from './Problem'; 4 | import PageController from './PageController'; 5 | import { ProblemInfo } from '@types'; 6 | import SearchBox from './SearchBox'; 7 | import { useRecoilState } from 'recoil'; 8 | import { filterState } from '../../../recoils'; 9 | 10 | type ListType = { 11 | list: ProblemInfo[]; 12 | }; 13 | 14 | const ListContainer = styled.div` 15 | min-width: 80rem; 16 | width: 80rem; 17 | margin: 0 auto; 18 | display: flex; 19 | padding: 0 5rem; 20 | `; 21 | 22 | const ListWrapper = styled.div` 23 | width: 75%; 24 | display: flex; 25 | flex-direction: column; 26 | gap: 2rem; 27 | height: 100%; 28 | position: relative; 29 | `; 30 | 31 | const SubWrapper = styled.div` 32 | width: 30%; 33 | display: flex; 34 | flex-direction: column; 35 | align-items: flex-end; 36 | `; 37 | 38 | const Info = styled.div` 39 | font-size: 1.2rem; 40 | color: #537744; 41 | margin: 3rem 0 0 1rem; 42 | `; 43 | 44 | const List = ({ list }: ListType) => { 45 | const [page, setPage] = useState({ now: 1, max: Math.ceil(list.length / 7) }); 46 | const [pagedList, setPagedList] = useState(list); 47 | const [filter] = useRecoilState(filterState); 48 | 49 | useEffect(() => { 50 | const { now } = page; 51 | now && setPagedList([...list.slice(7 * (now - 1), 7 * now)]); 52 | }, [page]); 53 | 54 | useEffect(() => { 55 | setPage({ 56 | now: 1, 57 | max: Math.ceil(list.length / 7), 58 | }); 59 | }, [list]); 60 | return ( 61 | <> 62 | 63 | 64 | 65 | {list.length == 0 66 | ? '해당하는 문제가 존재하지 않습니다' 67 | : `총 ${list.length} 문제가 검색되었습니다`} 68 | 69 | {pagedList.length <= 7 && 70 | pagedList.map((elem, idx) => ( 71 | 76 | ))} 77 | setPage({ ...page, now })} 80 | > 81 | 82 | 83 | 84 | 85 | 86 | 87 | ); 88 | }; 89 | 90 | export default List; 91 | -------------------------------------------------------------------------------- /frontend/src/components/ProblemList/List/PageController.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import styled from 'styled-components'; 3 | import { SliderLeft, SliderRight } from '../../../assets/icons'; 4 | 5 | type pageType = { 6 | now: number; 7 | max: number; 8 | }; 9 | 10 | interface pageProps { 11 | onClickPage: (page: number) => void; 12 | page: pageType; 13 | } 14 | 15 | const ControllerWrapper = styled.div` 16 | position: absolute; 17 | bottom: 2rem; 18 | display: flex; 19 | justify-content: center; 20 | width: 100%; 21 | gap: 1.5rem; 22 | `; 23 | 24 | const SliderImage = styled.img` 25 | height: 2rem; 26 | width: 1rem; 27 | border-radius: 2rem; 28 | object-fit: cover; 29 | cursor: pointer; 30 | `; 31 | 32 | const PageWrapper = styled.div` 33 | display: flex; 34 | gap: 0.7rem; 35 | `; 36 | 37 | const Page = styled.div` 38 | font-size: 2rem; 39 | line-height: 2rem; 40 | font-weight: 400; 41 | color: gray; 42 | cursor: pointer; 43 | `; 44 | 45 | const NowPage = styled(Page)` 46 | font-weight: 700; 47 | color: green; 48 | `; 49 | 50 | const PageController = ({ page, onClickPage }: pageProps) => { 51 | const { now, max } = page; 52 | const handlePageClick = (page: number) => onClickPage(page); 53 | const handleLeftImageClick = useCallback(() => { 54 | if (now > 1) onClickPage(now - 1); 55 | }, [onClickPage]); 56 | const handleRightImageClick = useCallback(() => { 57 | if (now < max) onClickPage(now + 1); 58 | }, [onClickPage]); 59 | return ( 60 | 61 | 66 | 67 | {/* eslint-disable-next-line prefer-spread */} 68 | {Array.apply(null, new Array(max)).map((e, idx) => 69 | idx + 1 == now ? ( 70 | handlePageClick(idx + 1)}> 71 | {idx + 1} 72 | 73 | ) : ( 74 | handlePageClick(idx + 1)}> 75 | {idx + 1} 76 | 77 | ), 78 | )} 79 | 80 | 85 | 86 | ); 87 | }; 88 | 89 | export default PageController; 90 | -------------------------------------------------------------------------------- /frontend/src/components/ProblemList/List/Problem.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useEffect } from 'react'; 2 | import styled from 'styled-components'; 3 | import { ProblemType } from '@types'; 4 | import { ProblemInfo } from '@types'; 5 | import { Link } from 'react-router-dom'; 6 | import uuid from 'react-uuid'; 7 | 8 | type ProblemProps = { 9 | problem: ProblemInfo; 10 | check: boolean; 11 | }; 12 | 13 | const ProblemWrapper = styled.div` 14 | width: 100%; 15 | height: 7.5rem; 16 | border: 3px solid #cbcbcb; 17 | border-radius: 8px; 18 | background: #fff; 19 | position: relative; 20 | min-width: 40rem; 21 | 22 | &:hover { 23 | background: #e6f3ea; 24 | border: none; 25 | box-shadow: 3px 3px 3px 3px #b9b9b9; 26 | } 27 | `; 28 | 29 | const Title = styled.div` 30 | position: absolute; 31 | top: 1rem; 32 | left: 3rem; 33 | font-style: normal; 34 | font-weight: 500; 35 | font-size: 2rem; 36 | `; 37 | 38 | const Description = styled.div` 39 | position: absolute; 40 | bottom: 0.8rem; 41 | left: 3rem; 42 | font-style: normal; 43 | font-weight: 500; 44 | font-size: 1.2rem; 45 | text-align: center; 46 | `; 47 | 48 | const ButtonWrapper = styled.div` 49 | position: absolute; 50 | bottom: 0.9rem; 51 | right: 0.9rem; 52 | display: flex; 53 | gap: 1.5rem; 54 | `; 55 | 56 | const Button = styled.button` 57 | outline: none; 58 | width: 8rem; 59 | height: 2.6rem; 60 | border: 2px solid #32c766; 61 | border-radius: 8px; 62 | background: #fff; 63 | font-weight: 500; 64 | font-size: 1.3rem; 65 | line-height: 1.5rem; 66 | text-align: center; 67 | box-shadow: 0.5px 0.5px 0.5px 0.5px #75efa2; 68 | position: relative; 69 | &:hover { 70 | background: #aad4b6; 71 | color: white; 72 | font-weight: bold; 73 | box-shadow: 2px 2px 2px 2px #b9b9b9; 74 | border: none; 75 | } 76 | `; 77 | 78 | const getRoomNumber = (id: number) => { 79 | let room = localStorage.getItem(`problem${id}`); 80 | if (!room) { 81 | room = btoa(uuid()); 82 | localStorage.setItem(`problem${id}`, room); 83 | } 84 | return room; 85 | }; 86 | 87 | const Mark = styled.div` 88 | position: absolute; 89 | left: 0rem; 90 | top: -2.2rem; 91 | text-align: center; 92 | font-size: 3rem; 93 | color: #e69c9f; 94 | font-weight: bold; 95 | z-index: 2; 96 | `; 97 | 98 | const Problem = ({ problem, check }: ProblemProps) => { 99 | const { problemId, title, level, isSolved } = problem; 100 | const singleURL = `/problem/single/${problemId}`; 101 | let multiURL = `/problem/multi/${problemId}/`; 102 | if (problemId != null) { 103 | multiURL = `/problem/multi/${problemId}/${getRoomNumber(problemId)}`; 104 | } 105 | 106 | return ( 107 | 108 | {check && isSolved && } 109 | {title} 110 | Lv{level}, Python, Javascript 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | ); 121 | }; 122 | 123 | export default memo(Problem); 124 | -------------------------------------------------------------------------------- /frontend/src/components/ProblemList/List/SearchBox.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback } from 'react'; 2 | import styled from 'styled-components'; 3 | import { Delete, RedDelete } from '../../../assets/icons'; 4 | import { useRecoilState } from 'recoil'; 5 | import { filterState } from '../../../recoils'; 6 | 7 | const SearchWrapper = styled.div` 8 | width: 13rem; 9 | display: flex; 10 | flex-direction: column; 11 | gap: 1rem; 12 | margin-top: 4rem; 13 | margin-right: 3rem; 14 | `; 15 | 16 | const Title = styled.div` 17 | color: #888; 18 | font-size: 1.3rem; 19 | font-weight: bold; 20 | margin-right: 36px; 21 | `; 22 | 23 | const Box = styled.div` 24 | display: flex; 25 | align-items: center; 26 | `; 27 | 28 | const Search = styled.div` 29 | width: 10rem; 30 | word-break: break-all; 31 | height: fit-content; 32 | border: 3px solid #b5d4a8; 33 | border-radius: 8px; 34 | background: #fff; 35 | font-size: 1.4rem; 36 | text-align: center; 37 | line-height: 2.5rem; 38 | margin-left: 2rem; 39 | text-overflow: ellipsis; 40 | `; 41 | 42 | const DeleteIcon = styled.img` 43 | width: 1.5rem; 44 | height: 1.5rem; 45 | filter: invert(0.3); 46 | cursor: pointer; 47 | `; 48 | 49 | const SearchBox = () => { 50 | const [filter, setFilter] = useRecoilState(filterState); 51 | const clickEvent = { 52 | solved: () => setFilter({ ...filter, solved: '푼 상태' }), 53 | level: () => setFilter({ ...filter, level: '문제 레벨' }), 54 | search: () => setFilter({ ...filter, search: '' }), 55 | }; 56 | const handleImgClick = (kind: string) => { 57 | if (kind == 'solved' || kind == 'level' || kind == 'search') 58 | clickEvent[kind](); 59 | }; 60 | const checkFilter = useCallback(() => { 61 | const { solved, level, search } = filter; 62 | return solved !== '푼 상태' || level !== '문제 레벨' || search !== ''; 63 | }, [filter]); 64 | return ( 65 | 66 | {checkFilter() ? '현재 검색어' : ''} 67 | {Object.entries(filter).map((elem) => { 68 | const [kind, value] = elem; 69 | if ( 70 | (kind === 'solved' && value === '푼 상태') || 71 | (kind === 'level' && value === '문제 레벨') || 72 | value === '' || 73 | kind === 'check' 74 | ) 75 | return; 76 | return ( 77 | 78 | handleImgClick(kind)} /> 79 | 80 | {value.length >= 20 ? `${value.slice(0, 20)}...` : value} 81 | 82 | 83 | ); 84 | })} 85 | 86 | ); 87 | }; 88 | 89 | export default SearchBox; 90 | -------------------------------------------------------------------------------- /frontend/src/components/ProblemList/SearchFilter/Filter.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect, memo } from 'react'; 2 | import styled from 'styled-components'; 3 | import { SelectButton } from '../../../assets/icons'; 4 | import Modal from './Modal'; 5 | import { FilterType } from '@types'; 6 | import { useRecoilState } from 'recoil'; 7 | import { filterState } from '../../../recoils'; 8 | 9 | const FilterWrapper = styled.button` 10 | width: 10rem; 11 | height: 3rem; 12 | border: 3px solid #b5d4a8; 13 | border-radius: 10px; 14 | background: #fff; 15 | text-align: center; 16 | font-weight: 500; 17 | font-size: 1.45rem; 18 | line-height: 3rem; 19 | display: flex; 20 | align-items: center; 21 | justify-content: space-around; 22 | position: relative; 23 | cursor: pointer; 24 | 25 | &:hover { 26 | border: 3px solid #80a471; 27 | } 28 | `; 29 | 30 | const ModalButton = styled.img` 31 | width: 1rem; 32 | height: 1rem; 33 | cursor: pointer; 34 | `; 35 | 36 | const Filter = ({ content }: FilterType) => { 37 | const { name, elements } = content; 38 | const [open, setOpen] = useState(false); 39 | const [filter] = useRecoilState(filterState); 40 | const { solved, level } = filter; 41 | const filterRef = useRef(null); 42 | const handleClickOutside = ({ target }: any) => { 43 | if (!filterRef.current || !filterRef.current.contains(target)) { 44 | setOpen(false); 45 | } 46 | }; 47 | 48 | useEffect(() => { 49 | window.addEventListener('click', handleClickOutside); 50 | return () => { 51 | window.removeEventListener('click', handleClickOutside); 52 | }; 53 | }, []); 54 | 55 | return ( 56 | setOpen(!open)}> 57 | {name === '상태' ? solved : level} 58 | 62 | {open && ( 63 | setOpen(!open)} 65 | name={name} 66 | elements={elements} 67 | > 68 | )} 69 | 70 | ); 71 | }; 72 | 73 | export default memo(Filter); 74 | -------------------------------------------------------------------------------- /frontend/src/components/ProblemList/SearchFilter/Modal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { useRecoilState } from 'recoil'; 4 | import { filterState } from '../../../recoils'; 5 | 6 | type ModalElements = { 7 | name: string; 8 | elements: string[]; 9 | onClick: () => void; 10 | }; 11 | 12 | const ModalWrapper = styled.div` 13 | width: 100%; 14 | position: absolute; 15 | top: 3rem; 16 | border: 2px solid #c6dfbb; 17 | background: rgba(256, 256, 256, 0.8); 18 | border-radius: 10px; 19 | z-index: 1; 20 | `; 21 | 22 | const ModalElement = styled.div` 23 | font-size: 20px; 24 | cursor: pointer; 25 | color: #80a471; 26 | &:hover { 27 | background: #e2f0dc; 28 | font-weight: bold; 29 | } 30 | `; 31 | 32 | const Modal = ({ onClick, name, elements }: ModalElements) => { 33 | const [filter, setFilter] = useRecoilState(filterState); 34 | const handleClickElement = (element: string) => { 35 | name === '상태' ? changeStatus(element) : changeLevel(element); 36 | onClick(); 37 | }; 38 | const changeLevel = (level: string) => { 39 | setFilter({ ...filter, level }); 40 | }; 41 | const changeStatus = (solved: string) => { 42 | setFilter({ ...filter, solved }); 43 | }; 44 | return ( 45 | 46 | {elements.map((elem, idx) => ( 47 | handleClickElement(elem)}> 48 | {elem} 49 | 50 | ))} 51 | 52 | ); 53 | }; 54 | 55 | export default Modal; 56 | -------------------------------------------------------------------------------- /frontend/src/components/ProblemList/SearchFilter/Search.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import styled from 'styled-components'; 3 | import { useRecoilState } from 'recoil'; 4 | import { filterState } from '../../../recoils'; 5 | 6 | const SearchWrapper = styled.div` 7 | height: 3rem; 8 | display: flex; 9 | align-items: center; 10 | gap: 16px; 11 | font-size: 1rem; 12 | cursor: pointer; 13 | `; 14 | 15 | const SearchInput = styled.input` 16 | width: 18rem; 17 | height: 3rem; 18 | cursor: pointer; 19 | outline: none; 20 | border: 3px solid #b5d4a8; 21 | border-radius: 10px; 22 | text-align: right; 23 | font-size: 1.5rem; 24 | padding-right: 0.5rem; 25 | &:hover { 26 | border: 3px solid #80a471; 27 | } 28 | `; 29 | 30 | const SearchButton = styled.button` 31 | outline: none; 32 | background: #fff; 33 | border: 3px solid #b5d4a8; 34 | border-radius: 10px; 35 | font-size: 1.25rem; 36 | height: 3rem; 37 | cursor: pointer; 38 | width: 4.4rem; 39 | &:hover { 40 | border: 3px solid #80a471; 41 | } 42 | `; 43 | 44 | const Search = () => { 45 | const [filter, setFilter] = useRecoilState(filterState); 46 | const { search } = filter; 47 | return ( 48 | 49 | ) => 52 | setFilter({ ...filter, search: e.target.value }) 53 | } 54 | > 55 | 검색 56 | 57 | ); 58 | }; 59 | 60 | export default Search; 61 | -------------------------------------------------------------------------------- /frontend/src/components/ProblemList/SearchFilter/SearchFilter.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import Filter from './Filter'; 4 | import FiltersInfo from '../../../utils/FiltersInfo'; 5 | import Search from './Search'; 6 | import { useRecoilState } from 'recoil'; 7 | import { filterState } from '../../../recoils'; 8 | 9 | const FiltersContainer = styled.div` 10 | width: 100%; 11 | height: 13rem; 12 | position: relative; 13 | background: #e5ebdf; 14 | display: flex; 15 | `; 16 | 17 | const FiltersWrapper = styled.div` 18 | min-width: 80rem; 19 | width: 80rem; 20 | min-height: 13rem; 21 | height: 13rem; 22 | position: relative; 23 | margin: 0 auto; 24 | `; 25 | 26 | const FilterTitle = styled.div` 27 | font-weight: 600; 28 | font-size: 2.5rem; 29 | position: absolute; 30 | top: 1.7rem; 31 | left: 8rem; 32 | color: #446635; 33 | `; 34 | 35 | const FilterContent = styled.div` 36 | position: absolute; 37 | bottom: 0.5rem; 38 | left: 6rem; 39 | height: 5rem; 40 | display: flex; 41 | gap: 1rem; 42 | align-items: center; 43 | `; 44 | 45 | const Button = styled.button` 46 | outline: none; 47 | background: #fff; 48 | border: 3px solid #b5d4a8; 49 | border-radius: 10px; 50 | font-size: 1.25rem; 51 | height: 48px; 52 | cursor: pointer; 53 | 54 | &:hover { 55 | border: 3px solid #80a471; 56 | } 57 | `; 58 | 59 | const Checkbox = styled.input` 60 | width: 1.5rem; 61 | height: 1.5rem; 62 | appearance: none; 63 | border: 3px solid #b5d4a8; 64 | background: #fff; 65 | margin-left: -0.6rem; 66 | border-radius: 5px; 67 | &:hover { 68 | border: 3px solid #80a471; 69 | } 70 | &:checked { 71 | border-color: transparent; 72 | background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M5.707 7.293a1 1 0 0 0-1.414 1.414l2 2a1 1 0 0 0 1.414 0l4-4a1 1 0 0 0-1.414-1.414L7 8.586 5.707 7.293z'/%3e%3c/svg%3e"); 73 | background-color: #b5d5b6; 74 | border: 3px solid #e4e4e4; 75 | } 76 | `; 77 | 78 | const StyledP = styled.div` 79 | font-size: 1.2rem; 80 | height: 1.5rem; 81 | margin-left: 0.5rem; 82 | line-height: 1.5rem; 83 | `; 84 | 85 | const SearchFilter = () => { 86 | const [filter, setFilter] = useRecoilState(filterState); 87 | const handleButtonClick = () => { 88 | setFilter({ 89 | ...filter, 90 | solved: '푼 상태', 91 | level: '문제 레벨', 92 | search: '', 93 | }); 94 | }; 95 | const handleCheckbtnClick = (e: React.ChangeEvent) => { 96 | setFilter({ ...filter, check: e.target.checked }); 97 | }; 98 | return ( 99 | 100 | 101 | Select the Problems! 102 | 103 | {FiltersInfo.map((elem, idx) => ( 104 | 105 | ))} 106 | 107 | 108 | 풀이상태 표시 109 | 110 | 111 | 112 | 113 | ); 114 | }; 115 | 116 | export default SearchFilter; 117 | -------------------------------------------------------------------------------- /frontend/src/components/ProblemList/index.ts: -------------------------------------------------------------------------------- 1 | import SearchFilter from './SearchFilter/SearchFilter'; 2 | import List from './List/List'; 3 | 4 | export { SearchFilter, List }; 5 | -------------------------------------------------------------------------------- /frontend/src/components/Ranking/MyInfo.tsx: -------------------------------------------------------------------------------- 1 | import { useUserState } from '../../hooks/useUserState'; 2 | import React, { useEffect, useMemo, useState } from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | const MyInfoWrapper = styled.div` 6 | min-width: 22rem; 7 | width: 22rem; 8 | height: fit-content; 9 | border-radius: 0.75rem; 10 | border: 1px solid #9ccaaf; 11 | margin-top: 2rem; 12 | background: #f1f9eb; 13 | display: flex; 14 | flex-direction: column; 15 | align-items: center; 16 | font-size: 1rem; 17 | font-weight: 400; 18 | `; 19 | 20 | const MyInfoTitle = styled.p` 21 | min-width: 16rem; 22 | width: 16rem; 23 | min-height: 2rem; 24 | height: 2rem; 25 | margin-top: 1.5rem; 26 | font-size: 1.5rem; 27 | font-weight: 600; 28 | `; 29 | 30 | const SingleInfoWrapper = styled.div` 31 | display: flex; 32 | justify-content: space-between; 33 | min-width: 16rem; 34 | width: 16rem; 35 | min-height: 2rem; 36 | height: 2rem; 37 | margin-top: 1.5rem; 38 | 39 | span { 40 | :nth-child(2) { 41 | font-weight: 600; 42 | } 43 | } 44 | 45 | :last-child { 46 | margin-bottom: 1.5rem; 47 | } 48 | `; 49 | 50 | export const MyInfo = ({ rank, count }: { rank: number; count: number }) => { 51 | const { user } = useUserState(); 52 | const { ID } = useMemo(() => user, [user, user.ID]); 53 | const [myRank, setMyRank] = useState(0); 54 | const [mySolved, setMySolved] = useState(0); 55 | 56 | useEffect(() => { 57 | setMyRank(rank); 58 | }, [rank]); 59 | 60 | useEffect(() => { 61 | setMySolved(count); 62 | }, [count]); 63 | 64 | return ( 65 | 66 | 내 정보 67 | 68 | 닉네임 69 | {ID} 70 | 71 | 72 | 현재 순위 73 | {myRank ? myRank : 'unranked'} 74 | 75 | 76 | 푼 문제 수 77 | {mySolved} 78 | 79 | 80 | ); 81 | }; 82 | -------------------------------------------------------------------------------- /frontend/src/components/Ranking/PageController.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { SliderLeft, SliderRight } from '../../assets/icons'; 4 | 5 | type pageType = { 6 | now: number; 7 | max: number; 8 | }; 9 | 10 | interface pageProps { 11 | onClickPage: (page: number) => void; 12 | page: pageType; 13 | } 14 | 15 | const ControllerWrapper = styled.div` 16 | position: absolute; 17 | bottom: 1.5rem; 18 | display: flex; 19 | gap: 1.5rem; 20 | left: 50%; 21 | transform: translate(-50%, 0); 22 | `; 23 | 24 | const SliderImage = styled.img` 25 | height: 2rem; 26 | width: 1rem; 27 | border-radius: 2rem; 28 | object-fit: cover; 29 | cursor: pointer; 30 | `; 31 | 32 | const PageWrapper = styled.div` 33 | display: flex; 34 | gap: 0.7rem; 35 | `; 36 | 37 | const Page = styled.div` 38 | font-size: 2rem; 39 | line-height: 2rem; 40 | font-weight: 400; 41 | color: gray; 42 | cursor: pointer; 43 | `; 44 | 45 | const NowPage = styled(Page)` 46 | font-weight: 700; 47 | color: green; 48 | `; 49 | 50 | const PageController = ({ page, onClickPage }: pageProps) => { 51 | const { now, max } = page; 52 | const handlePageClick = (page: number) => onClickPage(page); 53 | const handleLeftImageClick = () => { 54 | if (now > 1) onClickPage(now - 1); 55 | }; 56 | const handleRightImageClick = () => { 57 | if (now < max) onClickPage(now + 1); 58 | }; 59 | return ( 60 | 61 | 66 | 67 | {/* eslint-disable-next-line prefer-spread */} 68 | {Array.apply(null, new Array(max)).map((e, idx) => 69 | idx + 1 == now ? ( 70 | handlePageClick(idx + 1)}> 71 | {idx + 1} 72 | 73 | ) : ( 74 | handlePageClick(idx + 1)}> 75 | {idx + 1} 76 | 77 | ), 78 | )} 79 | 80 | 85 | 86 | ); 87 | }; 88 | 89 | export default PageController; 90 | -------------------------------------------------------------------------------- /frontend/src/components/Ranking/RankContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import styled from 'styled-components'; 3 | import { RankTable } from './RankTable'; 4 | 5 | const RankWrapper = styled.div` 6 | width: 50rem; 7 | height: 34rem; 8 | min-width: 50rem; 9 | min-height: 34rem; 10 | background: #f1f9eb; 11 | border: 2px solid #9ccaaf; 12 | border-radius: 10px; 13 | margin-top: auto; 14 | margin-bottom: auto; 15 | position: relative; 16 | 17 | p { 18 | font-size: 1.5rem; 19 | font-weight: 600; 20 | margin: 1.5rem 0 0 2.2rem; 21 | } 22 | `; 23 | 24 | export interface UserTableInfo { 25 | rank: number; 26 | ID: string; 27 | count: number; 28 | } 29 | 30 | export const RankContainer = ({ 31 | userList, 32 | }: { 33 | userList: Array; 34 | }) => { 35 | const columns = useMemo( 36 | () => [ 37 | { 38 | accessor: 'rank', 39 | Header: '순위', 40 | }, 41 | { 42 | accessor: 'ID', 43 | Header: 'ID', 44 | }, 45 | { 46 | accessor: 'count', 47 | Header: '푼 문제 수', 48 | }, 49 | ], 50 | [], 51 | ); 52 | const data = useMemo(() => userList, [userList]); 53 | 54 | return ( 55 | 56 |

랭킹

57 | 58 |
59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /frontend/src/components/Ranking/RankTable.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import styled from 'styled-components'; 3 | import { useTable, usePagination } from 'react-table'; 4 | import PageController from './PageController'; 5 | import { UserTableInfo } from './RankContainer'; 6 | 7 | const Table = styled.table` 8 | width: 90%; 9 | background: #ffffff; 10 | border: 1px solid #b9b9b9; 11 | border-radius: 2px; 12 | margin: 1rem auto; 13 | font-weight: 400; 14 | font-size: 1rem; 15 | font-family: Noto Sans KR, sans-serif; 16 | border-collapse: collapse; 17 | 18 | thead { 19 | background: rgba(141, 222, 233, 0.0001); 20 | background: #e7f1e0; 21 | } 22 | 23 | th, 24 | thead { 25 | border: #9b9b9b 2px solid; 26 | } 27 | 28 | tbody { 29 | background: rgba(255, 255, 255, 0.0001); 30 | text-align: center; 31 | } 32 | 33 | tr { 34 | height: 2rem; 35 | } 36 | 37 | td { 38 | border: silver 0.5px solid; 39 | } 40 | `; 41 | 42 | interface TableProps { 43 | columns: { accessor: string; Header: string }[]; 44 | data: Array; 45 | } 46 | 47 | export const RankTable = ({ columns, data }: TableProps) => { 48 | const [page, setPage] = useState({ 49 | now: 1, 50 | max: Math.ceil(data.length / 10), 51 | }); 52 | const [pagedList, setPagedList] = useState(data); 53 | 54 | useEffect(() => { 55 | const { now } = page; 56 | now && setPagedList([...data.slice(10 * (now - 1), 10 * now)]); 57 | }, [page]); 58 | 59 | useEffect(() => { 60 | setPage({ 61 | now: 1, 62 | max: Math.ceil(data.length / 10), 63 | }); 64 | }, [data]); 65 | 66 | const tableInstance = useTable( 67 | { 68 | // @ts-ignore 69 | columns, 70 | data: pagedList, 71 | }, 72 | usePagination, 73 | ); 74 | 75 | const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = 76 | tableInstance; 77 | 78 | return ( 79 | <> 80 | 81 | 82 | {headerGroups.map((headerGroup) => ( 83 | // eslint-disable-next-line react/jsx-key 84 | 85 | {headerGroup.headers.map((column) => ( 86 | // eslint-disable-next-line react/jsx-key 87 | 88 | ))} 89 | 90 | ))} 91 | 92 | 93 | {rows.map((row) => { 94 | prepareRow(row); 95 | return ( 96 | // eslint-disable-next-line react/jsx-key 97 | 98 | {row.cells.map((cell) => { 99 | return ( 100 | // eslint-disable-next-line react/jsx-key 101 | 102 | ); 103 | })} 104 | 105 | ); 106 | })} 107 | 108 |
{column.render('Header')}
{cell.render('Cell')}
109 | setPage({ ...page, now })} 112 | /> 113 | 114 | ); 115 | }; 116 | -------------------------------------------------------------------------------- /frontend/src/components/SignIn/InputForm.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | IDInputContainer, 3 | InputFormContainer, 4 | PasswordInputContainer, 5 | AnchorLogo, 6 | GreenMark, 7 | TextLink, 8 | InfoContainer, 9 | } from '../../styles/SignIn.style'; 10 | import React, { useCallback, useMemo, useState, useRef } from 'react'; 11 | import { useNavigate, useParams } from 'react-router-dom'; 12 | import { useUserState } from '../../hooks/useUserState'; 13 | 14 | export const SigninInputForm = () => { 15 | const [isLoading, setLoading] = useState(false); 16 | const id = useRef(null); 17 | const password = useRef(null); 18 | const requestURL = useMemo( 19 | () => import.meta.env.VITE_SERVER_URL + '/auth/signin', 20 | [], 21 | ); 22 | const navigate = useNavigate(); 23 | const { loginHandler } = useUserState(); 24 | const { version } = useParams(); 25 | 26 | const handleSubmit = useCallback( 27 | async (e: React.FormEvent) => { 28 | e.preventDefault(); 29 | setLoading(true); 30 | fetch(requestURL, { 31 | method: 'POST', 32 | headers: { 33 | 'Content-Type': 'application/json', 34 | }, 35 | body: JSON.stringify({ 36 | loginId: id.current?.value, 37 | password: password.current?.value, 38 | }), 39 | }) 40 | .then((res) => res.json()) 41 | .then((data) => { 42 | setLoading(false); 43 | if (data.msg === 'success') { 44 | const expirationTime = new Date( 45 | new Date().getTime() + data.effectiveTime, 46 | ).toISOString(); 47 | loginHandler(data.accessToken, expirationTime, data.userId); 48 | !version && navigate(-1); 49 | return; 50 | } 51 | alert('로그인에 실패하였습니다.'); 52 | }) 53 | .catch(() => { 54 | setLoading(false); 55 | alert('로그인에 실패하였습니다.'); 56 | }); 57 | }, 58 | [id, password, requestURL], 59 | ); 60 | 61 | return ( 62 | <> 63 | 64 | 65 | Signin to 66 |
67 | CamperRank 68 |
69 | ↪ Go to Signup 70 |
71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | {isLoading ? ( 81 | sending... 82 | ) : ( 83 | 84 | )} 85 | 86 | 87 | ); 88 | }; 89 | -------------------------------------------------------------------------------- /frontend/src/hooks/useInput.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | const useInput = (initialState: string) => { 4 | const [value, setValue] = useState(initialState); 5 | const onChange = (event: React.ChangeEvent) => { 6 | setValue(event.target.value); 7 | }; 8 | const onReset = () => setValue(initialState); 9 | return { value, onChange, onReset }; 10 | }; 11 | 12 | export default useInput; 13 | -------------------------------------------------------------------------------- /frontend/src/hooks/useModal.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | const useModal = () => { 4 | const [isShowing, setIsShowing] = useState(false); 5 | 6 | function toggle() { 7 | setIsShowing(!isShowing); 8 | } 9 | 10 | return { 11 | isShowing, 12 | toggle, 13 | }; 14 | }; 15 | 16 | export default useModal; 17 | -------------------------------------------------------------------------------- /frontend/src/hooks/useUserState.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from 'react'; 2 | import { 3 | calculateRemainingTime, 4 | getLocalToken, 5 | removeLocalToken, 6 | setLocalToken, 7 | } from '../utils/userUtil'; 8 | import { useRecoilState } from 'recoil'; 9 | import { userState } from '../recoils'; 10 | 11 | let logoutTimer: any; 12 | // const URL = import.meta.env.VITE_SERVER_URL; 13 | export const useUserState = () => { 14 | const [user, setUser] = useRecoilState(userState); 15 | const { token, camperID, expirationTime } = getLocalToken(); 16 | const remainingTime = 17 | expirationTime === null ? 0 : calculateRemainingTime(expirationTime); 18 | 19 | const logoutHandler = useCallback(() => { 20 | removeLocalToken(); 21 | setUser({ 22 | token: '', 23 | isLoggedIn: false, 24 | ID: '', 25 | }); 26 | if (logoutTimer) { 27 | clearTimeout(logoutTimer); 28 | } 29 | }, []); 30 | 31 | const loginHandler = useCallback( 32 | (token: string, expirationTime: string, camperID: string) => { 33 | const remainingTime = 34 | expirationTime === null ? 0 : calculateRemainingTime(expirationTime); 35 | // @ts-ignore 36 | setLocalToken(token, expirationTime, camperID); 37 | setUser({ 38 | // @ts-ignore 39 | token, 40 | isLoggedIn: true, 41 | // @ts-ignore 42 | ID: camperID, 43 | }); 44 | logoutTimer = setTimeout(logoutHandler, remainingTime); 45 | }, 46 | [], 47 | ); 48 | 49 | useEffect(() => { 50 | const logoutCond = 51 | !token || !camperID || !expirationTime || remainingTime <= 0; 52 | if (logoutCond) { 53 | if (user.isLoggedIn) { 54 | logoutHandler(); 55 | } 56 | return; 57 | } 58 | if (user.isLoggedIn) { 59 | return; 60 | } 61 | loginHandler(token, expirationTime, camperID); 62 | }, []); 63 | 64 | // useEffect(() => { 65 | // if (!user.isLoggedIn || !user.ID || !user.token) { 66 | // return; 67 | // } 68 | // fetch(`${URL}/auth/jwtLogin`, { 69 | // method: 'POST', 70 | // headers: { 71 | // Authorization: 'Bearer ' + user.token, 72 | // }, 73 | // }) 74 | // .then((res) => res.json()) 75 | // .then((res) => { 76 | // if (res.userId === user.ID) { 77 | // return; 78 | // } 79 | // logoutHandler(); 80 | // }); 81 | // }, []); 82 | 83 | return { 84 | user, 85 | loginHandler, 86 | logoutHandler, 87 | }; 88 | }; 89 | -------------------------------------------------------------------------------- /frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App'; 4 | 5 | import { RecoilRoot } from 'recoil'; 6 | import { GlobalStyle } from './styles/GlobalStyle'; 7 | 8 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 9 | <> 10 | 11 | 12 | 13 | 14 | , 15 | ); 16 | -------------------------------------------------------------------------------- /frontend/src/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { Banner, List } from '../components/Home'; 4 | import { MainHeader } from '../components/MainHeader'; 5 | import { Footer } from '../components/Footer'; 6 | import { useUserState } from '../hooks/useUserState'; 7 | 8 | const MainWrapper = styled.div` 9 | width: 100%; 10 | height: fit-content; 11 | min-width: 80rem; 12 | margin: 0 auto; 13 | display: flex; 14 | flex-direction: column; 15 | align-items: center; 16 | `; 17 | 18 | const HeaderWrapper = styled.div` 19 | min-width: 80rem; 20 | width: 80rem; 21 | min-height: 8rem; 22 | height: 8rem; 23 | `; 24 | 25 | const BannerWrapper = styled.div` 26 | width: 100%; 27 | height: 15rem; 28 | overflow: hidden; 29 | position: relative; 30 | `; 31 | 32 | const ListWrapper = styled.div` 33 | min-width: 80rem; 34 | width: 80rem; 35 | height: 55rem; 36 | `; 37 | 38 | const FooterWrapper = styled.div` 39 | min-width: 80rem; 40 | width: 80rem; 41 | min-height: 15rem; 42 | height: 15rem; 43 | `; 44 | 45 | const Main = () => { 46 | useUserState(); 47 | return ( 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 |