├── .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 |
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 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
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 |
4 |
--------------------------------------------------------------------------------
/frontend/src/assets/copy-icon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/src/assets/hamburger.svg:
--------------------------------------------------------------------------------
1 |
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 |
5 |
--------------------------------------------------------------------------------
/frontend/src/assets/icons/Refresh_icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
93 |
--------------------------------------------------------------------------------
/frontend/src/assets/icons/SelectButton.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/frontend/src/assets/icons/SliderLeft.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/frontend/src/assets/icons/SliderRight.svg:
--------------------------------------------------------------------------------
1 |
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 |
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 |
78 | ))}
79 |
80 | >
81 | );
82 | };
83 | export default PageButtons;
84 |
--------------------------------------------------------------------------------
/frontend/src/components/Problem/Buttons/index.ts:
--------------------------------------------------------------------------------
1 | import PageButtons from './PageButtons';
2 | import ProblemButtons from './ProblemButtons';
3 |
4 | export { PageButtons, ProblemButtons };
5 |
--------------------------------------------------------------------------------
/frontend/src/components/Problem/Content.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { useParams, useNavigate } from 'react-router-dom';
3 | import styled from 'styled-components';
4 | import { ProblemType } from '@types';
5 |
6 | const ContentWrapper = styled.div`
7 | border: 3px double #cbcbcb;
8 | border-radius: 5px;
9 | width: 100%;
10 | margin-top: 2rem;
11 | padding: 1.5rem;
12 | background: #f5fdf8;
13 | height: fit-content;
14 | min-height: 75%;
15 | `;
16 |
17 | const Level = styled.div`
18 | position: absolute;
19 | top: -0.7rem;
20 | left: 0;
21 | font-weight: bold;
22 | font-size: 1rem;
23 | padding: 1rem;
24 | `;
25 |
26 | const ProblemDummy = `
27 |
28 |
문제 내용
29 |
A와 B의 합을 출력하시오
30 |
입력 형태
31 |
A와 B가 담긴 배열을 요소로 가지는 배열
32 |
출력 형태
33 |
A와 B의 합을 요소로 가지는 배열
34 |
제한 사항
35 |
0 <= A <= 10
36 |
0 <= B <= 10
37 |
예시 입력#1
38 |
[[3, 5], [4, 4], [10, 10]]
39 |
예시 출력#1
40 |
[8, 8, 20]
41 |
예시 입력#2
42 |
[[9, 9], [8, 8]]
43 |
예시 출력#2
44 |
[18, 16]
45 |
46 |
47 | `;
48 |
49 | const ProblemContent = ({ problem }: ProblemType) => {
50 | if (!problem) return <>>;
51 | const { level, description } = problem;
52 | return (
53 | <>
54 | LV. {level}
55 |
62 | >
63 | );
64 | };
65 |
66 | export default ProblemContent;
67 |
--------------------------------------------------------------------------------
/frontend/src/components/Problem/InviteModal.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { ReactComponent as Copy } from '../../assets/copy-icon.svg';
3 | import { useCallback, useRef } from 'react';
4 | import { ReactComponent as Refresh } from '../../assets/icons/Refresh_icon.svg';
5 | import { useNavigate, useParams } from 'react-router-dom';
6 | import uuid from 'react-uuid';
7 |
8 | interface props {
9 | isShowing: boolean;
10 | }
11 |
12 | const Wrapper = styled.div`
13 | position: absolute;
14 | left: 4rem;
15 | transform: translateY(-100%);
16 | background-color: rgb(255, 255, 255);
17 | width: 55rem;
18 | height: 6rem;
19 | z-index: 99;
20 | border: 1px solid;
21 | border-radius: 8px;
22 | box-shadow: 0 2px 3px 0 rgba(34, 36, 38, 0.15);
23 |
24 | p {
25 | margin: 0.5rem 1rem;
26 | font-weight: 600;
27 | font-family: Noto Sans KR, sans-serif;
28 | font-size: 1rem;
29 | }
30 | `;
31 |
32 | const URLWrapper = styled.div`
33 | width: 55rem;
34 | margin-left: 1rem;
35 | height: calc(100% - 37px);
36 | display: flex;
37 | justify-content: left;
38 | align-items: center;
39 | `;
40 |
41 | const URLContainer = styled.div`
42 | width: 90%;
43 | height: 75%;
44 | border: 0;
45 | border-radius: 16px;
46 | outline: none;
47 | padding-left: 10px;
48 | background-color: rgb(233, 233, 233);
49 | display: flex;
50 | align-items: center;
51 | font-weight: 500;
52 | -webkit-user-select: text;
53 | -moz-user-select: text;
54 | -ms-user-select: text;
55 | user-select: text;
56 | `;
57 |
58 | const ButtonWrapper = styled.button`
59 | margin-left: 0.3rem;
60 | width: 3%;
61 | height: 50%;
62 | cursor: pointer;
63 | background: #ffffff;
64 | border: 2px solid #33c363;
65 | box-shadow: 0 8px 24px rgb(51 195 99 / 50%);
66 | border-radius: 4px;
67 | padding: 0;
68 |
69 | :active {
70 | background: #dbf6e4;
71 | box-shadow: 0 5px #666;
72 | transform: translateY(4px);
73 | }
74 |
75 | :hover {
76 | background: #dbf6e4;
77 | }
78 | `;
79 |
80 | export const InviteModal = ({ isShowing }: props) => {
81 | const url = useRef(null);
82 | const navigate = useNavigate();
83 | const { id } = useParams();
84 |
85 | const handleCopy = useCallback(() => {
86 | navigator.clipboard.writeText(url.current!.innerText);
87 | }, [url.current]);
88 |
89 | const handleChange = useCallback(() => {
90 | const newPath = btoa(uuid());
91 | localStorage.setItem(`problem${id}`, newPath);
92 | navigate(`/problem/multi/${id}/${newPath}`);
93 | navigate(0);
94 | }, []);
95 |
96 | return isShowing ? (
97 |
98 | 초대 URL
99 |
100 | {`${window.location.href}`}
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 | ) : null;
110 | };
111 |
--------------------------------------------------------------------------------
/frontend/src/components/Problem/_Editor.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef, useEffect } from 'react';
2 | import styled from 'styled-components';
3 | import ReservedWords from '../../utils/ReservedWords';
4 |
5 | // OLD VERSION
6 |
7 | const EditorWrapper = styled.div`
8 | display: flex;
9 | height: calc(100% - 2rem);
10 | overflow-x: hidden;
11 | overflow-y: auto;
12 | position: relative;
13 | `;
14 |
15 | const Code = styled.div`
16 | width: 95%;
17 | padding: 0.3rem;
18 | font-size: 0.9rem;
19 | outline: none;
20 | min-height: 100%;
21 | height: fit-content;
22 | border: 2px double #888888;
23 | border-radius: 3px;
24 | `;
25 |
26 | const CodeEditor = styled(Code)`
27 | &:focus {
28 | border: 2px double #888888;
29 | }
30 | z-index: 1;
31 | opacity: 0.5;
32 | `;
33 |
34 | const CodePrinter = styled(Code)`
35 | font-weight: 500;
36 | position: absolute;
37 | right: 0;
38 | span {
39 | color: red;
40 | }
41 | `;
42 |
43 | const Title = styled.div`
44 | font-weight: 600;
45 | font-size: 0.8rem;
46 | padding: 0.5rem;
47 | margin-left: 1rem;
48 | `;
49 |
50 | const LineWrapper = styled.div`
51 | flex-grow: 1;
52 | padding-top: 0.4rem;
53 | text-align: right;
54 | p {
55 | font-size: 0.9rem;
56 | color: #888888;
57 | margin-right: 0.5rem;
58 | }
59 | `;
60 |
61 | const Editor = () => {
62 | const [code, setCode] = useState('');
63 | const [line, setLine] = useState(0);
64 | const editorRef = useRef(null);
65 | const printerRef = useRef(null);
66 | const countEscape = (str: string) => {
67 | let count = 0;
68 | let hasChar = false;
69 | for (const char of str) {
70 | if (char === '\n') count++;
71 | else hasChar = true;
72 | }
73 | if (hasChar) count++;
74 | return count;
75 | };
76 |
77 | useEffect(() => {
78 | if (code === '') {
79 | if (printerRef && printerRef.current) {
80 | printerRef.current.innerHTML = '';
81 | }
82 | }
83 | const removedCode = code.replaceAll('\n\n', '\n');
84 | setLine(countEscape(removedCode));
85 | let editorHTML = editorRef.current?.innerHTML;
86 | ReservedWords.map((elem) => {
87 | editorHTML = editorHTML?.replace(
88 | new RegExp(`\\b${elem}\\b`, 'g'),
89 | `${elem}`,
90 | );
91 | });
92 | if (editorHTML && printerRef && printerRef.current) {
93 | printerRef.current.innerHTML = editorHTML;
94 | }
95 | }, [code]);
96 |
97 | return (
98 | <>
99 | Solution
100 |
101 |
102 | {/* eslint-disable-next-line prefer-spread */}
103 | {Array.apply(null, new Array(line)).map((e, idx) => (
104 | {idx + 1}
105 | ))}
106 |
107 | ) => {
112 | const target = e.target as HTMLDivElement;
113 | setCode(target.innerText);
114 | }}
115 | >
116 |
117 |
118 | >
119 | );
120 | };
121 |
122 | export default Editor;
123 |
--------------------------------------------------------------------------------
/frontend/src/components/Problem/index.tsx:
--------------------------------------------------------------------------------
1 | import ProblemContent from './Content';
2 | import Result from './Result';
3 | export { ProblemContent, Result };
4 |
--------------------------------------------------------------------------------
/frontend/src/components/ProblemHeader.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | HeaderContainer,
3 | AnchorLogo,
4 | GreenMark,
5 | MenuContainer,
6 | } from '../styles/ProblemHeader.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 { ReactComponent as Greater } from '../assets/Greater.svg';
12 | import { useUserState } from '../hooks/useUserState';
13 |
14 | interface propsType {
15 | URL: string;
16 | problemName: string;
17 | type: number;
18 | roomNumber?: string;
19 | //0: 문제풀이 페이지
20 | //1: 질문 페이지
21 | }
22 |
23 | export const ProblemHeader = ({ URL, problemName, type }: propsType) => {
24 | const [user, setUser] = useRecoilState(userState);
25 | const { logoutHandler } = useUserState();
26 | const handleLogoutClick = useCallback(
27 | (/*e: React.MouseEvent*/) => {
28 | if (!user.isLoggedIn) {
29 | return;
30 | }
31 | logoutHandler();
32 | },
33 | [user, setUser, user.isLoggedIn],
34 | );
35 |
36 | return (
37 |
38 |
39 | CamperRank
40 |
41 |
42 |
43 | -
44 | 문제 리스트
45 |
46 | -
47 |
48 |
49 | -
50 | {problemName}
51 |
52 | {!!type && (
53 | <>
54 | -
55 |
56 |
57 | -
58 | 질문
59 |
60 | >
61 | )}
62 |
63 |
64 |
65 |
66 |
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 | {column.render('Header')} |
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 | {cell.render('Cell')} |
102 | );
103 | })}
104 |
105 | );
106 | })}
107 |
108 |
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 |
60 |
61 |
62 | );
63 | };
64 |
65 | export default Main;
66 |
--------------------------------------------------------------------------------
/frontend/src/pages/ProblemList.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import styled from 'styled-components';
3 | import { useRecoilState } from 'recoil';
4 | import { MainHeader } from '../components/MainHeader';
5 | import { SearchFilter, List } from '../components/ProblemList';
6 | import { Footer } from '../components/Footer';
7 | import { filterState } from '../recoils';
8 | import { ProblemInfo } from '@types';
9 | import { userState } from '../recoils';
10 |
11 | const URL = import.meta.env.VITE_SERVER_URL;
12 |
13 | const MainWrapper = styled.div`
14 | width: 100%;
15 | margin: 0 auto;
16 | display: flex;
17 | flex-direction: column;
18 | align-items: center;
19 | min-width: 80rem;
20 | min-height: 123rem;
21 | height: 123rem;
22 | `;
23 |
24 | const HeaderWrapper = styled.div`
25 | min-width: 80rem;
26 | width: 80rem;
27 | min-height: 8rem;
28 | height: 8rem;
29 | `;
30 |
31 | const ListWrapper = styled.div`
32 | width: 100%;
33 | height: 90rem;
34 | background: #f1f5ee;
35 | display: flex;
36 | `;
37 |
38 | const FooterWrapper = styled.div`
39 | width: 100%;
40 | height: 20rem;
41 | `;
42 |
43 | const ProblemList = () => {
44 | const [filter, setFilter] = useRecoilState(filterState);
45 | const [list, setList] = useState([]);
46 | const [filtered, setFiltered] = useState([]);
47 | const [user] = useRecoilState(userState);
48 |
49 | useEffect(() => {
50 | const { ID } = user;
51 | const fetchURL = ID ? `${URL}/problem?loginId=${ID}` : `${URL}/problem`;
52 | setFilter({
53 | solved: '푼 상태',
54 | level: '문제 레벨',
55 | search: '',
56 | check: false,
57 | });
58 | fetch(fetchURL)
59 | .then((res) => res.json())
60 | .then((res) => {
61 | setList(Object.values(res));
62 | });
63 | }, [user]);
64 |
65 | useEffect(() => {
66 | const { solved, level, search } = filter;
67 | let filtered = [...list];
68 | if (level && level !== '문제 레벨')
69 | filtered = filtered.filter((elem) => elem.level === +level.slice(-1));
70 | if (search && search !== '')
71 | filtered = filtered.filter((elem) => {
72 | if (elem.title)
73 | return elem.title.toUpperCase().includes(search.toUpperCase());
74 | else return false;
75 | });
76 | if (solved && solved !== '푼 상태')
77 | filtered = filtered.filter((elem) => {
78 | return solved === '푼 문제'
79 | ? elem.isSolved === true
80 | : elem.isSolved === false;
81 | });
82 |
83 | setFiltered(filtered);
84 | }, [filter, list]);
85 |
86 | return (
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 | );
100 | };
101 |
102 | export default ProblemList;
103 |
--------------------------------------------------------------------------------
/frontend/src/pages/Profile.tsx:
--------------------------------------------------------------------------------
1 | import { MainHeader } from '../components/MainHeader';
2 | import { Footer } from '../components/Footer';
3 | import React from 'react';
4 | import styled from 'styled-components';
5 |
6 | const Wrapper = styled.div`
7 | width: 100%;
8 | min-height: 66rem;
9 | margin: 0 auto;
10 | display: flex;
11 | flex-direction: column;
12 | align-items: center;
13 | `;
14 |
15 | const HeaderWrapper = styled.div`
16 | min-width: 80rem;
17 | min-height: 8rem;
18 | height: 8rem;
19 | `;
20 |
21 | const ContentWrapper = styled.div`
22 | min-width: 80rem;
23 | flex: 1;
24 | background: #e5ebdf;
25 | `;
26 |
27 | const FooterWrapper = styled.div`
28 | min-width: 80rem;
29 | min-height: 20rem;
30 | `;
31 |
32 | export const Profile = () => {
33 | return (
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | );
44 | };
45 |
--------------------------------------------------------------------------------
/frontend/src/pages/Ranking.tsx:
--------------------------------------------------------------------------------
1 | import { MainHeader } from '../components/MainHeader';
2 | import { Footer } from '../components/Footer';
3 | import React, { useEffect, useMemo, useState } from 'react';
4 | import styled from 'styled-components';
5 | import { useUserState } from '../hooks/useUserState';
6 | import { MyInfo } from '../components/Ranking/MyInfo';
7 | import {
8 | RankContainer,
9 | UserTableInfo,
10 | } from '../components/Ranking/RankContainer';
11 |
12 | const Wrapper = styled.div`
13 | width: 100%;
14 | min-width: 80rem;
15 | min-height: 66rem;
16 | height: 66rem;
17 | margin: 0 auto;
18 | display: flex;
19 | flex-direction: column;
20 | align-items: center;
21 | `;
22 |
23 | const HeaderWrapper = styled.div`
24 | min-width: 80rem;
25 | width: 80rem;
26 | min-height: 8rem;
27 | height: 8rem;
28 | `;
29 |
30 | const ContentWrapper = styled.div`
31 | display: flex;
32 | min-width: 80rem;
33 | width: 80rem;
34 | min-height: 38rem;
35 | height: 38rem;
36 | background: #e5ebdf;
37 | justify-content: space-around;
38 | `;
39 |
40 | const FooterWrapper = styled.div`
41 | min-width: 80rem;
42 | width: 80rem;
43 | min-height: 16rem;
44 | height: 16rem;
45 | `;
46 |
47 | interface UserSolvedInfo {
48 | loginId: string;
49 | solvedCount: number;
50 | }
51 |
52 | interface RankFetchResult {
53 | string: UserSolvedInfo;
54 | }
55 |
56 | const compare = (a: UserSolvedInfo, b: UserSolvedInfo) =>
57 | b.solvedCount - a.solvedCount;
58 |
59 | const URL = import.meta.env.VITE_SERVER_URL;
60 |
61 | export const Ranking = () => {
62 | const { user } = useUserState();
63 | const { isLoggedIn } = useMemo(() => user, [user, user.isLoggedIn]);
64 | const [userList, setUserList] = useState>([]);
65 | const [myRank, setMyRank] = useState(0);
66 | const [mySolved, setMySolved] = useState(0);
67 |
68 | useEffect(() => {
69 | fetch(`${URL}/rank`, {})
70 | .then((res) => res.json())
71 | .then((res: RankFetchResult) => {
72 | const tempUserList: Array = Object.values(res)
73 | .sort(compare)
74 | .map((ele: UserSolvedInfo, idx) => {
75 | return {
76 | rank: idx + 1,
77 | ID: ele.loginId,
78 | count: ele.solvedCount,
79 | };
80 | });
81 | setUserList(tempUserList);
82 | });
83 | }, []);
84 |
85 | useEffect(() => {
86 | const myInfo = userList.find((ele) => ele.ID === user.ID);
87 | if (!myInfo) {
88 | return;
89 | }
90 | setMyRank(myInfo.rank);
91 | setMySolved(myInfo.count);
92 | }, [userList]);
93 |
94 | return (
95 |
96 |
97 |
98 |
99 |
100 | {isLoggedIn && }
101 |
102 |
103 |
104 |
105 |
106 |
107 | );
108 | };
109 |
--------------------------------------------------------------------------------
/frontend/src/pages/Sign.tsx:
--------------------------------------------------------------------------------
1 | import { Footer } from '../components/Footer';
2 | import React, { useEffect } from 'react';
3 | import styled from 'styled-components';
4 | import { SignupInputForm } from '../components/SignUp/InputForm';
5 | import { SigninInputForm } from '../components/SignIn/InputForm';
6 | import { useLocation } from 'react-router-dom';
7 |
8 | const MainWrapper = styled.div`
9 | width: 100%;
10 | min-width: 80rem;
11 | height: 100vh;
12 | margin: 0 auto;
13 | display: flex;
14 | flex-direction: column;
15 | align-items: center;
16 | background: #eef9f1;
17 | `;
18 |
19 | const ContentWrapper = styled.div`
20 | display: flex;
21 | min-width: 80rem;
22 | width: 80rem;
23 | flex-grow: 1;
24 | justify-content: space-around;
25 | align-items: center;
26 | `;
27 |
28 | const FooterWrapper = styled.div`
29 | min-width: 80rem;
30 | width: 80rem;
31 | min-height: 16rem;
32 | height: 16rem;
33 | `;
34 |
35 | export const Sign = () => {
36 | const { pathname } = useLocation();
37 | return (
38 |
39 |
40 | {pathname.includes('signup') ? (
41 |
42 | ) : (
43 |
44 | )}
45 |
46 |
47 |
48 |
49 |
50 | );
51 | };
52 |
--------------------------------------------------------------------------------
/frontend/src/pages/SignIn.tsx:
--------------------------------------------------------------------------------
1 | import { MainHeader } from '../components/MainHeader';
2 | import { Footer } from '../components/Footer';
3 | import React, { useEffect } from 'react';
4 | import styled from 'styled-components';
5 | import { SigninInputForm } from '../components/SignIn/InputForm';
6 | import { useRecoilValue } from 'recoil';
7 | import { userState } from '../recoils';
8 | import { useNavigate } from 'react-router-dom';
9 | import { useUserState } from '../hooks/useUserState';
10 |
11 | const MainWrapper = styled.div`
12 | width: 100%;
13 | height: auto;
14 | margin: 0 auto;
15 | display: flex;
16 | flex-direction: column;
17 | `;
18 |
19 | const HeaderWrapper = styled.div`
20 | width: 100%;
21 | height: 8rem;
22 | box-sizing: border-box;
23 | `;
24 |
25 | const ContentWrapper = styled.div`
26 | width: 100%;
27 | flex: 1;
28 | box-sizing: border-box;
29 | background: #e4e8e0;
30 | display: flex;
31 | justify-content: center;
32 | `;
33 |
34 | const FooterWrapper = styled.div`
35 | width: 100%;
36 | height: 400px;
37 | box-sizing: border-box;
38 | `;
39 |
40 | export const SignIn = () => {
41 | const user = useRecoilValue(userState);
42 | const navigate = useNavigate();
43 | useUserState();
44 | useEffect(() => {
45 | if (!user.isLoggedIn) {
46 | return;
47 | }
48 | navigate(-1);
49 | }, []);
50 |
51 | return (
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 | );
64 | };
65 |
--------------------------------------------------------------------------------
/frontend/src/pages/SignUp.tsx:
--------------------------------------------------------------------------------
1 | import { MainHeader } from '../components/MainHeader';
2 | import { Footer } from '../components/Footer';
3 | import React from 'react';
4 | import styled from 'styled-components';
5 | import { SignupInputForm } from '../components/SignUp/InputForm';
6 |
7 | const MainWrapper = styled.div`
8 | width: 100%;
9 | min-width: 80rem;
10 | height: 100vh;
11 | margin: 0 auto;
12 | display: flex;
13 | flex-direction: column;
14 | align-items: center;
15 | background: #eef9f1;
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 ContentWrapper = styled.div`
26 | display: flex;
27 | min-width: 80rem;
28 | width: 80rem;
29 | flex-grow: 1;
30 | justify-content: space-around;
31 | `;
32 |
33 | const FooterWrapper = styled.div`
34 | min-width: 80rem;
35 | width: 80rem;
36 | min-height: 16rem;
37 | height: 16rem;
38 | `;
39 |
40 | export const SignUp = () => {
41 | return (
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | );
54 | };
55 |
--------------------------------------------------------------------------------
/frontend/src/pages/index.ts:
--------------------------------------------------------------------------------
1 | import Home from './Home';
2 | import ProblemList from './ProblemList';
3 | import Problem from './Problem';
4 | import { Ranking } from './Ranking';
5 | import { Profile } from './Profile';
6 | import { Sign } from './Sign';
7 | export { Sign, Problem, Home, ProblemList, Ranking, Profile };
8 |
--------------------------------------------------------------------------------
/frontend/src/recoils/editorState.tsx:
--------------------------------------------------------------------------------
1 | import { atom } from 'recoil';
2 |
3 | interface Editor {
4 | text: string;
5 | language: string;
6 | }
7 |
8 | export const editorState = atom({
9 | key: 'editorState',
10 | default: {
11 | text: '',
12 | language: '',
13 | },
14 | });
15 |
--------------------------------------------------------------------------------
/frontend/src/recoils/filterState.ts:
--------------------------------------------------------------------------------
1 | import { atom } from 'recoil';
2 |
3 | interface Filter {
4 | solved?: string;
5 | level?: string;
6 | search?: string;
7 | check: boolean;
8 | }
9 |
10 | export const filterState = atom({
11 | key: 'filterState',
12 | default: {
13 | solved: '푼 상태',
14 | level: '문제 레벨',
15 | search: '',
16 | check: false,
17 | },
18 | });
19 |
--------------------------------------------------------------------------------
/frontend/src/recoils/gradingState.tsx:
--------------------------------------------------------------------------------
1 | import { atom } from 'recoil';
2 |
3 | type TestCase = {
4 | testCaseNumber?: string;
5 | userPrint?: string;
6 | resultCode?: number;
7 | userAnswer?: string;
8 | };
9 |
10 | type Result = {
11 | [key: number]: TestCase;
12 | statusCode?: number;
13 | solvedId?: number;
14 | solvedResult?: string;
15 | };
16 |
17 | interface Grading {
18 | status: string;
19 | result?: Result;
20 | kind?: string;
21 | }
22 |
23 | export const gradingState = atom({
24 | key: 'gradingState',
25 | default: {
26 | status: 'ready',
27 | },
28 | });
29 |
--------------------------------------------------------------------------------
/frontend/src/recoils/index.ts:
--------------------------------------------------------------------------------
1 | import { filterState } from './filterState';
2 | import { editorState } from './editorState';
3 | import { userState } from './userState';
4 | import { gradingState } from './gradingState';
5 | import { socketState } from './socketState';
6 |
7 | export { filterState, editorState, userState, gradingState, socketState };
8 |
--------------------------------------------------------------------------------
/frontend/src/recoils/socketState.tsx:
--------------------------------------------------------------------------------
1 | import { atom } from 'recoil';
2 | import { Socket } from 'socket.io-client';
3 |
4 | export const socketState = atom({
5 | key: 'socketState',
6 | default: undefined,
7 | dangerouslyAllowMutability: true,
8 | });
9 |
--------------------------------------------------------------------------------
/frontend/src/recoils/userState.tsx:
--------------------------------------------------------------------------------
1 | import { atom } from 'recoil';
2 |
3 | interface userInfo {
4 | token: string;
5 | isLoggedIn: boolean;
6 | ID: string;
7 | }
8 |
9 | export const userState = atom({
10 | key: 'user',
11 | default: {
12 | token: '',
13 | isLoggedIn: false,
14 | ID: '',
15 | },
16 | });
17 |
--------------------------------------------------------------------------------
/frontend/src/styles/Footer.style.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const FooterContainer = styled.div`
4 | box-sizing: border-box;
5 | width: 100%;
6 | height: 100%;
7 | text-align: center;
8 | display: flex;
9 | flex-direction: column;
10 | justify-content: space-between;
11 | padding-top: 96px;
12 | padding-bottom: 96px;
13 | `;
14 |
15 | export const KeyPhrase = styled.p`
16 | width: 100%;
17 | font-weight: 700;
18 | font-size: 32px;
19 | text-align: center;
20 | color: #0a142f;
21 | `;
22 |
23 | export const MainText = styled.p`
24 | width: 100%;
25 | font-weight: 500;
26 | font-size: 16px;
27 | color: #0a142f;
28 | opacity: 0.8;
29 | `;
30 |
31 | export const ButtonContainer = styled.div`
32 | width: 100%;
33 | display: flex;
34 | justify-content: center;
35 |
36 | a {
37 | cursor: pointer;
38 | width: 112px;
39 | height: 36px;
40 | box-sizing: border-box;
41 | background: #f7f9fb;
42 | border: 1px solid #888888;
43 | border-radius: 20px;
44 | text-decoration: none;
45 | font-family: Noto Sans KR, sans-serif;
46 | font-weight: 400;
47 | font-size: 16px;
48 | color: #000000;
49 | display: flex;
50 | align-items: center;
51 | justify-content: center;
52 | margin: 0 8px;
53 | }
54 | `;
55 |
--------------------------------------------------------------------------------
/frontend/src/styles/GlobalStyle.tsx:
--------------------------------------------------------------------------------
1 | import { createGlobalStyle } from 'styled-components';
2 |
3 | export const GlobalStyle = createGlobalStyle`
4 |
5 | * {
6 | margin: 0;
7 | font-family: Noto Sans KR, sans-serif;
8 | list-style: none;
9 | color: #000000;
10 | box-sizing: border-box;
11 | }
12 | #root{
13 | height: 100vh;
14 | }
15 | button {
16 | cursor: pointer;
17 | }
18 | `;
19 |
--------------------------------------------------------------------------------
/frontend/src/styles/MainHeader.style.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { Link } from 'react-router-dom';
3 |
4 | export const MainHeaderContainer = styled.div`
5 | box-sizing: border-box;
6 | width: 100%;
7 | min-width: 80rem;
8 | padding: 0 6rem;
9 | height: 100%;
10 | display: flex;
11 | justify-content: space-between;
12 | align-items: center;
13 | nav {
14 | margin-right: 11rem;
15 |
16 | ul {
17 | justify-content: space-between;
18 | align-items: center;
19 |
20 | li {
21 | display: inline;
22 | margin-left: 6rem;
23 |
24 | a {
25 | text-decoration: none;
26 | font-weight: 600;
27 | font-size: 1.5rem;
28 | color: #000000;
29 | }
30 | }
31 | }
32 | }
33 | `;
34 |
35 | export const AnchorLogo = styled(Link)`
36 | font-weight: 700;
37 | font-size: 2rem;
38 | cursor: pointer;
39 | text-decoration: none;
40 | `;
41 |
42 | export const GreenMark = styled.mark`
43 | color: #1f7a41;
44 | background: none;
45 | `;
46 |
47 | export const MenuContainer = styled.div`
48 | margin-bottom: 7rem;
49 |
50 | button {
51 | cursor: pointer;
52 | font-family: Noto Sans KR, sans-serif;
53 | font-weight: 500;
54 | font-size: 0.7rem;
55 | border: none;
56 | background: none;
57 | }
58 |
59 | a:nth-child(1) {
60 | button {
61 | border-right: solid 1px silver;
62 | }
63 | }
64 | `;
65 |
--------------------------------------------------------------------------------
/frontend/src/styles/ProblemHeader.style.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { Link } from 'react-router-dom';
3 |
4 | export const HeaderContainer = styled.div`
5 | box-sizing: border-box;
6 | width: 100%;
7 | padding: 0 6rem;
8 | height: 100%;
9 | display: flex;
10 | justify-content: space-between;
11 | align-items: center;
12 |
13 | div {
14 | height: 2.5rem;
15 |
16 | ul {
17 | display: flex;
18 | justify-content: space-between;
19 | align-items: center;
20 | height: 3rem;
21 |
22 | li {
23 | height: 2rem;
24 | margin-left: 2rem;
25 | min-width: 9rem;
26 | width: 9rem;
27 |
28 | .greater {
29 | margin-top: 0.4rem;
30 | height: 1.5rem;
31 | width: 0.75rem;
32 | }
33 |
34 | a {
35 | text-decoration: none;
36 | font-weight: 700;
37 | font-size: 1.5rem;
38 | color: #000000;
39 | }
40 | }
41 |
42 | li:nth-child(2) {
43 | min-width: 2rem;
44 | width: 2rem;
45 | }
46 |
47 | li:nth-child(3) {
48 | width: auto;
49 | }
50 | }
51 | }
52 | `;
53 |
54 | export const AnchorLogo = styled(Link)`
55 | font-weight: 700;
56 | font-size: 2rem;
57 | cursor: pointer;
58 | text-decoration: none;
59 | `;
60 |
61 | export const GreenMark = styled.mark`
62 | color: #1f7a41;
63 | background: none;
64 | `;
65 |
66 | export const MenuContainer = styled.div`
67 | margin-bottom: 2rem;
68 | min-width: 8rem;
69 | width: 8rem;
70 |
71 | button {
72 | cursor: pointer;
73 | font-family: Noto Sans KR, sans-serif;
74 | font-weight: 500;
75 | font-size: 0.7rem;
76 | border: none;
77 | background: none;
78 | min-width: 4rem;
79 | }
80 |
81 | a:nth-child(1) {
82 | button {
83 | border-right: solid 1px silver;
84 | }
85 | }
86 | `;
87 |
--------------------------------------------------------------------------------
/frontend/src/styles/SignIn.style.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'react-router-dom';
2 | import styled from 'styled-components';
3 |
4 | export const InputFormContainer = styled.form`
5 | width: 38rem;
6 | height: 18rem;
7 | background: #f9fffa;
8 | box-shadow: 0 6px 16px 0 #c4e6cd;
9 | margin-top: 3rem;
10 | margin-bottom: 2rem;
11 | padding: 1.5rem;
12 | font-weight: 400;
13 | font-size: 1.5rem;
14 | display: flex;
15 | flex-direction: column;
16 | justify-content: center;
17 | gap: 1.8rem;
18 | position: relative;
19 |
20 | button {
21 | position: absolute;
22 | bottom: 1.5rem;
23 | right: 7rem;
24 | background: #c4e6cd;
25 | border: 2px solid #f0f0f0;
26 | border-radius: 10px;
27 | cursor: pointer;
28 | width: 7.6rem;
29 | height: 2.7rem;
30 | float: right;
31 | font-size: 1.2rem;
32 | color: #0f5e29;
33 | &:hover {
34 | background: #b2dcbd;
35 | border: none;
36 | font-weight: bold;
37 | box-shadow: 2px 2px 1px 1px #b9b9b9;
38 | }
39 | }
40 |
41 | span {
42 | display: flex;
43 | justify-content: center;
44 | align-items: center;
45 | font-size: 16px;
46 | position: absolute;
47 | bottom: 16px;
48 | right: 104px;
49 | width: 112px;
50 | height: 40px;
51 | }
52 | `;
53 |
54 | export const IDInputContainer = styled.div`
55 | display: flex;
56 | justify-content: right;
57 |
58 | input {
59 | margin-right: 5rem;
60 | background: #f1f9eb;
61 | border: 3px solid #c4e6cd;
62 | border-radius: 10px;
63 | width: 16rem;
64 | height: 2.5rem;
65 | &::placeholder {
66 | font-weight: 300;
67 | font-size: 1rem;
68 | text-align: center;
69 | color: #919191;
70 | }
71 | &:hover {
72 | border: 3px solid #9fcdab;
73 | }
74 | }
75 |
76 | label {
77 | margin-right: 1rem;
78 | color: #186e35;
79 | }
80 | `;
81 |
82 | export const PasswordInputContainer = styled.div`
83 | display: flex;
84 | justify-content: right;
85 |
86 | input {
87 | margin-right: 5rem;
88 | background: #f1f9eb;
89 | border: 3px solid #c4e6cd;
90 | border-radius: 10px;
91 | width: 16rem;
92 | height: 2.5rem;
93 | &:hover {
94 | border: 3px solid #9fcdab;
95 | }
96 | }
97 |
98 | label {
99 | margin-right: 1rem;
100 | color: #186e35;
101 | }
102 |
103 | margin-bottom: 4rem;
104 | `;
105 |
106 | export const AnchorLogo = styled(Link)`
107 | font-weight: 700;
108 | font-size: 3.5rem;
109 | cursor: pointer;
110 | text-decoration: none;
111 | `;
112 |
113 | export const GreenMark = styled.mark`
114 | color: #1f7a41;
115 | background: none;
116 | `;
117 |
118 | export const TextLink = styled(Link)`
119 | font-weight: 500;
120 | font-size: 1.6rem;
121 | cursor: pointer;
122 | text-decoration: none;
123 | display: block;
124 | margin-top: 2rem;
125 | color: #5a956a;
126 | `;
127 |
128 | export const InfoContainer = styled.div`
129 | text-align: center;
130 | `;
131 |
--------------------------------------------------------------------------------
/frontend/src/styles/SignUp.style.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'react-router-dom';
2 | import styled from 'styled-components';
3 |
4 | export const InputFormContainer = styled.form`
5 | width: 44rem;
6 | height: 21rem;
7 | background: #f9fffa;
8 | box-shadow: 0px 6px 16px 0px #c4e6cd;
9 | margin-top: 3rem;
10 | margin-bottom: 2rem;
11 | padding: 1.6rem 1.5rem 4.5rem;
12 | font-weight: 400;
13 | font-size: 1.5rem;
14 | display: flex;
15 | flex-direction: column;
16 | justify-content: center;
17 | gap: 1.8rem;
18 | position: relative;
19 |
20 | button {
21 | background: #c4e6cd;
22 | border: 2px solid #f0f0f0;
23 | border-radius: 10px;
24 | width: 7rem;
25 | height: 2.5rem;
26 | font-size: 1rem;
27 | color: #0f5e29;
28 | &:hover {
29 | background: #b2dcbd;
30 | border: none;
31 | font-weight: bold;
32 | font-size: 1rem;
33 | box-shadow: 2px 2px 1px 1px #b9b9b9;
34 | }
35 | }
36 | `;
37 |
38 | export const IDInputContainer = styled.div`
39 | display: flex;
40 | justify-content: right;
41 | margin-right: 3rem;
42 | input {
43 | margin-right: 1rem;
44 | background: #f1f9eb;
45 | border: 3px solid #c4e6cd;
46 | border-radius: 10px;
47 | width: 16rem;
48 | height: 2.5rem;
49 |
50 | &::placeholder {
51 | font-weight: 300;
52 | font-size: 1rem;
53 | text-align: center;
54 | color: #919191;
55 | }
56 | &:hover {
57 | border: 3px solid #9fcdab;
58 | }
59 | }
60 |
61 | p {
62 | margin-right: 1rem;
63 | color: #186e35;
64 | }
65 | `;
66 |
67 | export const PasswordInputContainer = styled.div`
68 | display: flex;
69 | justify-content: right;
70 | margin-right: 11rem;
71 | input {
72 | background: #f1f9eb;
73 | border: 3px solid #c4e6cd;
74 | border-radius: 10px;
75 | width: 16rem;
76 | height: 2.5rem;
77 |
78 | &::placeholder {
79 | font-weight: 300;
80 | font-size: 14px;
81 | text-align: center;
82 | color: #919191;
83 | }
84 | &:hover {
85 | border: 3px solid #9fcdab;
86 | }
87 | }
88 |
89 | p {
90 | margin-right: 1rem;
91 | color: #186e35;
92 | }
93 | `;
94 |
95 | export const ButtonContainer = styled.div`
96 | display: flex;
97 | justify-content: space-around;
98 | position: absolute;
99 | bottom: 1.2rem;
100 | right: 12.5rem;
101 | width: 16rem;
102 | `;
103 |
104 | export const CheckButton = styled.button`
105 | background: #e1ebdb;
106 | border: 2px solid #aeaeae;
107 | border-radius: 10px;
108 | width: 4rem;
109 | height: 2.5rem;
110 | `;
111 |
112 | export const LightContainer = styled.div`
113 | width: 2.5rem;
114 | height: 2.5rem;
115 | font-size: 0.4rem;
116 | display: flex;
117 | justify-content: center;
118 | align-items: center;
119 | line-height: 1rem;
120 | position: absolute;
121 | filter: invert(0.9);
122 | `;
123 |
124 | export const AnchorLogo = styled(Link)`
125 | font-weight: 700;
126 | font-size: 3.5rem;
127 | cursor: pointer;
128 | text-decoration: none;
129 | `;
130 |
131 | export const GreenMark = styled.mark`
132 | color: #1f7a41;
133 | background: none;
134 | `;
135 |
--------------------------------------------------------------------------------
/frontend/src/types/banner.d.ts:
--------------------------------------------------------------------------------
1 | declare module '@types' {
2 | interface BannerContent {
3 | text: string;
4 | image: string;
5 | color: string;
6 | }
7 |
8 | type BannerType = {
9 | content: BannerContent;
10 | };
11 | }
12 |
--------------------------------------------------------------------------------
/frontend/src/types/filter.d.ts:
--------------------------------------------------------------------------------
1 | declare module '@types' {
2 | interface FilterContent {
3 | name: string;
4 | elements: string[];
5 | }
6 |
7 | type FilterType = {
8 | content: FilterContent;
9 | };
10 | }
11 |
--------------------------------------------------------------------------------
/frontend/src/types/problem.d.ts:
--------------------------------------------------------------------------------
1 | declare module '@types' {
2 | interface ProblemInfo {
3 | level?: number;
4 | title?: string;
5 | description?: string;
6 | problemId?: number;
7 | createdAt?: string;
8 | updatedAt?: string;
9 | isSolved?: boolean;
10 | }
11 |
12 | type ProblemType = {
13 | problem: ProblemInfo;
14 | };
15 | }
16 |
--------------------------------------------------------------------------------
/frontend/src/utils/BannerInfo.ts:
--------------------------------------------------------------------------------
1 | import { Banner1, Banner2, Banner3 } from '../assets/images';
2 |
3 | const BannerInfo = [
4 | {
5 | text: '동료와 함께\n문제를 풀어보세요',
6 | image: Banner1,
7 | color: '#abb59f',
8 | },
9 | {
10 | text: '실시간으로\n문제를 풀어보세요',
11 | image: Banner2,
12 | color: '#a0b6ad',
13 | },
14 | {
15 | text: '알고리즘 대회를\n준비해보세요',
16 | image: Banner3,
17 | color: '#B6B2A0',
18 | },
19 | ];
20 |
21 | export default BannerInfo;
22 |
--------------------------------------------------------------------------------
/frontend/src/utils/FiltersInfo.ts:
--------------------------------------------------------------------------------
1 | const FiltersInfo = [
2 | {
3 | name: '상태',
4 | elements: ['푼 문제', '안 푼 문제'],
5 | },
6 | {
7 | name: '레벨',
8 | elements: ['LV. 1', 'LV. 2', 'LV. 3'],
9 | },
10 | ];
11 |
12 | export default FiltersInfo;
13 |
--------------------------------------------------------------------------------
/frontend/src/utils/ProblemsDummy.ts:
--------------------------------------------------------------------------------
1 | const problem1 = {
2 | level: 1,
3 | title: 'A + B = ?',
4 | description: 'Lv1, Python, Javascript, Success Rate: 95.12%',
5 | id: 1,
6 | };
7 |
8 | const problem2 = {
9 | level: 2,
10 | title: 'A + B = ?',
11 | description: 'Lv2, Python, Javascript, Success Rate: 95.12%',
12 | id: 2,
13 | };
14 |
15 | const problem3 = {
16 | level: 3,
17 | title: 'A + B = ?',
18 | description: 'Lv3, Python, Javascript, Success Rate: 95.12%',
19 | id: 3,
20 | };
21 |
22 | const problems = [
23 | ...new Array(10).fill(problem1),
24 | ...new Array(11).fill(problem2),
25 | ...new Array(12).fill(problem3),
26 | ];
27 |
28 | export default problems;
29 |
--------------------------------------------------------------------------------
/frontend/src/utils/ReservedWords.ts:
--------------------------------------------------------------------------------
1 | const ReservedWords = [
2 | 'abstract',
3 | 'arguments',
4 | 'boolean',
5 | 'break',
6 | 'byte',
7 | 'case',
8 | 'catch',
9 | 'char',
10 | 'class*',
11 | 'const',
12 | 'continue',
13 | 'debugger',
14 | 'default',
15 | 'delete',
16 | 'do',
17 | 'double',
18 | 'else',
19 | 'enum*',
20 | 'eval',
21 | 'export*',
22 | 'extends*',
23 | 'false',
24 | 'final',
25 | 'finally',
26 | 'float',
27 | 'for',
28 | 'function',
29 | 'goto',
30 | 'if',
31 | 'implements',
32 | 'import*',
33 | 'in',
34 | 'instanceof',
35 | 'int',
36 | 'interface',
37 | 'let',
38 | 'long',
39 | 'native',
40 | 'new',
41 | 'null',
42 | 'package',
43 | 'private',
44 | 'protected',
45 | 'public',
46 | 'return',
47 | 'short',
48 | 'static',
49 | 'super*',
50 | 'switch',
51 | 'synchronized',
52 | 'this',
53 | 'throw',
54 | 'throws',
55 | 'transient',
56 | 'true',
57 | 'try',
58 | 'typeof',
59 | 'var',
60 | 'void',
61 | 'volatile',
62 | 'while',
63 | 'with',
64 | 'yield',
65 | ];
66 |
67 | export default ReservedWords;
68 |
--------------------------------------------------------------------------------
/frontend/src/utils/cookie.tsx:
--------------------------------------------------------------------------------
1 | import { Cookies } from 'react-cookie';
2 |
3 | const cookies = new Cookies();
4 |
5 | export const setCookie = (name: string, value: string, option?: any) => {
6 | return cookies.set(name, value, { ...option });
7 | };
8 |
9 | export const getCookie = (name: string) => {
10 | return cookies.get(name);
11 | };
12 |
13 | export const removeCookie = (name: string) => {
14 | return cookies.remove(name);
15 | };
16 |
--------------------------------------------------------------------------------
/frontend/src/utils/defaultCode.ts:
--------------------------------------------------------------------------------
1 | const defaultCodes = {
2 | JavaScript: `/*
3 | 함수 내부에 실행 코드를 작성하세요
4 | */
5 |
6 | function solution(param) {
7 |
8 | let answer;
9 |
10 | return answer;
11 |
12 | }`,
13 | Python: `/*
14 | 함수 내부에 실행 코드를 작성하세요
15 | */
16 |
17 | def solution(param):
18 |
19 | answer = 0
20 |
21 | return answer
22 |
23 | }`,
24 | '': `/*
25 |
26 | 언어를 선택하세요
27 |
28 | */`,
29 | };
30 |
31 | export default defaultCodes;
32 |
--------------------------------------------------------------------------------
/frontend/src/utils/editorColors.ts:
--------------------------------------------------------------------------------
1 | const editorColors = [
2 | {color: '#30bced', light: '#30bced33'},
3 | {color: '#6eeb83', light: '#6eeb8333'},
4 | {color: '#ffbc42', light: '#ffbc4233'},
5 | {color: '#ecd444', light: '#ecd44433'},
6 | {color: '#ee6352', light: '#ee635233'},
7 | {color: '#9ac2c9', light: '#9ac2c933'},
8 | {color: '#8acb88', light: '#8acb8833'},
9 | {color: '#1be7ff', light: '#1be7ff33'}
10 | ];
11 |
12 | export default editorColors;
--------------------------------------------------------------------------------
/frontend/src/utils/userUtil.tsx:
--------------------------------------------------------------------------------
1 | export const removeLocalToken = () => {
2 | localStorage.removeItem('camperRankToken');
3 | localStorage.removeItem('camperID');
4 | localStorage.removeItem('camperRankTokenTime');
5 | };
6 |
7 | export const getLocalToken = () => {
8 | const token = localStorage.getItem('camperRankToken');
9 | const camperID = localStorage.getItem('camperID');
10 | const expirationTime = localStorage.getItem('camperRankTokenTime');
11 | return { token, camperID, expirationTime };
12 | };
13 |
14 | export const setLocalToken = (
15 | accessToken: string,
16 | expirationTime: string,
17 | userId: string,
18 | ) => {
19 | localStorage.setItem('camperRankToken', accessToken);
20 | localStorage.setItem('camperRankTokenTime', expirationTime);
21 | localStorage.setItem('camperID', userId);
22 | };
23 |
24 | export const calculateRemainingTime = (expirationTime: string) =>
25 | new Date(expirationTime).getTime() - new Date().getTime();
26 |
--------------------------------------------------------------------------------
/frontend/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/frontend/test/App.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import App from '../src/App';
3 |
4 | describe('js test', () => {
5 | it('number test', () => {
6 | expect(3 + 4).toBe(7); // 3+4가 7인지 테스트
7 | });
8 |
9 | it('string test', () => {
10 | const name = 'J4J';
11 |
12 | expect(name).toBe('J4J'); // name이 J4J인지 테스트
13 | });
14 | });
15 |
--------------------------------------------------------------------------------
/frontend/test/SearchFilter.test.tsx:
--------------------------------------------------------------------------------
1 | import { act, render, screen } from '@testing-library/react';
2 | import { SearchFilter } from '../src/components/ProblemList';
3 | import { RecoilRoot } from 'recoil';
4 | import { unmountComponentAtNode } from 'react-dom';
5 |
6 | let container: any = null;
7 | beforeEach(() => {
8 | // 렌더링 대상으로 DOM 엘리먼트를 설정합니다.
9 | container = document.createElement('div');
10 | document.body.appendChild(container);
11 | });
12 |
13 | afterEach(() => {
14 | // 기존의 테스트 환경을 정리합니다.
15 | unmountComponentAtNode(container);
16 | container.remove();
17 | container = null;
18 | });
19 |
20 | it('renders with or without a name', () => {
21 | act(() => {
22 | render(
23 |
24 |
25 | ,
26 | container,
27 | );
28 | });
29 | expect(container.textContent).toBe('');
30 | });
31 |
32 | export {};
33 |
--------------------------------------------------------------------------------
/frontend/test/setup.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line
2 | // import/no-extraneous-dependencies
3 | import '@testing-library/jest-dom';
4 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": true,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx"
18 | },
19 | "include": ["src", "custom.d.ts"],
20 | "references": [
21 | {
22 | "path": "./tsconfig.node.json"
23 | }
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------
/frontend/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "ESNext",
5 | "moduleResolution": "Node",
6 | "allowSyntheticDefaultImports": true
7 | },
8 | "include": ["vite.config.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/frontend/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import react from '@vitejs/plugin-react';
3 | import svgr from 'vite-plugin-svgr';
4 | import tsconfigPaths from 'vite-tsconfig-paths';
5 | import { compression } from 'vite-plugin-compression2';
6 |
7 | // https://vitejs.dev/config/
8 | export default defineConfig({
9 | plugins: [
10 | svgr(),
11 | react(),
12 | tsconfigPaths(),
13 | compression({
14 | include: [/\.(js)$/, /\.(css)$/],
15 | threshold: 1400,
16 | }),
17 | ],
18 | });
19 |
--------------------------------------------------------------------------------
/grading/.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_2288c8ca73ef8be5ea7f8b54703baf70026795a448756dcbf7abc93d6a3d0405
--------------------------------------------------------------------------------
/grading/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | .env*
3 | .flaskenv*
4 | !.env.project
5 | !.env.vault
6 | yarn.lock
--------------------------------------------------------------------------------
/grading/ecosystem.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | apps: [
3 | {
4 | name: 'nestjs-server', // pm2 name
5 | script: './dist/main.js', // // 앱 실행 스크립트
6 | instances: 2, // 클러스터 모드 사용 시 생성할 인스턴스 수
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 |
--------------------------------------------------------------------------------
/grading/nodemon.json:
--------------------------------------------------------------------------------
1 | {
2 | "watch": ["src", "config"],
3 | "ext": ".ts,.js",
4 | "ignore": [],
5 | "exec": "ts-node ./src/app.ts"
6 | }
--------------------------------------------------------------------------------
/grading/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "grading",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1",
8 | "start": "ts-node src/app.ts",
9 | "build": "tsc -p .",
10 | "dev": "nodemon --watch \"src/**/*.ts\" --exec \"ts-node\" src/index.ts"
11 | },
12 | "keywords": [],
13 | "author": "",
14 | "license": "ISC",
15 | "devDependencies": {
16 | "@types/compression": "^1.7.2",
17 | "@types/express": "^4.17.14",
18 | "@types/method-override": "^0.0.32",
19 | "@types/node": "^18.11.9",
20 | "express": "^4.18.2",
21 | "nodemon": "^2.0.20",
22 | "ts-node": "^10.9.1",
23 | "typescript": "^4.9.3"
24 | },
25 | "dependencies": {
26 | "@types/body-parser": "^1.19.2",
27 | "@types/cors": "^2.8.12",
28 | "@types/uuid": "^8.3.4",
29 | "body-parser": "^1.20.1",
30 | "compression": "^1.7.4",
31 | "cors": "^2.8.5",
32 | "method-override": "^3.0.0",
33 | "uuid": "^9.0.0"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/grading/src/app.ts:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import gradeRouter from "./routes/grade.route";
3 | import cors from "cors";
4 | import compression from "compression";
5 | import methodOverride from "method-override";
6 |
7 | const app = express();
8 |
9 | // const corsOptions = {
10 | // origin: "*",
11 | // credentials: true,
12 | // };
13 |
14 | app.use(cors());
15 |
16 | // 미들웨어 압축, 파일 용량 줄임
17 | app.use(compression());
18 | // restful api 중 put, delete를 사용하기 위해 씀
19 | app.use(methodOverride());
20 |
21 | // urlencoded 페이로드로 들어오는 요청을 분석, extended true는 qs 모듈을 써서 body parsing
22 | app.use(express.urlencoded({ extended: true }));
23 |
24 | app.use(express.json());
25 | app.use("/grade-server", gradeRouter);
26 |
27 |
28 | app.listen("4000", () => {
29 | console.log(`
30 | ################################################
31 | 🛡️ Server listening on port: 4000🛡️
32 | ################################################
33 | `);
34 | });
35 |
--------------------------------------------------------------------------------
/grading/src/controllers/demo/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python
2 | WORKDIR /home
3 | CMD ["test.py"]
4 | ENTRYPOINT ["python3"]
--------------------------------------------------------------------------------
/grading/src/controllers/demo/test.py:
--------------------------------------------------------------------------------
1 | print('hello')
--------------------------------------------------------------------------------
/grading/src/controllers/python.txt:
--------------------------------------------------------------------------------
1 | if __name__ == '__main__':
2 | answer = solution(
--------------------------------------------------------------------------------
/grading/src/routes/grade.route.ts:
--------------------------------------------------------------------------------
1 | import { Router } from "express";
2 | import { gradingController, startDocker, startGrade } from "../controllers/grade.controller";
3 |
4 | const router = Router();
5 |
6 | router.post("/v1/grading", gradingController);
7 | router.post("/v1/grade", startGrade);
8 | router.post("/v1/docker", startDocker);
9 |
10 | export = router;
11 |
--------------------------------------------------------------------------------
/grading/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": [
4 | "es5",
5 | "es6",
6 | "dom"
7 | ],
8 | "target": "es2016",
9 | "experimentalDecorators": true,
10 | "emitDecoratorMetadata": true,
11 | "module": "commonjs",
12 | "moduleResolution": "node",
13 | "outDir": "./build",
14 | "esModuleInterop": true,
15 | "forceConsistentCasingInFileNames": true,
16 | "strictPropertyInitialization": false,
17 | "strict": true,
18 | "sourceMap": true
19 | }
20 | }
--------------------------------------------------------------------------------
/socket/.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', 'build', 'dist', 'public'],
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 |
--------------------------------------------------------------------------------
/socket/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | build
--------------------------------------------------------------------------------
/socket/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "trailingComma": "all"
4 | }
5 |
--------------------------------------------------------------------------------
/socket/nodemon.json:
--------------------------------------------------------------------------------
1 | {
2 | "watch": ["src", "config"],
3 | "ext": ".ts,.js",
4 | "ignore": [],
5 | "exec": "ts-node ./src/app.ts"
6 | }
--------------------------------------------------------------------------------
/socket/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "socket",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "build": "tsc --build --clean && tsc --build",
8 | "start": "ts-node src/app.ts",
9 | "dev": "nodemon --exec ts-node src/app.ts",
10 | "test": "echo \"Error: no test specified\" && exit 1"
11 | },
12 | "keywords": [],
13 | "author": "",
14 | "license": "ISC",
15 | "devDependencies": {
16 | "@types/compression": "^1.7.2",
17 | "@types/cors": "^2.8.12",
18 | "@types/express": "^4.17.14",
19 | "@types/node": "^18.11.9",
20 | "@typescript-eslint/eslint-plugin": "^5.45.0",
21 | "@typescript-eslint/parser": "^5.45.0",
22 | "eslint": "^8.28.0",
23 | "eslint-config-airbnb-base": "^15.0.0",
24 | "eslint-config-prettier": "^8.5.0",
25 | "eslint-plugin-import": "^2.26.0",
26 | "eslint-plugin-prettier": "^4.2.1",
27 | "prettier": "^2.8.0",
28 | "ts-node": "^10.9.1",
29 | "typescript": "^4.9.3"
30 | },
31 | "dependencies": {
32 | "compression": "^1.7.4",
33 | "cors": "^2.8.5",
34 | "express": "^4.18.2",
35 | "peer": "^0.6.1",
36 | "socket.io": "^4.5.4"
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/socket/src/app.ts:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import * as http from 'http';
3 | import { Server } from 'socket.io';
4 |
5 | const app = express();
6 | const socketServerPort = 3333;
7 | const httpServer = http.createServer(app).listen(socketServerPort);
8 | const io = new Server(httpServer, {
9 | cors: {
10 | origin: '*',
11 | credentials: true,
12 | methods: ['GET', 'POST'],
13 | },
14 | cookie: true,
15 | });
16 |
17 | function logging(msg: string, param?: string) {
18 | if (!param) {
19 | console.log(`[${new Date().toLocaleString()}] ${msg}`);
20 | return;
21 | }
22 | console.log(`[${new Date().toLocaleString()}] ${msg}: ${param}`);
23 | }
24 |
25 | io.on('connection', (socket) => {
26 | logging('user joined server');
27 | socket.on('join-room', (roomId, userId) => {
28 | const room = io.sockets.adapter.rooms.get(roomId);
29 | if (room && room.size >= 3) {
30 | logging('room is full, out userId: ', userId);
31 | socket.emit('full');
32 | return;
33 | }
34 |
35 | logging('join userId: ', userId);
36 | logging('roomId: ', roomId);
37 |
38 | socket.join(roomId);
39 | socket.to(roomId).emit('user-connected', userId);
40 |
41 | socket.on('disconnect', () => {
42 | socket.to(roomId).emit('user-disconnected', userId);
43 | });
44 | });
45 |
46 | socket.on('change-language', (roomId, code, lang) => {
47 | logging('roomId: ', roomId);
48 | logging('code: ', code);
49 | socket.to(roomId).emit('change-language', code, lang);
50 | });
51 | });
52 |
53 | app.use(express.urlencoded({ extended: true }));
54 |
55 | app.use(express.json());
56 |
--------------------------------------------------------------------------------
/socket/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": [
4 | "es5",
5 | "es6",
6 | "dom"
7 | ],
8 | "target": "es2016",
9 | "experimentalDecorators": true,
10 | "emitDecoratorMetadata": true,
11 | "module": "commonjs",
12 | "moduleResolution": "node",
13 | "outDir": "./build",
14 | "esModuleInterop": true,
15 | "forceConsistentCasingInFileNames": true,
16 | "strictPropertyInitialization": false,
17 | "strict": true,
18 | "sourceMap": true
19 | }
20 | }
--------------------------------------------------------------------------------