├── .DS_Store ├── .github ├── ISSUE_TEMPLATE │ └── custom.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── cd-backend.yml │ ├── cd-frontend.yml │ ├── cd-media-server.yml │ ├── ci-backend.yml │ └── ci-frontend.yml ├── README.md ├── backend ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── README.md ├── nest-cli.json ├── package-lock.json ├── package.json ├── src │ ├── app.controller.spec.ts │ ├── app.controller.ts │ ├── app.module.ts │ ├── app.service.ts │ ├── comment │ │ ├── comment.module.ts │ │ └── entities │ │ │ └── comments.entity.ts │ ├── config │ │ └── cors.config.ts │ ├── constants │ │ └── sfuEvents.ts │ ├── filter │ │ ├── all-exceptions.filter.ts │ │ └── socket-exceptions.filter.ts │ ├── global-chat │ │ ├── globalChat.gateway.ts │ │ └── globalChat.module.ts │ ├── main.ts │ ├── mediaServer │ │ ├── mediaServer.gateway.ts │ │ └── mediaServer.module.ts │ ├── redis │ │ ├── redis-cache.controller.ts │ │ ├── redis-cache.module.ts │ │ └── redis-cache.service.ts │ ├── sfu │ │ ├── sfu.gateway.ts │ │ └── sfu.module.ts │ ├── socket │ │ ├── socket.gateway.ts │ │ └── socket.module.ts │ ├── study-room │ │ ├── dto │ │ │ └── createRoom.dto.ts │ │ ├── entities │ │ │ └── studyRoom.entity.ts │ │ ├── study-room.controller.spec.ts │ │ ├── study-room.controller.ts │ │ ├── study-room.module.ts │ │ ├── study-room.service.spec.ts │ │ └── study-room.service.ts │ ├── user │ │ ├── dto │ │ │ └── user.dto.ts │ │ ├── entities │ │ │ └── user.entity.ts │ │ ├── user.controller.spec.ts │ │ ├── user.controller.ts │ │ ├── user.module.ts │ │ ├── user.service.spec.ts │ │ └── user.service.ts │ └── utils │ │ ├── dateFormatter.ts │ │ ├── salt.ts │ │ └── sendDcBodyFormatter.ts ├── test │ ├── app.e2e-spec.ts │ └── jest-e2e.json ├── tsconfig.build.json └── tsconfig.json ├── frontend ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── README.md ├── craco.config.js ├── package-lock.json ├── package.json ├── public │ └── index.html ├── src │ ├── App.test.tsx │ ├── App.tsx │ ├── assets │ │ ├── 404.jpg │ │ ├── StyledLogo.png │ │ ├── home.png │ │ ├── icons │ │ │ ├── canvas.svg │ │ │ ├── chat.svg │ │ │ ├── check.svg │ │ │ ├── create.svg │ │ │ ├── down-triangle.svg │ │ │ ├── hashtag.svg │ │ │ ├── king.svg │ │ │ ├── leftArrow.svg │ │ │ ├── mic-off.svg │ │ │ ├── mic.svg │ │ │ ├── monitor-off.svg │ │ │ ├── monitor.svg │ │ │ ├── participants.svg │ │ │ ├── rightArrow.svg │ │ │ ├── searchBarButton.svg │ │ │ ├── user.svg │ │ │ ├── video-off.svg │ │ │ └── video.svg │ │ ├── loader.svg │ │ ├── logo.svg │ │ ├── logoWithName.svg │ │ ├── question.png │ │ ├── sample.jpg │ │ ├── study.png │ │ └── tody.svg │ ├── axios │ │ ├── instances │ │ │ └── axiosBackend.ts │ │ └── requests │ │ │ ├── checkEnterableRequest.ts │ │ │ ├── checkMasterRequest.ts │ │ │ ├── checkUniqueIdRequest.ts │ │ │ ├── checkUniqueNicknameRequest.ts │ │ │ ├── createStudyRoomRequest.ts │ │ │ ├── deleteRoomRequest.ts │ │ │ ├── enterRoomRequest.ts │ │ │ ├── getParticipantsListRequest.ts │ │ │ ├── getStudyRoomInfoRequest.ts │ │ │ ├── getStudyRoomListRequest.ts │ │ │ ├── leaveRoomRequest.ts │ │ │ ├── loginRequest.ts │ │ │ ├── logoutRequest.ts │ │ │ ├── signupRequest.ts │ │ │ └── silentLoginRequest.ts │ ├── components │ │ ├── common │ │ │ ├── CreatButton.tsx │ │ │ ├── CustomButton.tsx │ │ │ ├── CustomInput.tsx │ │ │ ├── Loader.tsx │ │ │ ├── MainSideBar.tsx │ │ │ ├── MenuList.tsx │ │ │ ├── Modal.tsx │ │ │ ├── Pagination.tsx │ │ │ ├── PrivateRoute.tsx │ │ │ ├── SearchBar.tsx │ │ │ ├── StudyRoomGuard.tsx │ │ │ ├── StyledHeader1.tsx │ │ │ ├── UserProfile.tsx │ │ │ └── ViewConditionCheckBox.tsx │ │ ├── studyRoom │ │ │ ├── BottomBar.tsx │ │ │ ├── Canvas.tsx │ │ │ ├── ChatItem.tsx │ │ │ ├── ChatList.tsx │ │ │ ├── ChatSideBar.tsx │ │ │ ├── NicknameWrapper.tsx │ │ │ ├── ParticipantsSideBar.tsx │ │ │ └── RemoteVideo.tsx │ │ └── studyRoomList │ │ │ ├── CreateNewRoomModal.tsx │ │ │ ├── GlobalChat.tsx │ │ │ ├── SearchRoomResult.tsx │ │ │ ├── StudyRoomItem.tsx │ │ │ ├── StudyRoomList.tsx │ │ │ ├── StudyRoomListChatBar.tsx │ │ │ ├── StudyRoomListChatItem.tsx │ │ │ └── TagInput.tsx │ ├── constants │ │ └── sfuEvents.ts │ ├── hooks │ │ ├── useAxios.ts │ │ ├── useInputValidation.ts │ │ ├── useSfu.ts │ │ └── useStudyRoomPage.ts │ ├── index.tsx │ ├── pages │ │ ├── ErrorPage.tsx │ │ ├── InitPage.tsx │ │ ├── LoginPage.tsx │ │ ├── MainPage.tsx │ │ ├── MeshPage.tsx │ │ ├── NotFoundPage.tsx │ │ ├── SfuPage.tsx │ │ ├── SignupPage.tsx │ │ ├── StudyRoomListPage.tsx │ │ └── StudyRoomPage.tsx │ ├── react-app-env.d.ts │ ├── recoil │ │ └── atoms.ts │ ├── routes │ │ └── Router.tsx │ ├── setupTests.ts │ ├── sockets │ │ └── sfuSocket.ts │ ├── styles │ │ ├── index.css │ │ └── reset.css │ └── types │ │ ├── chat.types.ts │ │ ├── recoil.types.ts │ │ ├── studyRoom.types.ts │ │ └── studyRoomList.types.ts └── tsconfig.json ├── media-server ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── README.md ├── nest-cli.json ├── package-lock.json ├── package.json ├── src │ ├── config │ │ └── cors.config.ts │ ├── constants │ │ └── sfuEvents.ts │ ├── filter │ │ └── socket-exceptions.filter.ts │ ├── main.ts │ ├── mediaServer │ │ ├── mediaServer.gateway.ts │ │ └── mediaServer.module.ts │ ├── sfu │ │ ├── sfu.gateway.ts │ │ └── sfu.module.ts │ └── utils │ │ └── sendDcBodyFormatter.ts ├── test │ ├── app.e2e-spec.ts │ └── jest-e2e.json ├── tsconfig.build.json └── tsconfig.json └── package-lock.json /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web30-TODY/bdce222248ccc2f57d1079c33430b36f1ba4ae89/.DS_Store -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 개요 11 | (문장) 12 | 13 | ## 관련 브랜치 14 | ex. (new)신규브랜치명, 기존브랜치명 15 | 16 | ## 할 일 (optional) 17 | - [ ] 18 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## 관련 이슈 번호 2 | 3 | ## 진행 사항 (optional) 4 | 5 | ## 문제 및 해결 (optional) 6 | -------------------------------------------------------------------------------- /.github/workflows/cd-backend.yml: -------------------------------------------------------------------------------- 1 | name: cd-backend 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | paths: 7 | - "backend/**" 8 | 9 | jobs: 10 | deploy-backend: 11 | name: deploy-backend 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: deploy to prod 15 | uses: appleboy/ssh-action@master 16 | with: 17 | host: ${{secrets.BE_HOST}} 18 | username: ${{secrets.BE_USERNAME}} 19 | password: ${{secrets.BE_PASSWORD}} 20 | port: ${{secrets.BE_PORT}} 21 | script: | 22 | cd tody 23 | git checkout main 24 | git pull origin main 25 | cd backend 26 | export NVM_DIR=~/.nvm 27 | source ~/.nvm/nvm.sh 28 | npm i 29 | npm run build 30 | npm run start:reload 31 | -------------------------------------------------------------------------------- /.github/workflows/cd-frontend.yml: -------------------------------------------------------------------------------- 1 | name: cd-frontend 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | paths: 7 | - "frontend/**" 8 | 9 | jobs: 10 | deploy-frontend: 11 | name: deploy-frontend 12 | runs-on: ubuntu-latest 13 | defaults: 14 | run: 15 | working-directory: frontend 16 | 17 | steps: 18 | - name: checkout 19 | uses: actions/checkout@v3 20 | 21 | - name: create env files 22 | run: | 23 | touch .env.production 24 | cat << EOF >> .env.production 25 | ${{ secrets.FE_ENV_PRODUCTION }} 26 | EOF 27 | 28 | - name: install dependencies 29 | run: npm i 30 | 31 | - name: build 32 | run: npm run build 33 | 34 | - name: deploy build outputs 35 | uses: appleboy/scp-action@master 36 | with: 37 | host: ${{ secrets.FE_HOST }} 38 | username: ${{ secrets.FE_USERNAME }} 39 | password: ${{ secrets.FE_PASSWORD }} 40 | port: ${{ secrets.FE_PORT }} 41 | source: "frontend/build/*" 42 | target: "/tody" 43 | -------------------------------------------------------------------------------- /.github/workflows/cd-media-server.yml: -------------------------------------------------------------------------------- 1 | name: cd-media-server 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | paths: 7 | - "media-server/**" 8 | 9 | jobs: 10 | deploy-media-server: 11 | name: deploy-media-server 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: deploy to prod 15 | uses: appleboy/ssh-action@master 16 | with: 17 | host: ${{secrets.MS_HOST}} 18 | username: ${{secrets.MS_USERNAME}} 19 | password: ${{secrets.MS_PASSWORD}} 20 | port: ${{secrets.MS_PORT}} 21 | script: | 22 | cd sfu 23 | git checkout main 24 | git pull origin main 25 | cd media-server 26 | npm i 27 | npm run build 28 | npm run start:reload 29 | -------------------------------------------------------------------------------- /.github/workflows/ci-backend.yml: -------------------------------------------------------------------------------- 1 | name: ci-backend 2 | 3 | on: 4 | pull_request: 5 | branches: ["main", "develop"] 6 | paths: 7 | - "backend/**" 8 | jobs: 9 | check-backend: 10 | name: check-backend 11 | runs-on: ubuntu-latest 12 | defaults: 13 | run: 14 | working-directory: backend 15 | steps: 16 | - name: checkout 17 | uses: actions/checkout@v3 18 | 19 | - name: install dependencies 20 | run: npm i 21 | 22 | - name: lint test 23 | run: npm run lint 24 | 25 | - name: unit test 26 | run: npm run test 27 | 28 | - name: build 29 | run: npm run build 30 | -------------------------------------------------------------------------------- /.github/workflows/ci-frontend.yml: -------------------------------------------------------------------------------- 1 | name: ci-frontend 2 | 3 | on: 4 | pull_request: 5 | branches: ["main", "develop"] 6 | paths: 7 | - "frontend/**" 8 | jobs: 9 | check-frontend: 10 | name: check-frontend 11 | runs-on: ubuntu-latest 12 | defaults: 13 | run: 14 | working-directory: frontend 15 | steps: 16 | - name: checkout 17 | uses: actions/checkout@v3 18 | 19 | - name: create env files 20 | run: | 21 | touch .env.production 22 | cat << EOF > .env.production 23 | ${{ secrets.FE_ENV_PRODUCTION }} 24 | EOF 25 | touch .env.test 26 | cat << EOF > .env.test 27 | ${{ secrets.FE_ENV_TEST }} 28 | EOF 29 | 30 | - name: install dependencies 31 | run: npm i 32 | 33 | - name: lint test 34 | run: npm run lint 35 | 36 | - name: unit test 37 | run: npm run test 38 | 39 | - name: build 40 | run: npm run build 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 프로젝트 소개 2 | > [👉 tody 사이트 바로가기](https://www.tody.kr) 3 | 4 | > [wiki 바로가기](https://github.com/boostcampwm-2022/web30-TODY/wiki) 5 | 6 | ## TODY (TOgether stuDY) 7 | 8 | ✏ **화상 공유 비대면 공부방 서비스** 9 | 10 | - **타깃** 11 | - 혼자 공부를 할 때? 12 | - 집중력이 떨어져 딴 짓을 하는 시간이 많아지는 사람 13 | - 공부는 해야 하는데 혼자는 하기 싫은 사람 14 | - 공부하다가 모르는 부분을 바로바로 질문하고 해결할 수 없다는 점이 불편한 사람 15 | 16 | 17 | - **동기** 18 | - 위와 같은 사람들을 위해 **화상 공유**를 통해 서로 공부하는 모습을 보며 동기부여를 받고, 어려운 부분에 대해 도움을 주고받을 수 있는 **웹 상의 공부방**이 있으면 좋겠다. 19 | - 간단하게 공개적으로 사람을 모아 목적에 맞는 공부방을 개설하고 **함께 공부할 수 있는 서비스**가 있으면 좋겠다. 20 | - 줌 회의실의 **접근성**과 디스코드 회의실의 **공개성**을 모두 가지는 화상 공유 서비스가 있으면 좋겠다. 21 | 22 | 23 | - **목표** 24 | - 사용자는 간단하게 **공부방을 생성**할 수 있고, 홈페이지에서 **채팅**을 통해 함께 공부할 사람을 모집할 수 있다. 25 | - 사용자는 키워드로 공부방을 **검색**해서 비슷한 목적을 가진 사람들과 함께 공부할 수 있다. 26 | - 각 공부방에서 최대 10명의 사용자가 **화상 공유**를 통해 문제 없이 소통할 수 있다. 27 | 28 | 29 | ## 주요 기능 30 | 31 | ### 화상 공유 32 | 33 | - **WebRTC**를 활용한 **화상 연결** 구현 34 | ![공부방 채팅](https://user-images.githubusercontent.com/109154976/207522850-2fcf00f7-79d8-442a-ad47-c3ba0cf615ff.gif) 35 | 36 | 37 | ### 실시간 채팅 38 | 39 | **1. 전체 사용자 간 채팅** 40 | 41 | - 전체 사용자 간 실시간 채팅은 공부방에 참여하기 전 채팅을 통해 **목적이나 목표가 맞는 사용자**끼리 만나 공부방을 생성하도록 유도하기 위한 기능이다. 42 | ![전체채팅](https://user-images.githubusercontent.com/109154976/207523013-43401208-f896-4ed4-84ca-5c644d96c7cc.gif) 43 | 44 | 45 | 46 | **2. 공부방 내 사용자 간 채팅** 47 | 48 | - 비디오 공유를 할 수 없는 경우의 참여자 또한 소통이 가능하도록 **실시간 채팅 기능**을 제공한다. 49 | ![공부방 채팅](https://user-images.githubusercontent.com/109154976/207522850-2fcf00f7-79d8-442a-ad47-c3ba0cf615ff.gif) 50 | 51 | 52 | 53 | 54 | ### 캔버스 공유 55 | 56 | - 공부방 내 참여자 간 **실시간 캔버스** 공유 57 | ![캔버스 공유](https://user-images.githubusercontent.com/109154976/207522916-31fafde5-6a11-400f-9b83-b5d55e2beb91.gif) 58 | 59 | 60 | ## 기술 스택 61 | 62 | ![Untitled 2](https://user-images.githubusercontent.com/109154976/206093781-88aef41d-b9fd-4632-a07c-62b1b34faa07.png) 63 | 64 | ## 아키텍처 65 | 66 | ![image](https://user-images.githubusercontent.com/109154976/207525357-af7d36cb-e53e-49c8-9f2d-61484cd6aaa6.png) 67 | 68 | 69 | -------------------------------------------------------------------------------- /backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir : __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | # Secrets 38 | /secrets 39 | .env 40 | .env.development 41 | .env.production -------------------------------------------------------------------------------- /backend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true, 4 | "tabWidth": 2, 5 | "printWidth": 80, 6 | "arrowParens": "always", 7 | "trailingComma": "all", 8 | "bracketSpacking": true, 9 | "bracketSameLine": true, 10 | "endOfLine": "lf", 11 | "quoteProps": "consistent" 12 | } -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

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

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

9 |

10 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Coverage 15 | Discord 16 | Backers on Open Collective 17 | Sponsors on Open Collective 18 | 19 | Support us 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://kamilmysliwiec.com) 68 | - Website - [https://nestjs.com](https://nestjs.com/) 69 | - Twitter - [@nestframework](https://twitter.com/nestframework) 70 | 71 | ## License 72 | 73 | Nest is [MIT licensed](LICENSE). 74 | -------------------------------------------------------------------------------- /backend/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } 6 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "cross-env NODE_ENV=development nest start --watch", 13 | "start:dev": "cross-env NODE_ENV=development pm2 start dist/main.js", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "cross-env NODE_ENV=production pm2 start dist/main.js", 16 | "start:reload": "cross-env NODE_ENV=production pm2 reload main --update-env", 17 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", 18 | "test": "jest", 19 | "test:watch": "jest --watch", 20 | "test:cov": "jest --coverage", 21 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 22 | "test:e2e": "jest --config ./test/jest-e2e.json" 23 | }, 24 | "dependencies": { 25 | "@nestjs/common": "^9.0.0", 26 | "@nestjs/config": "^2.2.0", 27 | "@nestjs/core": "^9.0.0", 28 | "@nestjs/jwt": "^9.0.0", 29 | "@nestjs/platform-express": "^9.0.0", 30 | "@nestjs/platform-socket.io": "^9.2.0", 31 | "@nestjs/platform-ws": "^9.2.0", 32 | "@nestjs/swagger": "^6.1.3", 33 | "@nestjs/typeorm": "^9.0.1", 34 | "cache-manager": "^5.1.3", 35 | "cache-manager-ioredis": "^2.1.0", 36 | "class-transformer": "^0.5.1", 37 | "class-validator": "^0.13.2", 38 | "cookie-parser": "^1.4.6", 39 | "cross-env": "^7.0.3", 40 | "dotenv": "^16.0.3", 41 | "mysql2": "^2.3.3", 42 | "reflect-metadata": "^0.1.13", 43 | "rimraf": "^3.0.2", 44 | "rxjs": "^7.2.0", 45 | "typeorm": "^0.3.10", 46 | "typeorm-model-generator": "^0.4.6", 47 | "uuid": "^9.0.0", 48 | "wrtc": "^0.4.7" 49 | }, 50 | "devDependencies": { 51 | "@nestjs/cli": "^9.0.0", 52 | "@nestjs/schematics": "^9.0.0", 53 | "@nestjs/testing": "^9.0.0", 54 | "@types/cache-manager": "^4.0.2", 55 | "@types/cache-manager-ioredis": "^2.0.3", 56 | "@types/cookie-parser": "^1.4.3", 57 | "@types/express": "^4.17.13", 58 | "@types/jest": "28.1.8", 59 | "@types/node": "^16.0.0", 60 | "@types/socket.io": "^3.0.2", 61 | "@types/supertest": "^2.0.11", 62 | "@types/ws": "^8.5.3", 63 | "@typescript-eslint/eslint-plugin": "^5.0.0", 64 | "@typescript-eslint/parser": "^5.0.0", 65 | "eslint": "^8.0.1", 66 | "eslint-config-prettier": "^8.3.0", 67 | "eslint-plugin-prettier": "^4.0.0", 68 | "jest": "28.1.3", 69 | "prettier": "^2.3.2", 70 | "source-map-support": "^0.5.20", 71 | "supertest": "^6.1.3", 72 | "ts-jest": "28.0.8", 73 | "ts-loader": "^9.2.3", 74 | "ts-node": "^10.0.0", 75 | "tsconfig-paths": "4.1.0", 76 | "typescript": "^4.7.4" 77 | }, 78 | "jest": { 79 | "moduleFileExtensions": [ 80 | "js", 81 | "json", 82 | "ts" 83 | ], 84 | "rootDir": "src", 85 | "testRegex": ".*\\.spec\\.ts$", 86 | "transform": { 87 | "^.+\\.(t|j)s$": "ts-jest" 88 | }, 89 | "collectCoverageFrom": [ 90 | "**/*.(t|j)s" 91 | ], 92 | "coverageDirectory": "../coverage", 93 | "testEnvironment": "node" 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /backend/src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | 5 | describe('AppController', () => { 6 | let appController: AppController; 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | providers: [AppService], 12 | }).compile(); 13 | 14 | appController = app.get(AppController); 15 | }); 16 | 17 | describe('root', () => { 18 | it('should return "Hello World!"', () => { 19 | expect(appController.getHello()).toBe('Hello World!'); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /backend/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | 4 | @Controller() 5 | export class AppController { 6 | constructor(private readonly appService: AppService) {} 7 | 8 | @Get() 9 | getHello(): string { 10 | return this.appService.getHello(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /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 { UserModule } from './user/user.module'; 6 | import { User } from './user/entities/user.entity'; 7 | import { CommentModule } from './comment/comment.module'; 8 | import { StudyRoomModule } from './study-room/study-room.module'; 9 | import { Comment } from './comment/entities/comments.entity'; 10 | import { StudyRoom } from './study-room/entities/studyRoom.entity'; 11 | import { ConfigModule } from '@nestjs/config'; 12 | import { RedisCacheModule } from './redis/redis-cache.module'; 13 | 14 | @Module({ 15 | imports: [ 16 | ConfigModule.forRoot({ 17 | envFilePath: `.env.${process.env.NODE_ENV}`, 18 | isGlobal: true, 19 | }), 20 | TypeOrmModule.forRoot({ 21 | type: 'mysql', 22 | host: process.env.DB_HOST, 23 | port: Number(process.env.DB_PORT), 24 | username: process.env.DB_USERNAME, 25 | password: process.env.DB_PASSWORD, 26 | database: process.env.DB_DATABASE, 27 | entities: [User, Comment, StudyRoom], 28 | synchronize: false, 29 | }), 30 | RedisCacheModule, 31 | UserModule, 32 | CommentModule, 33 | StudyRoomModule, 34 | ], 35 | controllers: [AppController], 36 | providers: [AppService], 37 | }) 38 | export class AppModule {} 39 | -------------------------------------------------------------------------------- /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/comment/comment.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | @Module({}) 4 | export class CommentModule {} 5 | -------------------------------------------------------------------------------- /backend/src/comment/entities/comments.entity.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../../user/entities/user.entity'; 2 | import { 3 | Column, 4 | Entity, 5 | JoinTable, 6 | ManyToMany, 7 | PrimaryGeneratedColumn, 8 | } from 'typeorm'; 9 | 10 | @Entity('T_COMMENT', { schema: 'tody' }) 11 | export class Comment { 12 | @PrimaryGeneratedColumn({ type: 'int', name: 'COMMENT_ID' }) 13 | commentId: number; 14 | 15 | @Column('varchar', { name: 'COMMENT_CONTENT', nullable: true, length: 4000 }) 16 | commentContent: string | null; 17 | 18 | @Column('timestamp', { name: 'CREATE_TIME', nullable: true }) 19 | createTime: Date | null; 20 | 21 | @Column('int', { name: 'QUESTION_ID' }) 22 | questionId: number; 23 | 24 | @Column('varchar', { name: 'USER_ID', length: 50 }) 25 | userId: string; 26 | 27 | @ManyToMany(() => User, (User) => User.Comments) 28 | @JoinTable({ 29 | name: 'T_COMMENT_LIKE', 30 | joinColumns: [{ name: 'COMMENT_ID', referencedColumnName: 'commentId' }], 31 | inverseJoinColumns: [{ name: 'USER_ID', referencedColumnName: 'userId' }], 32 | schema: 'tody', 33 | }) 34 | Users: User[]; 35 | } 36 | -------------------------------------------------------------------------------- /backend/src/config/cors.config.ts: -------------------------------------------------------------------------------- 1 | const origin = 2 | process.env.NODE_ENV === 'development' 3 | ? 'http://localhost:3000' 4 | : ['https://tody.kr', 'https://j221-test.tk']; 5 | 6 | export default { credentials: true, origin }; 7 | -------------------------------------------------------------------------------- /backend/src/constants/sfuEvents.ts: -------------------------------------------------------------------------------- 1 | const SFU_EVENTS = { 2 | JOIN: 'join', 3 | CONNECT: 'connect', 4 | NOTICE_ALL_PEERS: 'notice-all-peers', 5 | RECEIVER_ANSWER: 'receiverAnswer', 6 | RECEIVER_OFFER: 'receiverOffer', 7 | SENDER_ANSWER: 'senderAnswer', 8 | SENDER_OFFER: 'senderOffer', 9 | RECEIVER_ICECANDIDATE: 'receiverIcecandidate', 10 | SENDER_ICECANDIDATE: 'senderIcecandidate', 11 | NEW_PEER: 'new-peer', 12 | SOMEONE_LEFT_ROOM: 'someone-left-room', 13 | DISCONNECTING: 'disconnecting', 14 | }; 15 | 16 | export default SFU_EVENTS; 17 | -------------------------------------------------------------------------------- /backend/src/filter/all-exceptions.filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExceptionFilter, 3 | Catch, 4 | ArgumentsHost, 5 | HttpException, 6 | HttpStatus, 7 | } from '@nestjs/common'; 8 | 9 | @Catch() 10 | export class AllExceptionsFilter implements ExceptionFilter { 11 | catch(exception: unknown, host: ArgumentsHost) { 12 | const ctx = host.switchToHttp(); 13 | const res = ctx.getResponse(); 14 | if (exception instanceof HttpException) { 15 | res.status(exception.getStatus()).json(exception.getResponse()); 16 | return; 17 | } 18 | res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ 19 | statusCode: 500, 20 | message: '예상치 못한 오류가 발생하였습니다.', 21 | error: 'Internal Server Error', 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /backend/src/filter/socket-exceptions.filter.ts: -------------------------------------------------------------------------------- 1 | import { Catch, ArgumentsHost } from '@nestjs/common'; 2 | import { BaseWsExceptionFilter, WsException } from '@nestjs/websockets'; 3 | 4 | @Catch() 5 | export class SocketExceptionsFilter extends BaseWsExceptionFilter { 6 | catch(exception: unknown, host: ArgumentsHost) { 7 | super.catch(exception, host); 8 | if (exception instanceof WsException) { 9 | console.log(`WsException : ${exception.message}`); 10 | return; 11 | } 12 | console.log('unexpected socket error.'); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /backend/src/global-chat/globalChat.gateway.ts: -------------------------------------------------------------------------------- 1 | import { UseFilters } from '@nestjs/common'; 2 | import { 3 | MessageBody, 4 | SubscribeMessage, 5 | WebSocketGateway, 6 | WebSocketServer, 7 | OnGatewayConnection, 8 | OnGatewayDisconnect, 9 | OnGatewayInit, 10 | ConnectedSocket, 11 | } from '@nestjs/websockets'; 12 | import { Server, Socket } from 'socket.io'; 13 | import { SocketExceptionsFilter } from 'src/filter/socket-exceptions.filter'; 14 | 15 | @UseFilters(new SocketExceptionsFilter()) 16 | @WebSocketGateway({ cors: true, path: '/globalChat' }) 17 | export class globalChatGateway 18 | implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect 19 | { 20 | @WebSocketServer() server: Server; 21 | 22 | afterInit(server: Server) { 23 | console.log('globalChat socket server is running!!'); 24 | } 25 | 26 | async handleConnection(@ConnectedSocket() client: Socket) { 27 | console.log(`connected: ${client.id}`); 28 | client.join('global'); 29 | client.on('disconnecting', () => { 30 | console.log(client.id); 31 | }); 32 | } 33 | 34 | async handleDisconnect(client: Socket) { 35 | console.log(`disconnect: ${client.id}`); 36 | } 37 | 38 | @SubscribeMessage('globalChat') 39 | async handleGlobalChat( 40 | @ConnectedSocket() 41 | client: Socket, 42 | @MessageBody() body: { nickname: string; chat: string }, 43 | ) { 44 | client.broadcast 45 | .to('global') 46 | .emit('globalChat', { nickname: body.nickname, chat: body.chat }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /backend/src/global-chat/globalChat.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { globalChatGateway } from './globalChat.gateway'; 3 | 4 | @Module({ 5 | providers: [globalChatGateway], 6 | }) 7 | export class globalChatModule {} 8 | -------------------------------------------------------------------------------- /backend/src/main.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import { NestFactory } from '@nestjs/core'; 4 | import { AppModule } from './app.module'; 5 | import { ValidationPipe } from '@nestjs/common'; 6 | import { AllExceptionsFilter } from './filter/all-exceptions.filter'; 7 | import * as cookieParser from 'cookie-parser'; 8 | import corsConfig from './config/cors.config'; 9 | import { SfuModule } from './sfu/sfu.module'; 10 | import { globalChatModule } from './global-chat/globalChat.module'; 11 | 12 | async function bootstrap() { 13 | const app = await NestFactory.create(AppModule, { 14 | cors: corsConfig, 15 | }); 16 | app.use(cookieParser()); 17 | app.useGlobalPipes( 18 | new ValidationPipe({ 19 | whitelist: true, 20 | forbidNonWhitelisted: true, 21 | transform: true, 22 | }), 23 | ); 24 | app.useGlobalFilters(new AllExceptionsFilter()); 25 | await app.listen(5000); 26 | console.log('Server is running.'); 27 | 28 | const globalChatApp = await NestFactory.create(globalChatModule, { 29 | cors: true, 30 | }); 31 | 32 | await globalChatApp.listen(8000); 33 | // const sfuApp = await NestFactory.create(SfuModule, { 34 | // cors: true, 35 | // }); 36 | // await sfuApp.listen(9000); 37 | } 38 | bootstrap(); 39 | -------------------------------------------------------------------------------- /backend/src/mediaServer/mediaServer.gateway.ts: -------------------------------------------------------------------------------- 1 | import { 2 | WebSocketGateway, 3 | WebSocketServer, 4 | SubscribeMessage, 5 | MessageBody, 6 | ConnectedSocket, 7 | } from '@nestjs/websockets'; 8 | import { Server, Socket } from 'socket.io'; 9 | import * as wrtc from 'wrtc'; 10 | 11 | const PCConfig = { 12 | iceServers: [ 13 | { urls: 'stun:101.101.219.107:3478' }, 14 | { 15 | urls: 'turn:101.101.219.107:3478', 16 | username: 'test', 17 | credential: 'test123', 18 | }, 19 | ], 20 | }; 21 | 22 | let receiverPeerConnectionInfo = {}; 23 | let senderPeerConnectionInfo = {}; 24 | const userInfo = {}; // socketId가 key, stream이 value 25 | const roomInfoPerSocket = {}; 26 | 27 | export function deleteUser(toDeleleteUserSocketId) { 28 | delete userInfo[toDeleleteUserSocketId]; 29 | delete roomInfoPerSocket[toDeleleteUserSocketId]; 30 | } 31 | 32 | @WebSocketGateway({ cors: true }) 33 | export class MediaServerGateway { 34 | @WebSocketServer() server: Server; 35 | 36 | handleConnection(@ConnectedSocket() client: Socket) { 37 | console.log(`connected: ${client.id}`); 38 | } 39 | 40 | handleDisconnect(@ConnectedSocket() client: Socket) { 41 | console.log('disconnected', client.id); 42 | const roomId = roomInfoPerSocket[client.id]; 43 | deleteUser(client.id); 44 | closeReceivePeerConnection(client.id); 45 | closeSendPeerConnection(client.id); 46 | client.broadcast.to(roomId).emit('userLeftRoom', { socketId: client.id }); 47 | } 48 | 49 | @SubscribeMessage('senderOffer') 50 | async handleSenderOffer( 51 | @ConnectedSocket() client: Socket, 52 | @MessageBody() data: any, 53 | ) { 54 | const { senderSdp, roomId } = data; 55 | const senderSocketId = client.id; 56 | roomInfoPerSocket[senderSocketId] = roomId; 57 | 58 | const pc = createReceiverPeerConnection(senderSocketId, client, roomId); 59 | await pc.setRemoteDescription(senderSdp); 60 | const sdp = await pc.createAnswer({ 61 | offerToReceiveAudio: true, 62 | offerToReceiveVideo: true, 63 | }); 64 | await pc.setLocalDescription(sdp); 65 | 66 | client.join(roomId); 67 | this.server.to(senderSocketId).emit('getSenderAnswer', { 68 | receiverSdp: sdp, 69 | }); 70 | } 71 | @SubscribeMessage('senderCandidate') 72 | async handleSenderCandidate( 73 | @ConnectedSocket() client: Socket, 74 | @MessageBody() data: any, 75 | ) { 76 | const pc = receiverPeerConnectionInfo[client.id]; 77 | await pc.addIceCandidate(new wrtc.RTCIceCandidate(data.candidate)); 78 | } 79 | 80 | @SubscribeMessage('getUserList') 81 | async handle(@ConnectedSocket() client: Socket, @MessageBody() data: any) { 82 | const getSocketListOfRoom = await this.server 83 | .in(data.roomId) 84 | .fetchSockets(); 85 | const getOtherUserListOfRoom = getSocketListOfRoom 86 | .filter((socket) => socket.id !== client.id) 87 | .map((socket) => ({ 88 | socketId: socket.id, 89 | })); 90 | client.emit('allUserList', { 91 | allUserList: getOtherUserListOfRoom, 92 | }); 93 | } 94 | 95 | @SubscribeMessage('receiverOffer') 96 | async handleReceiverOffer( 97 | @ConnectedSocket() client: Socket, 98 | @MessageBody() data: any, 99 | ) { 100 | console.log('--receive Offer--'); 101 | const { receiverSdp, senderSocketId, roomId } = data; 102 | const receiverSocketId = client.id; 103 | const pc = createSenderPeerConnection(client, roomId, senderSocketId); 104 | await pc.setRemoteDescription(receiverSdp); 105 | const sdp = await pc.createAnswer(); 106 | await pc.setLocalDescription(sdp); 107 | this.server.to(receiverSocketId).emit('getReceiverAnswer', { 108 | senderSdp: sdp, 109 | senderSocketId: senderSocketId, 110 | }); 111 | } 112 | @SubscribeMessage('receiverCandidate') 113 | async handleReceiverCandidate( 114 | @ConnectedSocket() client: Socket, 115 | @MessageBody() data: any, 116 | ) { 117 | const senderPC = senderPeerConnectionInfo[data.senderSocketId].filter( 118 | (sPC) => sPC.socketId === client.id, 119 | )[0]; 120 | await senderPC.pc.addIceCandidate(new wrtc.RTCIceCandidate(data.candidate)); 121 | } 122 | } 123 | 124 | function createReceiverPeerConnection(senderSocketId, socket, roomId) { 125 | const pc = new wrtc.RTCPeerConnection(PCConfig); 126 | 127 | if (receiverPeerConnectionInfo[senderSocketId]) 128 | receiverPeerConnectionInfo[senderSocketId] = pc; 129 | else 130 | receiverPeerConnectionInfo = { 131 | ...receiverPeerConnectionInfo, 132 | [senderSocketId]: pc, 133 | }; 134 | 135 | pc.onicecandidate = (e) => { 136 | console.log('receiver oniceCandidate'); 137 | socket.to(senderSocketId).emit('getSenderCandidate', { 138 | candidate: e.candidate, 139 | }); 140 | }; 141 | 142 | pc.ontrack = (e) => { 143 | console.log('--------ontrack------'); 144 | console.log(e.streams[0]); 145 | 146 | if (userInfo[senderSocketId]) return; 147 | userInfo[senderSocketId] = e.streams[0]; 148 | socket.broadcast 149 | .to(roomId) 150 | .emit('enterNewUser', { socketId: senderSocketId }); 151 | }; 152 | 153 | return pc; 154 | } 155 | 156 | function createSenderPeerConnection(socket, roomId, senderSocketId) { 157 | const pc = new wrtc.RTCPeerConnection(PCConfig); 158 | const receiverSocketId = socket.id; 159 | if (senderPeerConnectionInfo[senderSocketId]) { 160 | senderPeerConnectionInfo[senderSocketId] = senderPeerConnectionInfo[ 161 | senderSocketId 162 | ] 163 | .filter((user) => user.socketId !== receiverSocketId) 164 | .concat({ 165 | socketId: receiverSocketId, 166 | pc, 167 | }); 168 | } else { 169 | senderPeerConnectionInfo = { 170 | ...senderPeerConnectionInfo, 171 | [senderSocketId]: [{ socketId: receiverSocketId, pc }], 172 | }; 173 | } 174 | 175 | pc.onicecandidate = (e) => { 176 | console.log('sender oniceCandidate'); 177 | socket.to(receiverSocketId).emit('getReceiverCandidate', { 178 | candidate: e.candidate, 179 | senderSocketId: senderSocketId, 180 | }); 181 | }; 182 | 183 | const sendUser = userInfo[senderSocketId]; 184 | 185 | sendUser.getTracks().forEach((track) => { 186 | pc.addTrack(track, sendUser); 187 | }); 188 | 189 | return pc; 190 | } 191 | 192 | function closeReceivePeerConnection(toCloseSocketId) { 193 | if (!receiverPeerConnectionInfo[toCloseSocketId]) return; 194 | 195 | receiverPeerConnectionInfo[toCloseSocketId].close(); 196 | delete receiverPeerConnectionInfo[toCloseSocketId]; 197 | } 198 | 199 | function closeSendPeerConnection(toCloseSocketId) { 200 | if (!senderPeerConnectionInfo[toCloseSocketId]) return; 201 | 202 | senderPeerConnectionInfo[toCloseSocketId].forEach((senderPC) => { 203 | senderPC.pc.close(); 204 | 205 | if (!senderPeerConnectionInfo[senderPC.socektId]) return; 206 | 207 | senderPeerConnectionInfo[senderPC.socektId] = senderPeerConnectionInfo[ 208 | senderPC.socektId 209 | ].redecue((filtered, sPC) => { 210 | if (sPC.socketId === toCloseSocketId) { 211 | sPC.pc.close(); 212 | return; 213 | } 214 | filtered.push(sPC); 215 | return filtered; 216 | }, []); 217 | }); 218 | 219 | delete senderPeerConnectionInfo[toCloseSocketId]; 220 | } 221 | -------------------------------------------------------------------------------- /backend/src/mediaServer/mediaServer.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MediaServerGateway } from './mediaServer.gateway'; 3 | 4 | @Module({ 5 | providers: [MediaServerGateway], 6 | }) 7 | export class MediaServerModule {} 8 | -------------------------------------------------------------------------------- /backend/src/redis/redis-cache.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Get, Post, Query } from '@nestjs/common'; 2 | import { RedisCacheService } from './redis-cache.service'; 3 | 4 | @Controller() 5 | export class RedisCacheController { 6 | constructor(private redisCacheService: RedisCacheService) {} 7 | 8 | @Get('/cache') 9 | async getCache(@Query('key') key: string): Promise { 10 | const value = await this.redisCacheService.getValue(key); 11 | console.log(value); 12 | return value; 13 | } 14 | 15 | @Post('/cache') 16 | async setCache(@Body() cache): Promise { 17 | const result = await this.redisCacheService.setKey(cache.key, cache.value); 18 | return result; 19 | } 20 | 21 | @Post('/user/enterRoom') 22 | async enter( 23 | @Body() 24 | body: { 25 | studyRoomId: number; 26 | userId: string; 27 | nickname: string; 28 | isMaster: boolean; 29 | }, 30 | ): Promise { 31 | return await this.redisCacheService.enterRoom(body); 32 | } 33 | 34 | @Post('/user/leaveRoom') 35 | async leave( 36 | @Body() body: { studyRoomId: number; userId: string }, 37 | ): Promise { 38 | return await this.redisCacheService.leaveRoom(body); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /backend/src/redis/redis-cache.module.ts: -------------------------------------------------------------------------------- 1 | import { CacheModule, Module } from '@nestjs/common'; 2 | import * as redisStore from 'cache-manager-ioredis'; 3 | import { RedisCacheController } from './redis-cache.controller'; 4 | import { RedisCacheService } from './redis-cache.service'; 5 | 6 | @Module({ 7 | imports: [ 8 | CacheModule.register({ 9 | store: redisStore, 10 | host: process.env.REDIS_HOST, 11 | port: process.env.REDIS_PORT, 12 | password: process.env.REDIS_PASSWORD, 13 | ttl: 0, 14 | }), 15 | ], 16 | controllers: [RedisCacheController], 17 | providers: [RedisCacheService], 18 | exports: [RedisCacheService], 19 | }) 20 | export class RedisCacheModule {} 21 | -------------------------------------------------------------------------------- /backend/src/redis/redis-cache.service.ts: -------------------------------------------------------------------------------- 1 | import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common'; 2 | import { Cache } from 'cache-manager'; 3 | 4 | @Injectable() 5 | export class RedisCacheService { 6 | constructor(@Inject(CACHE_MANAGER) private readonly cacheManager: Cache) {} 7 | 8 | async setKey(key: string, value: string): Promise { 9 | await this.cacheManager.set(key, value); 10 | return true; 11 | } 12 | 13 | async getValue(key: string): Promise { 14 | const valueFromKey = (await this.cacheManager.get(key)) as string; 15 | return valueFromKey; 16 | } 17 | 18 | async getRoomValue( 19 | studyRoomId: number, 20 | ): Promise<{ [id: string]: { nickname: string; isMaster: boolean } }> { 21 | return await this.cacheManager.get(`studyRoom${studyRoomId}`); 22 | } 23 | 24 | async deleteRoomValue(studyRoomId: number): Promise { 25 | return await this.cacheManager.del(`studyRoom${studyRoomId}`); 26 | } 27 | 28 | async enterRoom(body: { 29 | studyRoomId: number; 30 | userId: string; 31 | nickname: string; 32 | isMaster: boolean; 33 | }): Promise { 34 | const studyRoomId = body.studyRoomId; 35 | const userId = body.userId; 36 | const nickname = body.nickname; 37 | const isMaster = body.isMaster; 38 | const key = `studyRoom${studyRoomId}`; 39 | const roomValue = await this.cacheManager.get(key); 40 | 41 | await this.cacheManager.set(`isInRoom${userId}`, true); 42 | 43 | if (roomValue) { 44 | roomValue[userId] = { nickname, isMaster }; 45 | await this.cacheManager.set(key, roomValue); 46 | return; 47 | } 48 | 49 | if (!roomValue) { 50 | const roomValue = {}; 51 | roomValue[userId] = { nickname, isMaster }; 52 | await this.cacheManager.set(key, roomValue); 53 | return; 54 | } 55 | } 56 | 57 | async leaveRoom(body: { 58 | studyRoomId: number; 59 | userId: string; 60 | }): Promise { 61 | const studyRoomId = body.studyRoomId; 62 | const userId = body.userId; 63 | const key = `studyRoom${studyRoomId}`; 64 | 65 | if (!studyRoomId || !userId) return; 66 | await this.cacheManager.del(`isInRoom${userId}`); 67 | 68 | const roomValue = await this.cacheManager.get(key); 69 | if (roomValue) { 70 | delete roomValue[userId]; 71 | await this.cacheManager.set(key, roomValue); 72 | } 73 | return; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /backend/src/sfu/sfu.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { SfuGateway } from './sfu.gateway'; 3 | 4 | @Module({ 5 | providers: [SfuGateway], 6 | }) 7 | export class SfuModule {} 8 | -------------------------------------------------------------------------------- /backend/src/socket/socket.gateway.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MessageBody, 3 | SubscribeMessage, 4 | WebSocketGateway, 5 | WebSocketServer, 6 | OnGatewayConnection, 7 | OnGatewayDisconnect, 8 | OnGatewayInit, 9 | ConnectedSocket, 10 | } from '@nestjs/websockets'; 11 | import { Server, Socket } from 'socket.io'; 12 | 13 | @WebSocketGateway({ cors: true }) 14 | export class SocketGateway 15 | implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect 16 | { 17 | @WebSocketServer() server: Server; 18 | 19 | afterInit(server: Server) { 20 | console.log('Socket server is running'); 21 | } 22 | 23 | async handleConnection(@ConnectedSocket() client: Socket) { 24 | console.log(`connected: ${client.id}`); 25 | client.on('disconnecting', () => { 26 | [...client.rooms].slice(1).forEach((roomName) => { 27 | client.to(roomName).emit('someone-left-room', client.id); 28 | }); 29 | }); 30 | } 31 | 32 | async handleDisconnect(client: Socket) { 33 | console.log(`disconnect: ${client.id}`); 34 | } 35 | 36 | @SubscribeMessage('join') 37 | async handleJoin( 38 | @ConnectedSocket() 39 | client: Socket, 40 | @MessageBody() roomName: string, 41 | ) { 42 | client.join(roomName); 43 | const socketsInRoom = await this.server.in(roomName).fetchSockets(); 44 | const peerIdsInRoom = socketsInRoom 45 | .filter((socket) => socket.id !== client.id) 46 | .map((socket) => socket.id); 47 | client.emit('notice-all-peers', peerIdsInRoom); 48 | } 49 | 50 | @SubscribeMessage('answer') 51 | handleAnswer( 52 | @ConnectedSocket() 53 | client: Socket, 54 | @MessageBody() { answer, fromId, toId }: any, 55 | ) { 56 | this.server.to(toId).emit('answer', { answer, fromId, toId }); 57 | } 58 | 59 | @SubscribeMessage('offer') 60 | handleOffer( 61 | @ConnectedSocket() client: Socket, 62 | @MessageBody() { offer, fromId, toId }: any, 63 | ) { 64 | this.server.to(toId).emit('offer', { offer, fromId, toId }); 65 | } 66 | 67 | @SubscribeMessage('icecandidate') 68 | handleIcecandidate( 69 | @ConnectedSocket() client: Socket, 70 | @MessageBody() { icecandidate, fromId, toId }: any, 71 | ) { 72 | this.server.to(toId).emit('icecandidate', { icecandidate, fromId, toId }); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /backend/src/socket/socket.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { SocketGateway } from './socket.gateway'; 3 | 4 | @Module({ 5 | providers: [SocketGateway], 6 | }) 7 | export class SocketModule {} 8 | -------------------------------------------------------------------------------- /backend/src/study-room/dto/createRoom.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsNumber, 3 | IsOptional, 4 | IsString, 5 | MaxLength, 6 | Min, 7 | } from 'class-validator'; 8 | 9 | export class createRoomDto { 10 | @IsString() 11 | readonly managerId: string; 12 | 13 | @IsString() 14 | @MaxLength(25, { message: 'title is too long' }) 15 | readonly name: string; 16 | 17 | @IsString() 18 | @MaxLength(100, { message: 'content is too long' }) 19 | readonly content: string; 20 | 21 | @IsNumber() 22 | @Min(1) 23 | readonly maxPersonnel: number; 24 | 25 | @IsOptional() 26 | @IsString({ each: true }) 27 | readonly tags: string[]; 28 | } 29 | -------------------------------------------------------------------------------- /backend/src/study-room/entities/studyRoom.entity.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../../user/entities/user.entity'; 2 | import { 3 | Column, 4 | Entity, 5 | JoinColumn, 6 | ManyToOne, 7 | PrimaryGeneratedColumn, 8 | } from 'typeorm'; 9 | 10 | @Entity('T_STUDY_ROOM', { schema: 'tody' }) 11 | export class StudyRoom { 12 | @PrimaryGeneratedColumn({ type: 'int', name: 'STUDY_ROOM_ID' }) 13 | studyRoomId: number; 14 | 15 | @Column('varchar', { name: 'STUDY_ROOM_NAME', nullable: true, length: 50 }) 16 | studyRoomName: string | null; 17 | 18 | @Column('varchar', { 19 | name: 'STUDY_ROOM_CONTENT', 20 | nullable: true, 21 | length: 255, 22 | }) 23 | studyRoomContent: string | null; 24 | 25 | @Column('int', { name: 'MAX_PERSONNEL', nullable: true }) 26 | maxPersonnel: number | null; 27 | 28 | @Column('varchar', { name: 'TAG1', nullable: true, length: 50 }) 29 | tag1: string | null; 30 | 31 | @Column('varchar', { name: 'TAG2', nullable: true, length: 50 }) 32 | tag2: string | null; 33 | 34 | //@Column('varchar', { name: 'MANAGER_ID', length: 50 }) 35 | @ManyToOne(() => User, (user) => user.userId) 36 | @JoinColumn({ name: 'MANAGER_ID' }) 37 | managerId: User; 38 | 39 | @Column('timestamp', { name: 'CREATE_TIME', nullable: true }) 40 | createTime: Date | null; 41 | } 42 | -------------------------------------------------------------------------------- /backend/src/study-room/study-room.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { StudyRoomController } from './study-room.controller'; 3 | import { Repository } from 'typeorm'; 4 | import { StudyRoomService } from './study-room.service'; 5 | import { StudyRoom } from './entities/studyRoom.entity'; 6 | import { getRepositoryToken } from '@nestjs/typeorm'; 7 | import { RedisCacheService } from '../redis/redis-cache.service'; 8 | import { CACHE_MANAGER } from '@nestjs/common'; 9 | 10 | const mockStudyRoomRepository = () => ({ 11 | save: jest.fn(), 12 | }); 13 | 14 | type MockRepository = Partial, jest.Mock>>; 15 | 16 | describe('StudyRoomController', () => { 17 | let controller: StudyRoomController; 18 | let service: StudyRoomService; 19 | let studyRoomRepository: MockRepository; 20 | 21 | beforeEach(async () => { 22 | const module: TestingModule = await Test.createTestingModule({ 23 | controllers: [StudyRoomController], 24 | providers: [ 25 | StudyRoomService, 26 | { 27 | provide: getRepositoryToken(StudyRoom), 28 | useValue: mockStudyRoomRepository(), 29 | }, 30 | RedisCacheService, 31 | { 32 | provide: CACHE_MANAGER, 33 | useValue: {}, 34 | }, 35 | ], 36 | }).compile(); 37 | 38 | controller = module.get(StudyRoomController); 39 | service = module.get(StudyRoomService); 40 | studyRoomRepository = module.get>( 41 | getRepositoryToken(StudyRoom), 42 | ); 43 | }); 44 | 45 | it('should be defined', () => { 46 | expect(controller).toBeDefined(); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /backend/src/study-room/study-room.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | HttpCode, 6 | Param, 7 | Post, 8 | Query, 9 | } from '@nestjs/common'; 10 | import { StudyRoomService } from './study-room.service'; 11 | import { createRoomDto } from './dto/createRoom.dto'; 12 | import { DeleteResult } from 'typeorm'; 13 | 14 | @Controller('study-room') 15 | export class StudyRoomController { 16 | constructor(private studyRoomService: StudyRoomService) {} 17 | 18 | @Post() 19 | @HttpCode(200) 20 | async createRoom(@Body() roomInfo: createRoomDto): Promise { 21 | const createdRoomID = await this.studyRoomService.createStudyRoom(roomInfo); 22 | return createdRoomID; 23 | } 24 | 25 | @Get() 26 | @HttpCode(200) 27 | async searchRoom( 28 | @Query('keyword') keyword: string, 29 | @Query('attendable') attendable: boolean, 30 | @Query('page') page: number, 31 | ): Promise<{ 32 | keyword: string; 33 | currentPage: number; 34 | pageCount: number; 35 | totalCount: number; 36 | studyRoomList: { 37 | studyRoomId: number; 38 | name: string; 39 | content: string; 40 | currentPersonnel: number; 41 | maxPersonnel: number; 42 | managerNickname: string; 43 | tags: string[]; 44 | nickNameOfParticipants: string[]; 45 | created: string; 46 | }[]; 47 | }> { 48 | const searchResult = await this.studyRoomService.searchStudyRoomList( 49 | keyword, 50 | attendable, 51 | page, 52 | ); 53 | return searchResult; 54 | } 55 | 56 | @Get('/roomInfo/:roomId') 57 | async getRoom(@Param('roomId') roomId: number): Promise<{ 58 | studyRoomId: number; 59 | name: string; 60 | content: string; 61 | currentPersonnel: number; 62 | maxPersonnel: number; 63 | managerNickname: string; 64 | tags: string[]; 65 | nickNameOfParticipants: string[]; 66 | created: string; 67 | }> { 68 | const result = await this.studyRoomService.getStudyRoom(roomId); 69 | return result; 70 | } 71 | 72 | @Get('/participants') 73 | @HttpCode(200) 74 | async getParticiantsOfRoom( 75 | @Query('study-room-id') studyRoomId: string, 76 | ): Promise { 77 | const participantsList = await this.studyRoomService.getParticipants( 78 | studyRoomId, 79 | ); 80 | return participantsList; 81 | } 82 | 83 | @Get('/enterable') 84 | async checkEnterable( 85 | @Query() query: { roomId: number; userId: string }, 86 | ): Promise<{ enterable: boolean }> { 87 | const { enterable } = await this.studyRoomService.checkEnterable( 88 | query.roomId, 89 | query.userId, 90 | ); 91 | return { enterable }; 92 | } 93 | 94 | @Post('/check-master') 95 | @HttpCode(200) 96 | async checkMasterOfRoom( 97 | @Body() info: { studyRoomId: number; userId: string }, 98 | ): Promise { 99 | const isMaster = await this.studyRoomService.checkMasterOfRoom( 100 | info.studyRoomId, 101 | info.userId, 102 | ); 103 | return isMaster; 104 | } 105 | 106 | @Post('/deleteRoom') 107 | async leave(@Body() body: { studyRoomId: number }): Promise { 108 | return await this.studyRoomService.deleteRoom(body.studyRoomId); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /backend/src/study-room/study-room.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { RedisCacheModule } from 'src/redis/redis-cache.module'; 4 | import { StudyRoom } from './entities/studyRoom.entity'; 5 | import { StudyRoomController } from './study-room.controller'; 6 | import { StudyRoomService } from './study-room.service'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([StudyRoom]), RedisCacheModule], 10 | controllers: [StudyRoomController], 11 | providers: [StudyRoomService], 12 | }) 13 | export class StudyRoomModule {} 14 | -------------------------------------------------------------------------------- /backend/src/study-room/study-room.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { getRepositoryToken } from '@nestjs/typeorm'; 3 | import { StudyRoom } from './entities/studyRoom.entity'; 4 | import { StudyRoomService } from './study-room.service'; 5 | import { Repository } from 'typeorm'; 6 | import { RedisCacheService } from '../redis/redis-cache.service'; 7 | import { CACHE_MANAGER } from '@nestjs/common'; 8 | 9 | const mockUserRepository = () => ({ 10 | save: jest.fn(), 11 | }); 12 | 13 | type MockRepository = Partial, jest.Mock>>; 14 | 15 | describe('StudyRoomService', () => { 16 | let service: StudyRoomService; 17 | let studyRoomRepository: MockRepository; 18 | let redisCacheService: RedisCacheService; 19 | 20 | beforeEach(async () => { 21 | const module: TestingModule = await Test.createTestingModule({ 22 | providers: [ 23 | RedisCacheService, 24 | { 25 | provide: CACHE_MANAGER, 26 | useValue: {}, 27 | }, 28 | StudyRoomService, 29 | { 30 | provide: getRepositoryToken(StudyRoom), 31 | useValue: mockUserRepository(), 32 | }, 33 | ], 34 | }).compile(); 35 | 36 | service = module.get(StudyRoomService); 37 | studyRoomRepository = module.get>( 38 | getRepositoryToken(StudyRoom), 39 | ); 40 | redisCacheService = module.get(RedisCacheService); 41 | }); 42 | 43 | it('should be defined', () => { 44 | expect(service).toBeDefined(); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /backend/src/user/dto/user.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, MinLength, MaxLength, Matches } from 'class-validator'; 2 | import { OmitType } from '@nestjs/swagger'; 3 | 4 | export class CreateUserDto { 5 | @IsString() 6 | @MinLength(4) 7 | @MaxLength(15) 8 | readonly id: string; 9 | 10 | @IsString() 11 | @Matches(/^(?=.*[a-zA-Z])(?=.*[!@#$%^&*+=-])(?=.*[0-9]).{8,20}$/) 12 | readonly password: string; 13 | 14 | @IsString() 15 | @MinLength(4) 16 | @MaxLength(15) 17 | readonly nickname: string; 18 | } 19 | 20 | export class CheckNicknameDto { 21 | @IsString() 22 | @MinLength(4) 23 | @MaxLength(15) 24 | readonly nickname: string; 25 | } 26 | 27 | export class CheckIdDto { 28 | @IsString() 29 | @MinLength(4) 30 | @MaxLength(15) 31 | readonly id: string; 32 | } 33 | 34 | export class ReadUserDto extends OmitType(CreateUserDto, [ 35 | 'nickname', 36 | ] as const) {} 37 | -------------------------------------------------------------------------------- /backend/src/user/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { StudyRoom } from '../../study-room/entities/studyRoom.entity'; 2 | import { Column, Entity, ManyToMany, OneToMany } from 'typeorm'; 3 | import { Comment } from '../../comment/entities/comments.entity'; 4 | 5 | @Entity('T_USER', { schema: 'tody' }) 6 | export class User { 7 | @Column('varchar', { primary: true, name: 'USER_ID', length: 50 }) 8 | userId: string; 9 | 10 | @Column('varchar', { name: 'USER_PW', nullable: true, length: 100 }) 11 | userPw: string | null; 12 | 13 | @Column('varchar', { name: 'NICKNAME', nullable: true, length: 100 }) 14 | nickname: string | null; 15 | 16 | @Column('varchar', { name: 'SALT', nullable: true, length: 255 }) 17 | salt: string | null; 18 | 19 | @ManyToMany(() => Comment, (Comment) => Comment.Users) 20 | Comments: Comment[]; 21 | 22 | @OneToMany(() => StudyRoom, (studyRoom) => studyRoom.managerId) 23 | studyRoom: StudyRoom[]; 24 | } 25 | -------------------------------------------------------------------------------- /backend/src/user/user.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UserController } from './user.controller'; 3 | import { UserService } from './user.service'; 4 | import { User } from './entities/user.entity'; 5 | import { Repository } from 'typeorm'; 6 | import { JwtService } from '@nestjs/jwt'; 7 | import { getRepositoryToken } from '@nestjs/typeorm'; 8 | 9 | const mockUserRepository = () => ({ 10 | save: jest.fn(), 11 | }); 12 | 13 | type MockRepository = Partial, jest.Mock>>; 14 | 15 | describe('UserController', () => { 16 | let controller: UserController; 17 | let service: UserService; 18 | let userRepository: MockRepository; 19 | 20 | beforeEach(async () => { 21 | const module: TestingModule = await Test.createTestingModule({ 22 | controllers: [UserController], 23 | providers: [ 24 | UserService, 25 | JwtService, 26 | { 27 | provide: getRepositoryToken(User), 28 | useValue: mockUserRepository(), 29 | }, 30 | ], 31 | }).compile(); 32 | 33 | controller = module.get(UserController); 34 | service = module.get(UserService); 35 | userRepository = module.get>(getRepositoryToken(User)); 36 | }); 37 | 38 | it('should be defined', () => { 39 | expect(controller).toBeDefined(); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /backend/src/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | Post, 6 | Param, 7 | Res, 8 | HttpCode, 9 | Req, 10 | } from '@nestjs/common'; 11 | import { UserService } from './user.service'; 12 | import { CreateUserDto, ReadUserDto } from './dto/user.dto'; 13 | import { Response, Request } from 'express'; 14 | 15 | @Controller('user') 16 | export class UserController { 17 | constructor(private userService: UserService) {} 18 | 19 | @Get('silent-login') 20 | async silentLogin( 21 | @Req() request: Request, 22 | ): Promise<{ userId: string; nickname: string }> { 23 | const { accessToken } = request.cookies; 24 | const { userId, nickname } = await this.userService.silentLogin( 25 | accessToken, 26 | ); 27 | return { userId, nickname }; 28 | } 29 | 30 | @Post('signup') 31 | async signup(@Body() userData: CreateUserDto): Promise<{ nickname: string }> { 32 | const { nickname } = await this.userService.createUser(userData); 33 | return { nickname }; 34 | } 35 | 36 | @Get('checkID/:id') 37 | async findOneById( 38 | @Param('id') id: string, 39 | ): Promise<{ isUnique: boolean; id: string }> { 40 | const checkId = await this.userService.findOneById({ id }); 41 | return checkId ? { isUnique: false, id } : { isUnique: true, id }; 42 | } 43 | 44 | @Get('checkNickname/:nickname') 45 | async findOneByNickname( 46 | @Param('nickname') nickname: string, 47 | ): Promise<{ isUnique: boolean; nickname: string }> { 48 | const checkNickname = await this.userService.findOneByNickname({ 49 | nickname, 50 | }); 51 | return checkNickname 52 | ? { isUnique: false, nickname } 53 | : { isUnique: true, nickname }; 54 | } 55 | 56 | @Post('login') 57 | async login( 58 | @Body() userData: ReadUserDto, 59 | @Res({ passthrough: true }) response: Response, 60 | ): Promise<{ userId: string; nickname: string }> { 61 | const { accessToken, userId, nickname } = await this.userService.login( 62 | userData, 63 | ); 64 | response.cookie('accessToken', accessToken, { 65 | httpOnly: true, 66 | maxAge: 43200000, 67 | }); 68 | return { userId, nickname }; 69 | } 70 | 71 | @Get('logout') 72 | @HttpCode(204) 73 | async logout(@Res({ passthrough: true }) response: Response): Promise { 74 | response.clearCookie('accessToken'); 75 | return; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /backend/src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UserController } from './user.controller'; 3 | import { UserService } from './user.service'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { User } from './entities/user.entity'; 6 | import { JwtModule } from '@nestjs/jwt'; 7 | import { ConfigModule } from '@nestjs/config'; 8 | 9 | @Module({ 10 | imports: [ 11 | ConfigModule.forRoot({ envFilePath: `.env.${process.env.NODE_ENV}` }), 12 | TypeOrmModule.forFeature([User]), 13 | JwtModule.register({ secret: process.env.JWT_SECRET }), 14 | ], 15 | controllers: [UserController], 16 | providers: [UserService], 17 | }) 18 | export class UserModule {} 19 | -------------------------------------------------------------------------------- /backend/src/user/user.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UserService } from './user.service'; 3 | import { getRepositoryToken } from '@nestjs/typeorm'; 4 | import { User } from './entities/user.entity'; 5 | import { Repository } from 'typeorm'; 6 | import { JwtService } from '@nestjs/jwt'; 7 | 8 | const mockUserRepository = () => ({ 9 | save: jest.fn(), 10 | }); 11 | 12 | type MockRepository = Partial, jest.Mock>>; 13 | 14 | describe('UserService', () => { 15 | let service: UserService; 16 | let userRepository: MockRepository; 17 | 18 | beforeEach(async () => { 19 | const module: TestingModule = await Test.createTestingModule({ 20 | providers: [ 21 | UserService, 22 | JwtService, 23 | { 24 | provide: getRepositoryToken(User), 25 | useValue: mockUserRepository(), 26 | }, 27 | ], 28 | }).compile(); 29 | 30 | service = module.get(UserService); 31 | userRepository = module.get>(getRepositoryToken(User)); 32 | }); 33 | 34 | it('should be defined', () => { 35 | expect(service).toBeDefined(); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /backend/src/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { User } from '../user/entities/user.entity'; 3 | import { InjectRepository } from '@nestjs/typeorm'; 4 | import { JwtService } from '@nestjs/jwt'; 5 | import { Repository } from 'typeorm'; 6 | import { getSalt, getSecurePassword } from '../utils/salt'; 7 | import { 8 | CreateUserDto, 9 | CheckIdDto, 10 | CheckNicknameDto, 11 | ReadUserDto, 12 | } from './dto/user.dto'; 13 | 14 | @Injectable() 15 | export class UserService { 16 | constructor( 17 | @InjectRepository(User) 18 | private userRepository: Repository, 19 | private jwtService: JwtService, 20 | ) {} 21 | 22 | async silentLogin(accessToken: string) { 23 | try { 24 | const verifiedToken = this.jwtService.verify(accessToken, { 25 | secret: process.env.JWT_SECRET, 26 | }); 27 | const { userId, nickname } = await this.findOneById({ 28 | id: verifiedToken.sub, 29 | }); 30 | return { userId, nickname }; 31 | } catch (err) { 32 | throw new UnauthorizedException(); 33 | } 34 | } 35 | 36 | async createUser({ 37 | id, 38 | password, 39 | nickname, 40 | }: CreateUserDto): Promise<{ nickname: string }> { 41 | const salt = await getSalt(); 42 | const securePassword = await getSecurePassword(password, salt); 43 | const result = await this.userRepository.insert({ 44 | userId: id, 45 | userPw: securePassword, 46 | nickname, 47 | salt, 48 | }); 49 | const insertedUserId = result.identifiers[0].userId; 50 | return this.userRepository.findOne({ 51 | where: { userId: insertedUserId }, 52 | select: ['nickname'], 53 | }); 54 | } 55 | 56 | async findOneById({ id }: CheckIdDto): Promise { 57 | return this.userRepository.findOne({ where: { userId: id } }); 58 | } 59 | 60 | async findOneByNickname({ nickname }: CheckNicknameDto): Promise { 61 | return this.userRepository.findOne({ 62 | where: { nickname }, 63 | }); 64 | } 65 | 66 | async validateUser({ id, password }: ReadUserDto): Promise { 67 | const user = await this.userRepository.findOne({ 68 | where: { userId: id }, 69 | }); 70 | if (!user) return false; 71 | const securePassword = await getSecurePassword(password, user.salt); 72 | return user.userPw !== securePassword ? false : true; 73 | } 74 | 75 | async login( 76 | userData: ReadUserDto, 77 | ): Promise<{ accessToken: string; userId: string; nickname: string }> { 78 | const isValidated = await this.validateUser(userData); 79 | if (isValidated) { 80 | const accessToken = this.jwtService.sign( 81 | {}, 82 | { 83 | expiresIn: '12h', 84 | issuer: 'tody', 85 | subject: userData.id, 86 | }, 87 | ); 88 | const { userId, nickname } = await this.findOneById({ id: userData.id }); 89 | return { 90 | accessToken, 91 | userId, 92 | nickname, 93 | }; 94 | } else { 95 | throw new UnauthorizedException('로그인에 실패하였습니다.'); 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /backend/src/utils/dateFormatter.ts: -------------------------------------------------------------------------------- 1 | const zeroFormatter = (number: number) => { 2 | return number > 9 ? number : `0${number}`; 3 | }; 4 | 5 | export function dateFormatter(date: Date) { 6 | const dateObj = new Date(date); 7 | const year = dateObj.getFullYear(); 8 | const month = dateObj.getMonth() + 1; 9 | const day = dateObj.getDate(); 10 | const hour = dateObj.getHours(); 11 | const minute = dateObj.getMinutes(); 12 | const second = dateObj.getSeconds(); 13 | const dateString = `${year}-${zeroFormatter(month)}-${zeroFormatter( 14 | day, 15 | )} ${zeroFormatter(hour)}:${zeroFormatter(minute)}:${zeroFormatter(second)}`; 16 | return dateString; 17 | } 18 | -------------------------------------------------------------------------------- /backend/src/utils/salt.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'crypto'; 2 | 3 | export function getSalt(): Promise { 4 | return new Promise((res, rej) => { 5 | crypto.randomBytes(32, (err, buf) => { 6 | if (err) rej(err); 7 | res(buf.toString('hex')); 8 | }); 9 | }); 10 | } 11 | 12 | export function getSecurePassword( 13 | password: string, 14 | salt: string, 15 | ): Promise { 16 | return new Promise((res, rej) => { 17 | crypto.pbkdf2(password, salt, 10000, 32, 'sha512', (err, derivedKey) => { 18 | if (err) rej(err); 19 | res(derivedKey.toString('hex')); 20 | }); 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /backend/src/utils/sendDcBodyFormatter.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | 3 | interface Chat { 4 | type: string; 5 | message: string; 6 | sender: string; 7 | } 8 | 9 | interface FormattedChat { 10 | id: string; 11 | type: string; 12 | message: string; 13 | sender: string; 14 | fromId: string; 15 | timestamp: Date | undefined; 16 | } 17 | 18 | export function getChatBody(body: Chat, fromId: string) { 19 | const sendBody: FormattedChat = { 20 | id: '', 21 | fromId: '', 22 | timestamp: undefined, 23 | ...body, 24 | }; 25 | sendBody.fromId = fromId; 26 | sendBody.timestamp = new Date(); 27 | sendBody.id = uuidv4(); 28 | return sendBody; 29 | } 30 | export function getCanvasBody(body, fromId) { 31 | return body; 32 | } 33 | -------------------------------------------------------------------------------- /backend/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /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", "test", "dist", "**/*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 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | }, 6 | extends: [ 7 | 'airbnb', 8 | 'airbnb-typescript', 9 | 'airbnb/hooks', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | parser: '@typescript-eslint/parser', 14 | parserOptions: { 15 | project: './tsconfig.json', 16 | tsconfigRootDir: __dirname, 17 | ecmaFeatures: { 18 | jsx: true, 19 | }, 20 | ecmaVersion: 'latest', 21 | sourceType: 'module', 22 | }, 23 | plugins: ['@typescript-eslint'], 24 | ignorePatterns: ['.eslintrc.js', 'craco.config.js'], 25 | rules: { 26 | 'react-hooks/rules-of-hooks': 'error', 27 | 'react-hooks/exhaustive-deps': 'warn', 28 | '@typescript-eslint/explicit-function-return-type': 'off', 29 | 'react/react-in-jsx-scope': 'off', 30 | 'react/jsx-filename-extension': [ 31 | 2, 32 | { extensions: ['.js', '.jsx', '.ts', '.tsx', '.scss'] }, 33 | ], 34 | 'import/extensions': [ 35 | 2, 36 | 'ignorePackages', 37 | { 38 | js: 'never', 39 | jsx: 'never', 40 | ts: 'never', 41 | tsx: 'never', 42 | }, 43 | ], 44 | 'import/prefer-default-export': 'off', 45 | 'react/function-component-definition': [ 46 | 2, 47 | { 48 | namedComponents: [ 49 | 'function-declaration', 50 | 'function-expression', 51 | 'arrow-function', 52 | ], 53 | }, 54 | ], 55 | 'react/jsx-props-no-spreading': [ 56 | 2, 57 | { 58 | custom: 'ignore', 59 | }, 60 | ], 61 | }, 62 | settings: { 63 | 'import/resolver': { 64 | node: { 65 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 66 | moduleDirectory: ['node_modules', '@types'], 67 | }, 68 | typescript: {}, 69 | }, 70 | }, 71 | }; 72 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # secrets 26 | /secrets -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true, 4 | "tabWidth": 2, 5 | "printWidth": 80, 6 | "arrowParens": "always", 7 | "trailingComma": "all", 8 | "bracketSpacking": true, 9 | "bracketSameLine": true, 10 | "endOfLine": "lf", 11 | "quoteProps": "consistent" 12 | } -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /frontend/craco.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | webpack: { 5 | alias: { 6 | '@components': path.resolve(__dirname, 'src/components'), 7 | '@styles': path.resolve(__dirname, 'src/styles'), 8 | '@utils': path.resolve(__dirname, 'src/utils'), 9 | '@assets': path.resolve(__dirname, 'src/assets'), 10 | '@hooks': path.resolve(__dirname, 'src/hooks'), 11 | '@pages': path.resolve(__dirname, 'src/pages'), 12 | }, 13 | }, 14 | jest: { 15 | configure: { 16 | moduleNameMapper: { 17 | '^\\@components/(.*)$': '/src/components/$1', 18 | '^\\@styles/(.*)$': '/src/styles/$1', 19 | '^\\@utils/(.*)$': '/src/utils/$1', 20 | '^\\@assets/(.*)$': '/src/assets/$1', 21 | '^\\@hooks/(.*)$': '/src/hooks/$1', 22 | '^\\@pages/(.*)$': '/src/pages/$1', 23 | '^axios$': 'axios/dist/node/axios.cjs', 24 | }, 25 | }, 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.16.5", 7 | "@testing-library/react": "^13.4.0", 8 | "@testing-library/user-event": "^13.5.0", 9 | "@types/jest": "^27.5.2", 10 | "@types/node": "^16.11.65", 11 | "@types/react": "^18.0.21", 12 | "@types/react-dom": "^18.0.6", 13 | "axios": "^1.1.3", 14 | "craco": "^0.0.3", 15 | "qs": "^6.11.0", 16 | "react": "^18.2.0", 17 | "react-dom": "^18.2.0", 18 | "react-router-dom": "^6.4.3", 19 | "react-scripts": "5.0.1", 20 | "recoil": "^0.7.6", 21 | "socket.io-client": "^4.5.4", 22 | "styled-components": "^5.3.6", 23 | "typescript": "^4.8.4", 24 | "uuid": "^9.0.0", 25 | "web-vitals": "^2.1.4" 26 | }, 27 | "scripts": { 28 | "start": "craco start --watch", 29 | "build": "craco build", 30 | "test": "craco test", 31 | "eject": "craco eject", 32 | "lint": "eslint src" 33 | }, 34 | "browserslist": { 35 | "production": [ 36 | ">0.2%", 37 | "not dead", 38 | "not op_mini all" 39 | ], 40 | "development": [ 41 | "last 1 chrome version", 42 | "last 1 firefox version", 43 | "last 1 safari version" 44 | ] 45 | }, 46 | "devDependencies": { 47 | "@types/socket.io-client": "^3.0.0", 48 | "@types/styled-components": "^5.1.26", 49 | "@types/uuid": "^9.0.0", 50 | "@typescript-eslint/eslint-plugin": "^5.13.0", 51 | "@typescript-eslint/parser": "^5.0.0", 52 | "eslint": "^8.2.0", 53 | "eslint-config-airbnb": "^19.0.4", 54 | "eslint-config-airbnb-typescript": "^17.0.0", 55 | "eslint-config-prettier": "^8.5.0", 56 | "eslint-import-resolver-typescript": "^3.5.2", 57 | "eslint-plugin-import": "^2.25.3", 58 | "eslint-plugin-jsx-a11y": "^6.5.1", 59 | "eslint-plugin-prettier": "^4.2.1", 60 | "eslint-plugin-react": "^7.28.0", 61 | "eslint-plugin-react-hooks": "^4.3.0", 62 | "prettier": "^2.7.1" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | TODY : Together Study 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /frontend/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { BrowserRouter } from 'react-router-dom'; 3 | import { RecoilRoot } from 'recoil'; 4 | import App from './App'; 5 | 6 | test('renders learn react link', () => { 7 | render( 8 | 9 | 10 | 11 | 12 | , 13 | ); 14 | }); 15 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useRecoilState } from 'recoil'; 2 | import { useEffect, useState } from 'react'; 3 | import useAxios from '@hooks/useAxios'; 4 | import { userState } from 'recoil/atoms'; 5 | import Loader from '@components/common/Loader'; 6 | import { UserData } from 'types/recoil.types'; 7 | import silentLoginRequest from './axios/requests/silentLoginRequest'; 8 | import Router from './routes/Router'; 9 | 10 | function App() { 11 | const [isAuthDone, setIsAuthDone] = useState(false); 12 | const [, , err, silentLoginData] = useAxios(silentLoginRequest, { 13 | onMount: true, 14 | errNavigate: false, 15 | }); 16 | const [user, setUser] = useRecoilState(userState); 17 | 18 | useEffect(() => { 19 | if (!err) return; 20 | setIsAuthDone(true); 21 | }, [err]); 22 | 23 | useEffect(() => { 24 | if (!user) return; 25 | setIsAuthDone(true); 26 | }, [user]); 27 | 28 | useEffect(() => { 29 | if (silentLoginData === null) return; 30 | setUser(silentLoginData); 31 | }, [silentLoginData]); 32 | 33 | if (!isAuthDone) { 34 | return ; 35 | } 36 | 37 | return ; 38 | } 39 | 40 | export default App; 41 | -------------------------------------------------------------------------------- /frontend/src/assets/404.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web30-TODY/bdce222248ccc2f57d1079c33430b36f1ba4ae89/frontend/src/assets/404.jpg -------------------------------------------------------------------------------- /frontend/src/assets/StyledLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web30-TODY/bdce222248ccc2f57d1079c33430b36f1ba4ae89/frontend/src/assets/StyledLogo.png -------------------------------------------------------------------------------- /frontend/src/assets/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web30-TODY/bdce222248ccc2f57d1079c33430b36f1ba4ae89/frontend/src/assets/home.png -------------------------------------------------------------------------------- /frontend/src/assets/icons/canvas.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/chat.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/check.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/create.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/down-triangle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/hashtag.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/king.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/leftArrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/mic-off.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/mic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/monitor-off.svg: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/monitor.svg: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/participants.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/rightArrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/searchBarButton.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/user.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/video-off.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/video.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /frontend/src/assets/loader.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /frontend/src/assets/question.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web30-TODY/bdce222248ccc2f57d1079c33430b36f1ba4ae89/frontend/src/assets/question.png -------------------------------------------------------------------------------- /frontend/src/assets/sample.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web30-TODY/bdce222248ccc2f57d1079c33430b36f1ba4ae89/frontend/src/assets/sample.jpg -------------------------------------------------------------------------------- /frontend/src/assets/study.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web30-TODY/bdce222248ccc2f57d1079c33430b36f1ba4ae89/frontend/src/assets/study.png -------------------------------------------------------------------------------- /frontend/src/axios/instances/axiosBackend.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | export default axios.create({ baseURL: process.env.REACT_APP_API_URL }); 4 | -------------------------------------------------------------------------------- /frontend/src/axios/requests/checkEnterableRequest.ts: -------------------------------------------------------------------------------- 1 | import axiosBackend from '../instances/axiosBackend'; 2 | 3 | interface Query { 4 | roomId: number; 5 | userId: string; 6 | } 7 | 8 | export default ({ roomId, userId }: Query) => { 9 | return axiosBackend.get( 10 | `/study-room/enterable?roomId=${roomId}&userId=${userId}`, 11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /frontend/src/axios/requests/checkMasterRequest.ts: -------------------------------------------------------------------------------- 1 | import axiosBackend from '../instances/axiosBackend'; 2 | 3 | interface FormData { 4 | studyRoomId: number; 5 | userId: string; 6 | } 7 | 8 | export default (formData: FormData) => { 9 | return axiosBackend.post('/study-room/check-master', formData, { 10 | withCredentials: true, 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /frontend/src/axios/requests/checkUniqueIdRequest.ts: -------------------------------------------------------------------------------- 1 | import axiosBackend from '../instances/axiosBackend'; 2 | 3 | export default (id: string) => { 4 | return axiosBackend.get(`/user/checkID/${id}`); 5 | }; 6 | -------------------------------------------------------------------------------- /frontend/src/axios/requests/checkUniqueNicknameRequest.ts: -------------------------------------------------------------------------------- 1 | import axiosBackend from '../instances/axiosBackend'; 2 | 3 | export default (nickname: string) => { 4 | return axiosBackend.get(`/user/checkNickname/${nickname}`); 5 | }; 6 | -------------------------------------------------------------------------------- /frontend/src/axios/requests/createStudyRoomRequest.ts: -------------------------------------------------------------------------------- 1 | import axiosBackend from '../instances/axiosBackend'; 2 | 3 | interface NewRoomInfoProps { 4 | managerId: string; 5 | name: string; 6 | content: string; 7 | maxPersonnel: number; 8 | tags: string[]; 9 | } 10 | 11 | export default function createStudyRoomRequest(newRoomInfo: NewRoomInfoProps) { 12 | return axiosBackend.post(`/study-room`, newRoomInfo); 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/axios/requests/deleteRoomRequest.ts: -------------------------------------------------------------------------------- 1 | import axiosBackend from '../instances/axiosBackend'; 2 | 3 | interface FormData { 4 | studyRoomId: number; 5 | } 6 | 7 | export default (formData: FormData) => { 8 | return axiosBackend.post('/study-room/deleteRoom', formData, { 9 | withCredentials: true, 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /frontend/src/axios/requests/enterRoomRequest.ts: -------------------------------------------------------------------------------- 1 | import axiosBackend from '../instances/axiosBackend'; 2 | 3 | interface FormData { 4 | studyRoomId: number; 5 | userId: string; 6 | nickname: string; 7 | isMaster: boolean; 8 | } 9 | 10 | export default (formData: FormData) => { 11 | return axiosBackend.post('/user/enterRoom', formData, { 12 | withCredentials: true, 13 | }); 14 | }; 15 | -------------------------------------------------------------------------------- /frontend/src/axios/requests/getParticipantsListRequest.ts: -------------------------------------------------------------------------------- 1 | import axiosBackend from '../instances/axiosBackend'; 2 | 3 | export default function getParticipantsListRequest(studyRoomId: number) { 4 | return axiosBackend.get( 5 | `/study-room/participants?study-room-id=studyRoom${studyRoomId}`, 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/axios/requests/getStudyRoomInfoRequest.ts: -------------------------------------------------------------------------------- 1 | import axiosBackend from '../instances/axiosBackend'; 2 | 3 | export default (roomId: string) => { 4 | return axiosBackend.get(`/study-room/roomInfo/${roomId}`); 5 | }; 6 | -------------------------------------------------------------------------------- /frontend/src/axios/requests/getStudyRoomListRequest.ts: -------------------------------------------------------------------------------- 1 | import axiosBackend from '../instances/axiosBackend'; 2 | 3 | interface ConditionProps { 4 | page: number; 5 | keyword: string; 6 | attendable: boolean; 7 | } 8 | 9 | export default function getStudyRoomListRequest({ 10 | page, 11 | keyword, 12 | attendable, 13 | }: ConditionProps) { 14 | return axiosBackend.get( 15 | `/study-room?page=${page}&keyword=${keyword}&attendable=${attendable}`, 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/axios/requests/leaveRoomRequest.ts: -------------------------------------------------------------------------------- 1 | import axiosBackend from '../instances/axiosBackend'; 2 | 3 | interface FormData { 4 | studyRoomId: number; 5 | userId: string; 6 | } 7 | 8 | export default (formData: FormData) => { 9 | return axiosBackend.post('/user/leaveRoom', formData, { 10 | withCredentials: true, 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /frontend/src/axios/requests/loginRequest.ts: -------------------------------------------------------------------------------- 1 | import axiosBackend from '../instances/axiosBackend'; 2 | 3 | interface FormData { 4 | id: string; 5 | password: string; 6 | } 7 | 8 | export default (formData: FormData) => { 9 | return axiosBackend.post('/user/login', formData, { 10 | withCredentials: true, 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /frontend/src/axios/requests/logoutRequest.ts: -------------------------------------------------------------------------------- 1 | import axiosBackend from '../instances/axiosBackend'; 2 | 3 | export default () => { 4 | return axiosBackend.get('/user/logout', { withCredentials: true }); 5 | }; 6 | -------------------------------------------------------------------------------- /frontend/src/axios/requests/signupRequest.ts: -------------------------------------------------------------------------------- 1 | import axiosBackend from '../instances/axiosBackend'; 2 | 3 | interface FormData { 4 | id: string; 5 | nickname: string; 6 | password: string; 7 | } 8 | 9 | export default (formData: FormData) => { 10 | return axiosBackend.post(`/user/signup`, formData); 11 | }; 12 | -------------------------------------------------------------------------------- /frontend/src/axios/requests/silentLoginRequest.ts: -------------------------------------------------------------------------------- 1 | import axiosBackend from '../instances/axiosBackend'; 2 | 3 | export default () => { 4 | return axiosBackend.get(`/user/silent-login`, { withCredentials: true }); 5 | }; 6 | -------------------------------------------------------------------------------- /frontend/src/components/common/CreatButton.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import CreateIcon from '@assets/icons/create.svg'; 3 | 4 | const Button = styled.button` 5 | width: fit-content; 6 | margin-left: auto; 7 | display: flex; 8 | align-items: center; 9 | gap: 12px; 10 | padding: 20px 20px 20px 12px; 11 | border-radius: 10px; 12 | box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.25); 13 | background-color: #ffeec3; 14 | 15 | font-family: 'yg-jalnan'; 16 | font-weight: 700; 17 | font-size: 18px; 18 | `; 19 | 20 | interface Props { 21 | children: string; 22 | onClick?: React.MouseEventHandler; 23 | } 24 | 25 | export default function CreateButton({ children, onClick }: Props) { 26 | return ( 27 | 31 | ); 32 | } 33 | 34 | CreateButton.defaultProps = { 35 | onClick: () => {}, 36 | }; 37 | -------------------------------------------------------------------------------- /frontend/src/components/common/CustomButton.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Button = styled.button` 4 | padding: ${({ padding }) => `${padding}`}; 5 | margin: ${({ margin }) => `${margin}`}; 6 | width: ${({ width }) => `${width}`}; 7 | height: ${({ height }) => `${height}`}; 8 | background-color: ${({ color }) => color}; 9 | border-radius: 15px; 10 | color: white; 11 | font-size: ${({ fontSize }) => `${fontSize}`}; 12 | font-weight: 700; 13 | &:disabled { 14 | opacity: 50%; 15 | } 16 | `; 17 | 18 | interface Props { 19 | type?: 'button' | 'submit' | 'reset'; 20 | children: string; 21 | onClick?: React.MouseEventHandler; 22 | width?: string; 23 | height?: string; 24 | color?: string; 25 | padding?: string; 26 | margin?: string; 27 | fontSize?: string; 28 | disabled?: boolean; 29 | } 30 | 31 | export default function CustomButton(props: Props) { 32 | const { children, ...restProps } = props; 33 | 34 | return ; 35 | } 36 | 37 | CustomButton.defaultProps = { 38 | type: 'button', 39 | onClick: () => {}, 40 | width: '100%', 41 | height: '', 42 | color: 'var(--orange)', 43 | padding: '21px 0', 44 | margin: '0', 45 | fontSize: '20px', 46 | disabled: false, 47 | }; 48 | -------------------------------------------------------------------------------- /frontend/src/components/common/CustomInput.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const CustomInputLayout = styled.div` 4 | width: 100%; 5 | 6 | & + & { 7 | margin-top: 10px; 8 | } 9 | `; 10 | 11 | const Input = styled.input` 12 | padding: 21px 28px; 13 | width: ${({ width }) => width}; 14 | border: 2px solid #ff8a00; 15 | border-radius: 15px; 16 | font-size: 18px; 17 | 18 | &::placeholder { 19 | color: #ffc7a1; 20 | } 21 | `; 22 | 23 | const GuideText = styled.div` 24 | margin: 8px 0 0 7px; 25 | color: var(--guideText); 26 | font-size: 14px; 27 | `; 28 | 29 | const WarningText = styled.div` 30 | margin: 8px 0 0 7px; 31 | color: var(--red); 32 | font-size: 14px; 33 | font-weight: 700; 34 | `; 35 | 36 | interface Props { 37 | name?: string; 38 | value?: string | number; 39 | onChange?: React.ChangeEventHandler; 40 | width?: string; 41 | placeholder?: string; 42 | warningText?: string; 43 | guideText?: string; 44 | type?: string; 45 | min?: number; 46 | maxLength?: number; 47 | inputRef?: React.MutableRefObject; 48 | required?: boolean; 49 | } 50 | 51 | export default function CustomInput(props: Props) { 52 | const { 53 | width, 54 | type, 55 | placeholder, 56 | warningText, 57 | guideText, 58 | name, 59 | value, 60 | onChange, 61 | maxLength, 62 | min, 63 | inputRef, 64 | required, 65 | } = props; 66 | 67 | return ( 68 | 69 | 81 | {guideText && {guideText}} 82 | {warningText && {warningText}} 83 | 84 | ); 85 | } 86 | 87 | CustomInput.defaultProps = { 88 | name: '', 89 | value: undefined, 90 | onChange: () => {}, 91 | width: '100%', 92 | type: 'text', 93 | placeholder: '', 94 | warningText: '', 95 | guideText: '', 96 | min: undefined, 97 | maxLength: undefined, 98 | inputRef: null, 99 | required: false, 100 | }; 101 | -------------------------------------------------------------------------------- /frontend/src/components/common/Loader.tsx: -------------------------------------------------------------------------------- 1 | import { ReactComponent as LoadingIcon } from '@assets/loader.svg'; 2 | import styled from 'styled-components'; 3 | 4 | const Background = styled.div` 5 | position: fixed; 6 | top: 0; 7 | bottom: 0; 8 | left: 0; 9 | right: 0; 10 | z-index: 999; 11 | display: flex; 12 | flex-direction: column; 13 | justify-content: center; 14 | align-items: center; 15 | background: #ffffffc1; ; 16 | `; 17 | 18 | export default function Loader() { 19 | return ( 20 | 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/components/common/MainSideBar.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { Link, useNavigate } from 'react-router-dom'; 3 | import React, { useCallback, useEffect } from 'react'; 4 | import useAxios from '@hooks/useAxios'; 5 | import { useSetRecoilState } from 'recoil'; 6 | import { userState } from 'recoil/atoms'; 7 | import MenuList from './MenuList'; 8 | import UserProfile from './UserProfile'; 9 | import Logo from '../../assets/StyledLogo.png'; 10 | import logoutRequest from '../../axios/requests/logoutRequest'; 11 | import Loader from './Loader'; 12 | 13 | const SideBarWrapper = styled.div` 14 | display: flex; 15 | flex-direction: column; 16 | justify-content: space-between; 17 | background-color: #ffce70; 18 | height: 100vh; 19 | width: 296px; 20 | `; 21 | 22 | const LogoutButton = styled.button` 23 | flex-basis: 100px; 24 | font-size: 1.5rem; 25 | background: none; 26 | `; 27 | 28 | const SideBar = styled.div` 29 | position: relative; 30 | flex: 1; 31 | display: flex; 32 | flex-direction: column; 33 | text-align: center; 34 | `; 35 | 36 | const LogoStyle = styled.img` 37 | position: absolute; 38 | top: 62px; 39 | left: 50%; 40 | transform: translate(-50%, 0); 41 | `; 42 | 43 | interface Props { 44 | width?: string; 45 | color?: string; 46 | } 47 | 48 | function MainSideBar(props: Props) { 49 | const navigate = useNavigate(); 50 | const [requestLogout, logoutLoading, , logoutData] = 51 | useAxios<''>(logoutRequest); 52 | const setUser = useSetRecoilState(userState); 53 | 54 | useEffect(() => { 55 | if (logoutData === null) return; 56 | setUser(null); 57 | navigate('/'); 58 | }, [logoutData, navigate, setUser]); 59 | 60 | const logout = useCallback(() => { 61 | requestLogout(); 62 | }, [requestLogout]); 63 | 64 | return ( 65 | 66 | {logoutLoading && } 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 로그아웃 75 | 76 | ); 77 | } 78 | 79 | MainSideBar.defaultProps = { 80 | width: '296px', 81 | color: 'var(--yellow1)', 82 | }; 83 | 84 | export default React.memo(MainSideBar); 85 | -------------------------------------------------------------------------------- /frontend/src/components/common/MenuList.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { Link, useLocation } from 'react-router-dom'; 3 | import menuHome from '../../assets/home.png'; 4 | import menuStudy from '../../assets/study.png'; 5 | 6 | const List = styled.div` 7 | display: flex; 8 | flex-direction: column; 9 | align-items: center; 10 | gap: 29px; 11 | `; 12 | 13 | const MenuItem = styled.div` 14 | width: 132px; 15 | height: 42px; 16 | display: flex; 17 | align-items: center; 18 | gap: 10px; 19 | padding: 6px 13px; 20 | border-radius: 8px; 21 | text-align: left; 22 | font-family: 'Pretendard-Regular'; 23 | font-size: 25px; 24 | 25 | &:hover, 26 | &.active { 27 | background-color: #ffb11a; 28 | box-shadow: inset 1px 1px 4px rgba(0, 0, 0, 0.25); 29 | } 30 | `; 31 | 32 | export default function MenuList() { 33 | const location = useLocation(); 34 | const menuList = [ 35 | { 36 | name: '홈', 37 | iconSrc: menuHome, 38 | path: '/home', 39 | }, 40 | { 41 | name: '공부방', 42 | iconSrc: menuStudy, 43 | path: '/study-rooms', 44 | }, 45 | ]; 46 | 47 | return ( 48 | 49 | {menuList.map((menu) => ( 50 | 51 | 52 | {`${menu.name} 53 | {menu.name} 54 | 55 | 56 | ))} 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /frontend/src/components/common/Modal.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const ModalBackground = styled.section` 4 | position: fixed; 5 | top: 0; 6 | bottom: 0; 7 | left: 0; 8 | right: 0; 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | background: rgba(0, 0, 0, 0.5); 13 | z-index: 10; 14 | `; 15 | 16 | const ModalContentLayout = styled.div` 17 | padding: 55px 100px; 18 | width: fit-content; 19 | border-radius: 25px; 20 | background-color: var(--white); 21 | `; 22 | const ModalContent = styled.div` 23 | display: flex; 24 | flex-direction: column; 25 | gap: 10px; 26 | align-items: center; 27 | width: 412px; 28 | `; 29 | 30 | interface Props { 31 | children: React.ReactElement[]; 32 | setModal: React.Dispatch; 33 | } 34 | 35 | export default function Modal({ children, setModal }: Props) { 36 | const closeModal = (e: React.MouseEvent) => { 37 | if ((e.target as HTMLElement).tagName === 'SECTION') setModal(false); 38 | }; 39 | 40 | return ( 41 | 42 | 43 | {children} 44 | 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /frontend/src/components/common/Pagination.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { ReactComponent as LeftArrowIcon } from '@assets/icons/leftArrow.svg'; 3 | import { ReactComponent as RightArrowIcon } from '@assets/icons/rightArrow.svg'; 4 | import { useNavigate } from 'react-router-dom'; 5 | 6 | const PaginationLayout = styled.div` 7 | width: fit-content; 8 | margin: 0 auto; 9 | display: flex; 10 | align-items: center; 11 | gap: 22px; 12 | `; 13 | 14 | const Page = styled.button<{ selected: boolean }>` 15 | display: flex; 16 | justify-content: center; 17 | align-items: center; 18 | width: 33px; 19 | height: 33px; 20 | background-color: ${({ selected }) => 21 | selected ? '#ffeec3' : 'var(--white)'}; 22 | border-radius: 6px; 23 | font-size: 20px; 24 | `; 25 | 26 | interface Props { 27 | pageCount: number; 28 | currentPage: number; 29 | getRoomConditions: { keyword: string; attendable: string }; 30 | } 31 | 32 | export default function Pagination({ 33 | pageCount, 34 | currentPage, 35 | getRoomConditions, 36 | }: Props) { 37 | const navigate = useNavigate(); 38 | const { keyword, attendable } = getRoomConditions; 39 | 40 | const onClickPage = (e: React.MouseEvent) => { 41 | const nextPage = Number((e.target as HTMLElement).innerText); 42 | if (currentPage === nextPage) return; 43 | navigate( 44 | `/study-rooms?page=${nextPage}&keyword=${keyword || ''}&attendable=${ 45 | attendable === undefined ? false : attendable 46 | }`, 47 | ); 48 | }; 49 | 50 | return ( 51 | 52 | 53 | {Array(pageCount) 54 | .fill(0) 55 | .map((x, index) => index + 1) 56 | .map((page) => ( 57 | 61 | {page} 62 | 63 | ))} 64 | 65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /frontend/src/components/common/PrivateRoute.tsx: -------------------------------------------------------------------------------- 1 | import { Navigate } from 'react-router-dom'; 2 | import { useRecoilValue } from 'recoil'; 3 | import { userState } from 'recoil/atoms'; 4 | 5 | interface Props { 6 | children: JSX.Element; 7 | } 8 | 9 | export default function PrivateRoute(props: Props) { 10 | const { children } = props; 11 | const user = useRecoilValue(userState); 12 | 13 | if (!user) { 14 | return ; 15 | } 16 | 17 | return children; 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/components/common/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { ReactComponent as SearchIcon } from '@assets/icons/searchBarButton.svg'; 3 | import React, { useState } from 'react'; 4 | import { useLocation, useNavigate } from 'react-router-dom'; 5 | import qs from 'qs'; 6 | 7 | const SearchBarLayout = styled.div` 8 | width: 100%; 9 | display: flex; 10 | flex-direction: column; 11 | margin-bottom: 40px; 12 | `; 13 | 14 | const GuideText = styled.span` 15 | margin-bottom: 10px; 16 | padding-left: 12px; 17 | font-family: 'Pretendard-Regular'; 18 | font-size: 16px; 19 | color: #a3a3a3; 20 | `; 21 | const SearchBarInputWrapper = styled.div` 22 | width: 100%; 23 | position: relative; 24 | `; 25 | const SearchBarInput = styled.input` 26 | width: 100%; 27 | padding: 23px 75px 23px 44px; 28 | background: white; 29 | border: 3px solid var(--orange); 30 | border-radius: 35px; 31 | font-weight: 700; 32 | font-size: 20px; 33 | 34 | &::placeholder { 35 | color: #ffc7a1; 36 | font-weight: 400; 37 | } 38 | `; 39 | const SearchBarButton = styled.button` 40 | display: flex; 41 | position: absolute; 42 | top: 50%; 43 | right: 0; 44 | transform: translate(0, -50%); 45 | margin-right: 7px; 46 | background: none; 47 | padding: 0; 48 | `; 49 | 50 | interface Props { 51 | guideText?: string; 52 | } 53 | 54 | function SearchBar({ guideText }: Props) { 55 | const navigate = useNavigate(); 56 | const location = useLocation(); 57 | const queryString = qs.parse(location.search, { 58 | ignoreQueryPrefix: true, 59 | }); 60 | 61 | const [input, setInput] = useState(''); 62 | const onChange = (e: React.ChangeEvent) => { 63 | setInput(e.target.value); 64 | }; 65 | 66 | const searchRoomList = () => { 67 | navigate( 68 | `/study-rooms?page=1&keyword=${input}&attendable=${queryString.attendable}`, 69 | ); 70 | setInput(''); 71 | }; 72 | 73 | return ( 74 | 75 | {guideText} 76 | 77 | (e.key === 'Enter' ? searchRoomList() : null)} 82 | /> 83 | 84 | 85 | 86 | 87 | 88 | ); 89 | } 90 | 91 | SearchBar.defaultProps = { 92 | guideText: '', 93 | }; 94 | 95 | export default React.memo(SearchBar); 96 | -------------------------------------------------------------------------------- /frontend/src/components/common/StudyRoomGuard.tsx: -------------------------------------------------------------------------------- 1 | import useAxios from '@hooks/useAxios'; 2 | import { Navigate, useParams } from 'react-router-dom'; 3 | import { useRecoilValue } from 'recoil'; 4 | import { userState } from 'recoil/atoms'; 5 | import Loader from './Loader'; 6 | import checkEnterableRequest from '../../axios/requests/checkEnterableRequest'; 7 | 8 | interface Props { 9 | children: JSX.Element; 10 | } 11 | 12 | export default function BlockRoomEnter({ children }: Props) { 13 | const { roomId } = useParams(); 14 | const user = useRecoilValue(userState); 15 | 16 | const [, loading, error] = useAxios<{ 17 | enterable: boolean; 18 | }>(checkEnterableRequest, { 19 | onMount: true, 20 | arg: { roomId, userId: user?.userId }, 21 | errNavigate: false, 22 | }); 23 | 24 | if (loading) { 25 | return ; 26 | } 27 | 28 | if (error) { 29 | alert(error.message); 30 | return ; 31 | } 32 | 33 | return children; 34 | } 35 | -------------------------------------------------------------------------------- /frontend/src/components/common/StyledHeader1.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const StyledHeader = styled.h1` 4 | margin-bottom: 45px; 5 | font-family: 'yg-jalnan'; 6 | font-size: 30px; 7 | `; 8 | 9 | interface Props { 10 | children: string; 11 | } 12 | export default function StyledHeader1({ children }: Props) { 13 | return {children}; 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/components/common/UserProfile.tsx: -------------------------------------------------------------------------------- 1 | import { useRecoilValue } from 'recoil'; 2 | import { userState } from 'recoil/atoms'; 3 | import styled from 'styled-components'; 4 | import sampleImage from '../../assets/sample.jpg'; 5 | import Loader from './Loader'; 6 | 7 | const Profile = styled.div``; 8 | 9 | const UserProfileImage = styled.img` 10 | margin-top: 148px; 11 | margin-bottom: 15px; 12 | width: 149px; 13 | height: 149px; 14 | border-radius: 100%; 15 | filter: drop-shadow(2px 2px 3px rgba(0, 0, 0, 0.25)); 16 | `; 17 | 18 | const UserProfileName = styled.div` 19 | margin-bottom: 142px; 20 | font-family: 'yg-jalnan'; 21 | font-weight: 700; 22 | font-size: 22px; 23 | `; 24 | 25 | interface Props { 26 | src?: string; 27 | } 28 | 29 | export default function UserProfile({ src }: Props) { 30 | const user = useRecoilValue(userState); 31 | return ( 32 | 33 | 34 | {user ? user.nickname : 'loading...'} 35 | 36 | ); 37 | } 38 | 39 | UserProfile.defaultProps = { 40 | src: sampleImage, 41 | }; 42 | -------------------------------------------------------------------------------- /frontend/src/components/common/ViewConditionCheckBox.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import CheckIcon from '@assets/icons/check.svg'; 3 | import { useNavigate } from 'react-router-dom'; 4 | 5 | const ViewConditionCheckBoxLayout = styled.div` 6 | display: flex; 7 | gap: 10px; 8 | `; 9 | const StyledLabel = styled.label` 10 | display: flex; 11 | align-items: center; 12 | user-select: none; 13 | font-size: 18px; 14 | `; 15 | const StyledInput = styled.input` 16 | margin-right: 10px; 17 | width: 25px; 18 | height: 25px; 19 | appearance: none; 20 | border: 3px solid #ffe0a5; 21 | border-radius: 8px; 22 | 23 | &:checked { 24 | background-image: url(${CheckIcon}); 25 | background-size: 100% 100%; 26 | background-repeat: no-repeat; 27 | } 28 | `; 29 | 30 | interface Props { 31 | children: string; 32 | getRoomConditions: { page: string; keyword: string }; 33 | } 34 | 35 | export default function ViewConditionCheckBox({ 36 | children, 37 | getRoomConditions, 38 | }: Props) { 39 | const { page, keyword } = getRoomConditions; 40 | const navigate = useNavigate(); 41 | const handleCheckBox = (e: React.ChangeEvent) => { 42 | navigate( 43 | `/study-rooms?page=${page || 1}&keyword=${keyword || ''}&attendable=${ 44 | e.target.checked 45 | }`, 46 | ); 47 | }; 48 | return ( 49 | 50 | 51 | 52 | {children} 53 | 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /frontend/src/components/studyRoom/Canvas.tsx: -------------------------------------------------------------------------------- 1 | import CustomButton from '@components/common/CustomButton'; 2 | import React, { useEffect, useRef } from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | const CanvasLayout = styled.div` 6 | position: relative; 7 | display: none; 8 | 9 | &.active { 10 | height: 100%; 11 | display: block; 12 | } 13 | `; 14 | 15 | const CanvasArea = styled.canvas` 16 | max-height: 100%; 17 | background-color: white; 18 | box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.25); 19 | `; 20 | 21 | const StyledButton = styled(CustomButton)` 22 | position: absolute; 23 | transform: translate(-100%, -125%); 24 | `; 25 | 26 | interface Props { 27 | sendDc: RTCDataChannel | null; 28 | receiveDcs: { [socketId: string]: RTCDataChannel }; 29 | isActive: boolean; 30 | } 31 | 32 | export default function Canvas({ sendDc, receiveDcs, isActive }: Props) { 33 | const canvasRef = useRef(null); 34 | const ctxRef = useRef(null); 35 | const isPaintingRef = useRef(false); 36 | const currentCoor = useRef({ x: 0, y: 0 }); 37 | 38 | const draw = ({ 39 | x1, 40 | y1, 41 | x2, 42 | y2, 43 | }: { 44 | x1: number; 45 | y1: number; 46 | x2: number; 47 | y2: number; 48 | }) => { 49 | if (!ctxRef.current) return; 50 | ctxRef.current.beginPath(); 51 | ctxRef.current.moveTo(x1, y1); 52 | ctxRef.current.lineTo(x2, y2); 53 | ctxRef.current.stroke(); 54 | }; 55 | 56 | const canvasMessageHandler = (e: MessageEvent) => { 57 | const body = JSON.parse(e.data); 58 | if (body.type !== 'canvas') return; 59 | if (body.isClear && ctxRef.current) { 60 | ctxRef.current.clearRect( 61 | 0, 62 | 0, 63 | canvasRef.current!.width, 64 | canvasRef.current!.height, 65 | ); 66 | return; 67 | } 68 | draw(body); 69 | }; 70 | 71 | useEffect(() => { 72 | Object.values(receiveDcs).forEach((receiveDc) => { 73 | receiveDc.addEventListener('message', canvasMessageHandler); 74 | }); 75 | return () => { 76 | Object.values(receiveDcs).forEach((receiveDc) => { 77 | receiveDc.removeEventListener('message', canvasMessageHandler); 78 | }); 79 | }; 80 | }, [receiveDcs]); 81 | 82 | useEffect(() => { 83 | sendDc?.addEventListener('message', canvasMessageHandler); 84 | 85 | const canvas = canvasRef.current; 86 | if (!canvas) return () => {}; 87 | const ctx = canvas.getContext('2d'); 88 | if (!ctx) return () => {}; 89 | ctx.lineJoin = 'round'; 90 | ctx.lineWidth = 2.5; 91 | ctx.strokeStyle = '#000000'; 92 | ctxRef.current = ctx; 93 | return () => { 94 | sendDc?.removeEventListener('message', canvasMessageHandler); 95 | }; 96 | }, []); 97 | 98 | const sendCanvasEvent = (e: React.MouseEvent) => { 99 | const rect = canvasRef.current!.getBoundingClientRect(); 100 | const scaleX = 1200 / rect.width; 101 | const scaleY = 900 / rect.height; 102 | if (!sendDc) return; 103 | const mouseEvent = e.type; 104 | const coor = { 105 | x1: currentCoor.current.x, 106 | y1: currentCoor.current.y, 107 | x2: e.nativeEvent.offsetX * scaleX, 108 | y2: e.nativeEvent.offsetY * scaleX, 109 | }; 110 | switch (mouseEvent) { 111 | case 'mousedown': 112 | isPaintingRef.current = true; 113 | currentCoor.current.x = e.nativeEvent.offsetX * scaleX; 114 | currentCoor.current.y = e.nativeEvent.offsetY * scaleY; 115 | break; 116 | case 'mouseleave': 117 | isPaintingRef.current = false; 118 | break; 119 | case 'mouseup': 120 | isPaintingRef.current = false; 121 | draw(coor); 122 | sendDc.send(JSON.stringify({ type: 'canvas', ...coor })); 123 | break; 124 | case 'mousemove': 125 | if (!isPaintingRef.current) return; 126 | draw(coor); 127 | sendDc.send(JSON.stringify({ type: 'canvas', ...coor })); 128 | currentCoor.current.x = e.nativeEvent.offsetX * scaleX; 129 | currentCoor.current.y = e.nativeEvent.offsetY * scaleY; 130 | break; 131 | default: 132 | break; 133 | } 134 | }; 135 | 136 | const canvasClear = () => { 137 | if (!ctxRef.current || !sendDc) return; 138 | ctxRef.current.clearRect( 139 | 0, 140 | 0, 141 | canvasRef.current!.width, 142 | canvasRef.current!.height, 143 | ); 144 | sendDc.send(JSON.stringify({ type: 'canvas', isClear: true })); 145 | }; 146 | 147 | return ( 148 | 149 | 158 | 164 | CLEAR 165 | 166 | 167 | ); 168 | } 169 | -------------------------------------------------------------------------------- /frontend/src/components/studyRoom/ChatItem.tsx: -------------------------------------------------------------------------------- 1 | import { useRecoilValue } from 'recoil'; 2 | import { userState } from 'recoil/atoms'; 3 | import styled from 'styled-components'; 4 | import { Chat } from 'types/chat.types'; 5 | 6 | const ChatItemLayout = styled.div<{ isMine: boolean }>` 7 | display: flex; 8 | flex-direction: ${({ isMine }) => (isMine ? 'row-reverse' : 'row')}; 9 | justify-content: flex-start; 10 | align-items: flex-start; 11 | gap: 5px; 12 | 13 | & + & { 14 | margin-top: 10px; 15 | } 16 | 17 | .profile-image { 18 | background-color: #d9d9d9; 19 | border-radius: 50%; 20 | min-width: 32px; 21 | height: 32px; 22 | } 23 | `; 24 | const Wrapper = styled.div``; 25 | const SpeechBubbleLayout = styled.div<{ isMine: boolean }>` 26 | display: flex; 27 | flex-direction: ${({ isMine }) => (isMine ? 'row-reverse' : 'row')}; 28 | align-items: flex-end; 29 | gap: 6px; 30 | `; 31 | 32 | const Nickname = styled.div` 33 | padding: 0 0 7px 3px; 34 | color: var(--black); 35 | font-size: 16px; 36 | `; 37 | 38 | const Bubble = styled.div<{ isMine: boolean }>` 39 | position: relative; 40 | margin-right: ${({ isMine }) => (isMine ? '10px' : '0px')}; 41 | padding: 7px 13px; 42 | width: fit-content; 43 | border-radius: ${({ isMine }) => 44 | isMine ? '12px 0 12px 12px' : '0 12px 12px 12px'}; 45 | background-color: ${({ isMine }) => (isMine ? '#FFE7B9' : 'var(--white)')}; 46 | box-shadow: 1px 1px 7px rgba(0, 0, 0, 0.15); 47 | color: var(--black); 48 | font-size: 18px; 49 | word-break: break-all; 50 | `; 51 | 52 | const Time = styled.span` 53 | min-width: fit-content; 54 | font-size: 14px; 55 | color: #959595; 56 | `; 57 | 58 | interface Props { 59 | chat: Chat; 60 | } 61 | 62 | export default function ChatItem({ chat }: Props) { 63 | const userInfo = useRecoilValue(userState); 64 | const { sender, message, timestamp } = chat; 65 | const isMine = sender === userInfo?.nickname; 66 | 67 | function chatTimeFomatter(timestampString: string) { 68 | return new Date(timestampString) 69 | ?.toTimeString() 70 | .split(' ')[0] 71 | .split(':') 72 | .slice(0, 2) 73 | .join(':'); 74 | } 75 | 76 | return ( 77 | 78 | {!isMine &&
} 79 | 80 | {!isMine && {sender}} 81 | 82 | {message} 83 | 84 | 85 | 86 | 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /frontend/src/components/studyRoom/ChatList.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useRef, useState } from 'react'; 2 | import styled from 'styled-components'; 3 | import { Chat } from 'types/chat.types'; 4 | import ChatItem from './ChatItem'; 5 | 6 | const ChatContent = styled.div` 7 | margin: 48px 17px 0; 8 | flex: 1; 9 | overflow-y: auto; 10 | 11 | &::-webkit-scrollbar { 12 | width: 6px; 13 | border-radius: 3px; 14 | background: var(--orange3); 15 | } 16 | &::-webkit-scrollbar-thumb { 17 | margin-left: 3px; 18 | border-radius: 3px; 19 | background: var(--orange); 20 | } 21 | `; 22 | 23 | interface Props { 24 | sendDc: RTCDataChannel | null; 25 | receiveDcs: { [id: string]: RTCDataChannel }; 26 | } 27 | 28 | export default function ChatList({ sendDc, receiveDcs }: Props) { 29 | const [chats, setChats] = useState([]); 30 | const chatListRef = useRef(null); 31 | 32 | const chatMessageHandler = useCallback((e: MessageEvent) => { 33 | const body = JSON.parse(e.data); 34 | if (body.type !== 'chat') return; 35 | setChats((prev) => [...prev, body]); 36 | }, []); 37 | 38 | useEffect(() => { 39 | Object.values(receiveDcs).forEach((receiveDc) => { 40 | receiveDc.addEventListener('message', chatMessageHandler); 41 | }); 42 | return () => { 43 | Object.values(receiveDcs).forEach((receiveDc) => { 44 | receiveDc.removeEventListener('message', chatMessageHandler); 45 | }); 46 | }; 47 | }, [receiveDcs]); 48 | 49 | useEffect(() => { 50 | if (!sendDc) return () => {}; 51 | sendDc.addEventListener('message', chatMessageHandler); 52 | return () => { 53 | sendDc.removeEventListener('message', chatMessageHandler); 54 | }; 55 | }, [sendDc]); 56 | 57 | useEffect(() => { 58 | if (chatListRef.current) { 59 | chatListRef.current.scrollTop = chatListRef.current.scrollHeight; 60 | } 61 | }, [chats]); 62 | 63 | return ( 64 | 65 | {chats.map((chat: Chat) => ( 66 | 67 | ))} 68 | 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /frontend/src/components/studyRoom/ChatSideBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import DownArrowIcon from '@assets/icons/down-triangle.svg'; 4 | import { useRecoilValue } from 'recoil'; 5 | import { userState } from 'recoil/atoms'; 6 | import ChatList from './ChatList'; 7 | 8 | const StudyRoomSideBarLayout = styled.div` 9 | width: 420px; 10 | display: flex; 11 | flex-direction: column; 12 | background-color: var(--white); 13 | border-left: 1px solid var(--yellow); 14 | 15 | &.hide { 16 | display: none; 17 | } 18 | `; 19 | const ChatTitle = styled.h1` 20 | margin-top: 20px; 21 | margin-left: 24px; 22 | font-family: 'yg-jalnan'; 23 | font-size: 18px; 24 | font-weight: 700; 25 | `; 26 | 27 | const ChatInputLayout = styled.div` 28 | margin: 10px 15px 0; 29 | padding: 15px 0; 30 | border-top: 1px solid #d9d9d9; 31 | `; 32 | 33 | const ChatInput = styled.input` 34 | width: 100%; 35 | padding: 10px 14px; 36 | background: #ffeec3; 37 | border-radius: 10px; 38 | border: none; 39 | font-size: 18px; 40 | 41 | &::placeholder { 42 | color: #ff7426; 43 | } 44 | `; 45 | 46 | const SelectReceiverLayout = styled.div` 47 | margin: 0 0 16px 9px; 48 | display: flex; 49 | align-items: center; 50 | gap: 8px; 51 | 52 | .to { 53 | font-family: 'yg-jalnan'; 54 | font-weight: 700; 55 | font-size: 18px; 56 | } 57 | `; 58 | 59 | const SelectReceiver = styled.select` 60 | width: 120px; 61 | padding: 5px 10px; 62 | border: 1px solid #ffce70; 63 | border-radius: 5px; 64 | font-size: 16px; 65 | background: url(${DownArrowIcon}) no-repeat 93% 50%/12px auto; 66 | -webkit-appearance: none; 67 | -moz-appearance: none; 68 | appearance: none; 69 | outline: none; 70 | `; 71 | 72 | interface Props { 73 | sendDc: RTCDataChannel | null; 74 | receiveDcs: { [id: string]: RTCDataChannel }; 75 | isShow: boolean; 76 | } 77 | 78 | export default function ChatSideBar({ sendDc, receiveDcs, isShow }: Props) { 79 | const user = useRecoilValue(userState); 80 | const sendChat = (e: React.KeyboardEvent) => { 81 | if (e.key !== 'Enter' || !e.currentTarget.value) return; 82 | if (!sendDc || !user) return; 83 | const { value } = e.currentTarget; 84 | 85 | const body = JSON.stringify({ 86 | type: 'chat', 87 | message: value, 88 | sender: user.nickname, 89 | }); 90 | sendDc.send(body); 91 | e.currentTarget.value = ''; 92 | }; 93 | 94 | return ( 95 | 96 | 채팅 97 | 98 | 99 | 100 | To. 101 | 102 | 103 | 104 | 105 | 106 | 111 | 112 | 113 | ); 114 | } 115 | -------------------------------------------------------------------------------- /frontend/src/components/studyRoom/NicknameWrapper.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const NicknameWrapperLayout = styled.div` 4 | position: relative; 5 | width: 100%; 6 | `; 7 | 8 | const NameBox = styled.div` 9 | position: absolute; 10 | top: 15px; 11 | left: 15px; 12 | padding: 6px 10px; 13 | border-radius: 5px; 14 | background: rgba(37, 37, 37, 0.39); 15 | color: white; 16 | `; 17 | 18 | interface Props { 19 | children: JSX.Element; 20 | nickname: string | undefined; 21 | } 22 | 23 | export default function NicknameWrapper({ children, nickname }: Props) { 24 | return ( 25 | 26 | {nickname} 27 | {children} 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /frontend/src/components/studyRoom/ParticipantsSideBar.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const ParticipantsSideBarLayout = styled.div` 4 | width: 420px; 5 | display: flex; 6 | flex-direction: column; 7 | background-color: var(--white); 8 | border-left: 1px solid var(--yellow); 9 | 10 | &.hide { 11 | display: none; 12 | } 13 | `; 14 | const Title = styled.h1` 15 | margin-top: 20px; 16 | margin-left: 24px; 17 | font-family: 'yg-jalnan'; 18 | font-size: 18px; 19 | font-weight: 700; 20 | `; 21 | 22 | const Content = styled.div` 23 | margin: 48px 17px 0; 24 | flex: 1; 25 | `; 26 | 27 | const ParticipantItem = styled.div` 28 | display: flex; 29 | align-items: center; 30 | gap: 10px; 31 | 32 | & + & { 33 | margin-top: 15px; 34 | } 35 | `; 36 | const ProfileImage = styled.div` 37 | width: 30px; 38 | height: 30px; 39 | background-color: var(--guideText); 40 | border-radius: 100%; 41 | `; 42 | const NickName = styled.div` 43 | font-size: 21px; 44 | `; 45 | 46 | interface Props { 47 | participants: string[]; 48 | isShow: boolean; 49 | } 50 | 51 | export default function ParticipantsSideBar({ participants, isShow }: Props) { 52 | const participantsList = participants ? Object.values(participants) : []; 53 | return ( 54 | 55 | 참여자 목록 56 | 57 | {participantsList.map((participant: string) => ( 58 | 59 | 60 | {participant} 61 | 62 | ))} 63 | 64 | 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /frontend/src/components/studyRoom/RemoteVideo.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | const Video = styled.video` 5 | width: 100%; 6 | border-radius: 12px; 7 | `; 8 | 9 | interface RemoteVideoProps { 10 | remoteStream: MediaStream; 11 | } 12 | 13 | export default function RemoteVideo({ remoteStream }: RemoteVideoProps) { 14 | const ref = useRef(null); 15 | 16 | useEffect(() => { 17 | if (ref.current) ref.current.srcObject = remoteStream; 18 | }, [remoteStream]); 19 | 20 | // eslint-disable-next-line jsx-a11y/media-has-caption 21 | return