├── .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 | 
35 |
36 |
37 | ### 실시간 채팅
38 |
39 | **1. 전체 사용자 간 채팅**
40 |
41 | - 전체 사용자 간 실시간 채팅은 공부방에 참여하기 전 채팅을 통해 **목적이나 목표가 맞는 사용자**끼리 만나 공부방을 생성하도록 유도하기 위한 기능이다.
42 | 
43 |
44 |
45 |
46 | **2. 공부방 내 사용자 간 채팅**
47 |
48 | - 비디오 공유를 할 수 없는 경우의 참여자 또한 소통이 가능하도록 **실시간 채팅 기능**을 제공한다.
49 | 
50 |
51 |
52 |
53 |
54 | ### 캔버스 공유
55 |
56 | - 공부방 내 참여자 간 **실시간 캔버스** 공유
57 | 
58 |
59 |
60 | ## 기술 스택
61 |
62 | 
63 |
64 | ## 아키텍처
65 |
66 | 
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 |
3 |
4 |
5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
6 | [circleci-url]: https://circleci.com/gh/nestjs/nest
7 |
8 | A progressive Node.js framework for building efficient and scalable server-side applications.
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
24 |
25 | ## Description
26 |
27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
28 |
29 | ## Installation
30 |
31 | ```bash
32 | $ npm install
33 | ```
34 |
35 | ## Running the app
36 |
37 | ```bash
38 | # development
39 | $ npm run start
40 |
41 | # watch mode
42 | $ npm run start:dev
43 |
44 | # production mode
45 | $ npm run start:prod
46 | ```
47 |
48 | ## Test
49 |
50 | ```bash
51 | # unit tests
52 | $ npm run test
53 |
54 | # e2e tests
55 | $ npm run test:e2e
56 |
57 | # test coverage
58 | $ npm run test:cov
59 | ```
60 |
61 | ## Support
62 |
63 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
64 |
65 | ## Stay in touch
66 |
67 | - Author - [Kamil Myśliwiec](https://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 |
11 |
--------------------------------------------------------------------------------
/frontend/src/assets/icons/chat.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/frontend/src/assets/icons/check.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/frontend/src/assets/icons/create.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/frontend/src/assets/icons/down-triangle.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/frontend/src/assets/icons/hashtag.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/frontend/src/assets/icons/king.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/frontend/src/assets/icons/leftArrow.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/frontend/src/assets/icons/mic-off.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/frontend/src/assets/icons/mic.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/frontend/src/assets/icons/monitor-off.svg:
--------------------------------------------------------------------------------
1 |
18 |
--------------------------------------------------------------------------------
/frontend/src/assets/icons/monitor.svg:
--------------------------------------------------------------------------------
1 |
16 |
--------------------------------------------------------------------------------
/frontend/src/assets/icons/participants.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/frontend/src/assets/icons/rightArrow.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/frontend/src/assets/icons/searchBarButton.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/frontend/src/assets/icons/user.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/frontend/src/assets/icons/video-off.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/frontend/src/assets/icons/video.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/frontend/src/assets/loader.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/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 |
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 ;
22 | }
23 |
--------------------------------------------------------------------------------
/frontend/src/components/studyRoomList/CreateNewRoomModal.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
2 | import CustomButton from '@components/common/CustomButton';
3 | import CustomInput from '@components/common/CustomInput';
4 | import Loader from '@components/common/Loader';
5 | import Modal from '@components/common/Modal';
6 | import useAxios from '@hooks/useAxios';
7 | import React, {
8 | ChangeEvent,
9 | FormEvent,
10 | useCallback,
11 | useEffect,
12 | useState,
13 | } from 'react';
14 | import { useNavigate } from 'react-router-dom';
15 | import { useRecoilValue } from 'recoil';
16 | import { userState } from 'recoil/atoms';
17 | import styled from 'styled-components';
18 | import createStudyRoomRequest from '../../axios/requests/createStudyRoomRequest';
19 | import TagInput from './TagInput';
20 |
21 | const PageTitle = styled.h1`
22 | margin-bottom: 24px;
23 | font-family: 'yg-jalnan';
24 | font-size: 25px;
25 | font-weight: 700;
26 | `;
27 |
28 | interface Props {
29 | setModal: React.Dispatch>;
30 | }
31 |
32 | export default function CreateNewRoomModal({ setModal }: Props) {
33 | const navigate = useNavigate();
34 | const user = useRecoilValue(userState);
35 | const [tagList, setTagList] = useState([]);
36 | const [maxPersonnel, setMaxPersonnel] = useState(1);
37 |
38 | const [createRoomRequest, createRoomLoading, , createdRoomId] = useAxios<{
39 | studyRoomId: number;
40 | }>(createStudyRoomRequest);
41 |
42 | useEffect(() => {
43 | if (createdRoomId) {
44 | navigate(`/study-room/${createdRoomId}`);
45 | }
46 | }, [createdRoomId]);
47 |
48 | const createNewStudyRoom = useCallback(
49 | (e: FormEvent) => {
50 | e.preventDefault();
51 | const formData = Object.fromEntries(new FormData(e.currentTarget));
52 | if (formData.name === '') return;
53 | if (user) {
54 | createRoomRequest({
55 | ...formData,
56 | maxPersonnel,
57 | managerId: user.userId,
58 | tags: tagList,
59 | });
60 | }
61 | },
62 | [maxPersonnel, tagList],
63 | );
64 |
65 | const toNumber = useCallback((e: ChangeEvent) => {
66 | setMaxPersonnel(Number(e.currentTarget.value));
67 | }, []);
68 |
69 | const preventDefault = useCallback((e: React.KeyboardEvent) => {
70 | const el = e.target as Element;
71 | if (e.key === 'Enter' && el.tagName !== 'BUTTON') e.preventDefault();
72 | }, []);
73 |
74 | return (
75 | <>
76 | {createRoomLoading && }
77 |
78 | 새로운 공부방 만들기
79 |
107 |
108 | >
109 | );
110 | }
111 |
--------------------------------------------------------------------------------
/frontend/src/components/studyRoomList/GlobalChat.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-non-null-assertion */
2 | import React, { useEffect, useRef, useState } from 'react';
3 | import styled from 'styled-components';
4 | import ChatItem from '@components/studyRoomList/StudyRoomListChatItem';
5 | import ChatBar from '@components/studyRoomList/StudyRoomListChatBar';
6 | import { useRecoilValue } from 'recoil';
7 | import { userState } from 'recoil/atoms';
8 | import { v4 } from 'uuid';
9 | import { io } from 'socket.io-client';
10 | import { ReactComponent as DownArrow } from '@assets/icons/down-triangle.svg';
11 |
12 | const StyledDownArrowIcon = styled(DownArrow)`
13 | width: 18px;
14 | `;
15 |
16 | const ChatLayout = styled.div`
17 | position: relative;
18 | margin: 8px -30px 0;
19 | min-height: 47px;
20 | `;
21 |
22 | const ActiveButton = styled.button`
23 | width: 118px;
24 | height: 33px;
25 | margin: 2px 0 -1px 66px;
26 | border-radius: 10px 10px 0px 0px;
27 | box-shadow: 2px -2px 2px rgb(0 0 0 / 17%);
28 | background-color: var(--orange2);
29 | z-index: 1;
30 | `;
31 |
32 | const ChatContainer = styled.div`
33 | width: 100%;
34 | height: 296px;
35 | padding: 25px 28px;
36 | display: flex;
37 | flex-direction: column;
38 | gap: 10px;
39 | box-shadow: 2px -2px 4px rgba(0, 0, 0, 0.2);
40 | border-radius: 30px 30px 0px 0px;
41 | background-color: var(--orange2);
42 | transition: all 1s;
43 | `;
44 |
45 | const ChatList = styled.div`
46 | height: 200px;
47 | display: flex;
48 | flex-direction: column;
49 | gap: 3px;
50 | overflow-y: scroll;
51 | &::-webkit-scrollbar {
52 | width: 6px;
53 | border-radius: 3px;
54 | background: var(--orange3);
55 | }
56 | &::-webkit-scrollbar-thumb {
57 | border-radius: 3px;
58 | background: var(--orange);
59 | }
60 | `;
61 |
62 | const Wrapper = styled.div`
63 | position: absolute;
64 | left: 0;
65 | bottom: 0;
66 | display: flex;
67 | flex-direction: column;
68 | width: 100%;
69 | overflow: hidden;
70 |
71 | &.inactive {
72 | ${StyledDownArrowIcon} {
73 | transform: rotate(180deg);
74 | }
75 | ${ChatContainer} {
76 | height: 0;
77 | padding: 11px 28px;
78 | }
79 | }
80 | `;
81 |
82 | const socket = io(process.env.REACT_APP_SOCKET_URL!, {
83 | autoConnect: false,
84 | path: '/globalChat/socket.io',
85 | });
86 |
87 | function GlobalChat() {
88 | const user = useRecoilValue(userState);
89 | const chatListRef = useRef(null);
90 | const [chatList, setChatList] = useState<
91 | Array<{ name: string; content: string }>
92 | >([]);
93 | const [chat, setChat] = useState('');
94 | const [isActive, setIsActive] = useState(false);
95 |
96 | const toggleChat = (e: React.MouseEvent) => {
97 | if (!(e.target as HTMLElement).closest('button')) return;
98 | setIsActive(!isActive);
99 | };
100 |
101 | useEffect(() => {
102 | socket.connect();
103 |
104 | socket.on(
105 | 'globalChat',
106 | async (body: { nickname: string; chat: string }) => {
107 | setChatList((prev) => [
108 | ...prev,
109 | { name: body.nickname, content: body.chat },
110 | ]);
111 | },
112 | );
113 |
114 | return () => {
115 | socket.off('globalChat');
116 | socket.disconnect();
117 | };
118 | }, []);
119 |
120 | useEffect(() => {
121 | if (user && chat) {
122 | socket.emit('globalChat', { nickname: user.nickname, chat });
123 | setChatList((prev) => [...prev, { name: user.nickname, content: chat }]);
124 | setChat('');
125 | }
126 | }, [chat]);
127 |
128 | useEffect(() => {
129 | if (chatListRef.current) {
130 | chatListRef.current.scrollTop = chatListRef.current.scrollHeight;
131 | }
132 | }, [chatList]);
133 |
134 | return (
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 | {chatList.map(({ name, content }) => {
143 | return ;
144 | })}
145 |
146 |
147 |
148 |
149 |
150 | );
151 | }
152 |
153 | export default React.memo(GlobalChat);
154 |
--------------------------------------------------------------------------------
/frontend/src/components/studyRoomList/SearchRoomResult.tsx:
--------------------------------------------------------------------------------
1 | import Loader from '@components/common/Loader';
2 | import Pagination from '@components/common/Pagination';
3 | import ViewConditionCheckBox from '@components/common/ViewConditionCheckBox';
4 | import useAxios from '@hooks/useAxios';
5 | import qs from 'qs';
6 | import React, { useEffect } from 'react';
7 | import { useLocation } from 'react-router-dom';
8 | import styled from 'styled-components';
9 | import { RoomListData } from 'types/studyRoomList.types';
10 | import getStudyRoomListRequest from '../../axios/requests/getStudyRoomListRequest';
11 | import StudyRoomList from './StudyRoomList';
12 |
13 | const SearchInfoLayout = styled.div`
14 | display: flex;
15 | justify-content: space-between;
16 | align-items: center;
17 | `;
18 |
19 | const SearchResultText = styled.h3`
20 | font-weight: 700;
21 | font-size: 20px;
22 | `;
23 |
24 | function SearchRoomResult() {
25 | const location = useLocation();
26 | const queryString = qs.parse(location.search, {
27 | ignoreQueryPrefix: true,
28 | });
29 |
30 | const [
31 | getRoomListRequest,
32 | getRoomListLoading,
33 | getRoomListError,
34 | searchResult,
35 | ] = useAxios(getStudyRoomListRequest);
36 |
37 | useEffect(() => {
38 | const page = queryString.page === undefined ? 1 : queryString.page;
39 | const keyword =
40 | queryString.keyword === undefined ? '' : queryString.keyword;
41 | const attendable =
42 | queryString.attendable === undefined ? false : queryString.attendable;
43 |
44 | getRoomListRequest({
45 | page,
46 | keyword,
47 | attendable,
48 | });
49 | }, [queryString.keyword, queryString.page, queryString.attendable]);
50 |
51 | return (
52 | <>
53 | {getRoomListLoading && }
54 |
55 |
56 | {searchResult?.keyword &&
57 | `"${searchResult?.keyword}"에 대한 검색결과`}{' '}
58 | 총 {searchResult?.totalCount}건
59 |
60 |
61 |
66 | 참여 가능한 방만 보기
67 |
68 |
69 |
70 |
71 | {searchResult && (
72 |
80 | )}
81 | >
82 | );
83 | }
84 |
85 | export default React.memo(SearchRoomResult);
86 |
--------------------------------------------------------------------------------
/frontend/src/components/studyRoomList/StudyRoomItem.tsx:
--------------------------------------------------------------------------------
1 | import { useNavigate } from 'react-router-dom';
2 | import styled from 'styled-components';
3 | import UserIcon from '@assets/icons/user.svg';
4 | import KingIcon from '@assets/icons/king.svg';
5 | import ParticipantsIcon from '@assets/icons/participants.svg';
6 | import HashTagIcon from '@assets/icons/hashtag.svg';
7 |
8 | const StudyRoomItemLayout = styled.div`
9 | padding: 15px 13px;
10 | display: flex;
11 | gap: 10px;
12 | flex-direction: column;
13 | background-color: #ffc737;
14 | border-radius: 10px;
15 | cursor: pointer;
16 | `;
17 |
18 | const NameLayout = styled.div`
19 | margin: 12px 0 10px;
20 | `;
21 | const Name = styled.div`
22 | overflow: hidden;
23 | white-space: nowrap;
24 | text-overflow: ellipsis;
25 | font-weight: 700;
26 | font-size: 20px;
27 | padding-left: 15px;
28 | `;
29 | const NameDeco = styled.div`
30 | height: 10px;
31 | background-color: #ffeec3;
32 | margin-top: -7px;
33 | `;
34 |
35 | const ManagerLayout = styled.div`
36 | display: flex;
37 | align-items: center;
38 | gap: 7px;
39 | font-size: 16px;
40 | `;
41 |
42 | const PersonStatusBox = styled.div`
43 | padding: 0 6px;
44 | display: flex;
45 | justify-content: center;
46 | align-items: center;
47 | width: fit-content;
48 | height: 31px;
49 | background-color: #fff1d7;
50 | border-radius: 6px;
51 | font-size: 16px;
52 | `;
53 |
54 | const HashTagLayout = styled.div`
55 | display: flex;
56 | img {
57 | margin-right: 7px;
58 | }
59 | `;
60 |
61 | const Tag = styled.div`
62 | width: fit-content;
63 | padding: 6px 12px;
64 | color: var(--white);
65 | font-size: 16px;
66 | background-color: #ff7426;
67 | border-radius: 25px;
68 |
69 | & + & {
70 | margin-left: 4px;
71 | }
72 | `;
73 |
74 | interface Props {
75 | name: string;
76 | content: string;
77 | maxPersonnel: number;
78 | currentPersonnel: number;
79 | managerNickname: string;
80 | tags: string[];
81 | nickNameOfParticipants: string[];
82 | created: string;
83 | studyRoomId: number;
84 | }
85 |
86 | export default function StudyRoomItem(props: Props) {
87 | const {
88 | name,
89 | content,
90 | maxPersonnel,
91 | currentPersonnel,
92 | managerNickname,
93 | tags,
94 | nickNameOfParticipants,
95 | created,
96 | studyRoomId,
97 | } = props;
98 |
99 | const navigate = useNavigate();
100 | return (
101 | {
103 | navigate(`/study-room/${studyRoomId}`, {
104 | state: props,
105 | });
106 | }}>
107 |
108 | {name}
109 |
110 |
111 |
112 |
113 |
114 |
115 | {managerNickname}
116 |
117 |
118 |
119 |
120 | {currentPersonnel}/{maxPersonnel}
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 | {tags.map((tag) => (
130 | {tag}
131 | ))}
132 |
133 |
134 | );
135 | }
136 |
--------------------------------------------------------------------------------
/frontend/src/components/studyRoomList/StudyRoomList.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import StudyRoomItem from '@components/studyRoomList/StudyRoomItem';
3 | import { RoomListData } from 'types/studyRoomList.types';
4 |
5 | const RoomListLayout = styled.div`
6 | flex: 1;
7 | margin: 30px 0 35px;
8 | `;
9 |
10 | const RoomList = styled.ul`
11 | display: grid;
12 | grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
13 | gap: 10px;
14 | `;
15 |
16 | interface Props {
17 | searchResult: RoomListData | null;
18 | }
19 |
20 | export default function StudyRoomList({ searchResult }: Props) {
21 | return (
22 |
23 |
24 | {searchResult &&
25 | searchResult.studyRoomList.map((room) => (
26 |
27 | ))}
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/frontend/src/components/studyRoomList/StudyRoomListChatBar.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { KeyboardEvent, SetStateAction, Dispatch } from 'react';
3 |
4 | const ChatBar = styled.div`
5 | display: flex;
6 | gap: 14px;
7 | `;
8 |
9 | const ChatModeBar = styled.div`
10 | width: 176px;
11 | height: 50px;
12 | background-color: var(--orange3);
13 | border-radius: 5px;
14 | text-align: center;
15 | font-family: 'Pretendard';
16 | font-weight: 700;
17 | font-size: 18px;
18 | line-height: 54px;
19 | `;
20 |
21 | const ChatInputBar = styled.input`
22 | width: calc(100% - 176px);
23 | height: 50px;
24 | padding-left: 18px;
25 | background-color: var(--orange4);
26 | border: none;
27 | border-radius: 5px;
28 | font-family: 'Pretendard';
29 | font-weight: 400;
30 | font-size: 18px;
31 | line-height: 21px;
32 | `;
33 |
34 | interface Props {
35 | nickname: string | undefined;
36 | setChat: Dispatch>;
37 | }
38 |
39 | export default function StudyRoomListChatBar(props: Props) {
40 | const { setChat, nickname } = props;
41 | return (
42 |
43 | 모두에게
44 | ) => {
46 | if (e.key === 'Enter' && nickname && e.currentTarget.value !== '') {
47 | setChat(e.currentTarget.value);
48 | e.currentTarget.value = '';
49 | }
50 | }}
51 | placeholder="채팅을 입력하세요."
52 | />
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/frontend/src/components/studyRoomList/StudyRoomListChatItem.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const ChatItem = styled.div`
4 | font-weight: 400;
5 | font-size: 16px;
6 | line-height: 19px;
7 | `;
8 |
9 | interface Props {
10 | name: string;
11 | content: string;
12 | }
13 |
14 | export default function StudyRoomListChatItem(props: Props) {
15 | const { name, content } = props;
16 | return (
17 |
18 | {name} : {content}
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/frontend/src/components/studyRoomList/TagInput.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const TagInputLayout = styled.div`
4 | margin-top: 10px;
5 | width: 100%;
6 | `;
7 |
8 | const Input = styled.input`
9 | width: 100%;
10 | padding: 21px 28px;
11 | border: 2px solid #ff8a00;
12 | border-radius: 15px;
13 | font-size: 18px;
14 |
15 | &::placeholder {
16 | color: #ffc7a1;
17 | }
18 | `;
19 |
20 | const GuideText = styled.div`
21 | margin: 8px 0 10px 7px;
22 | color: var(--guideText);
23 | font-size: 14px;
24 | `;
25 |
26 | const TagList = styled.ul`
27 | display: flex;
28 | flex-wrap: wrap;
29 | gap: 7px;
30 | `;
31 |
32 | const TagItem = styled.li`
33 | display: flex;
34 | align-items: center;
35 | background-color: #fedba7;
36 | padding: 10px;
37 | border-radius: 5px;
38 | font-size: 18px;
39 | `;
40 |
41 | const RemoveButton = styled.button`
42 | display: flex;
43 | justify-content: center;
44 | align-items: center;
45 |
46 | margin-left: 7px;
47 | width: 20px;
48 | height: 20px;
49 | background-color: var(--white);
50 | border-radius: 100%;
51 | `;
52 |
53 | interface Props {
54 | tagList: string[];
55 | setTagList: React.Dispatch>;
56 | }
57 |
58 | export default function TagInput({ tagList, setTagList }: Props) {
59 | const addTag = (e: React.KeyboardEvent) => {
60 | if (tagList.length === 2) {
61 | (e.target as HTMLInputElement).value = '';
62 | return;
63 | }
64 |
65 | const newTag = (e.target as HTMLInputElement).value.trim();
66 | if (newTag !== '' && !tagList.includes(newTag)) {
67 | setTagList([...tagList, (e.target as HTMLInputElement).value]);
68 | }
69 | (e.target as HTMLInputElement).value = '';
70 | };
71 |
72 | const removeTag = (indexToRemove: number) => {
73 | setTagList([...tagList.filter((_, index) => index !== indexToRemove)]);
74 | };
75 |
76 | return (
77 |
78 | (e.key === 'Enter' ? addTag(e) : null)}
81 | placeholder="원하는 태그를 입력 후, Enter를 입력하세요."
82 | />
83 | ※ 태그는 최대 2개까지 입력 가능합니다.
84 |
85 | {tagList.map((tag, index) => (
86 |
87 | {tag}
88 | removeTag(index)}>
89 | x
90 |
91 |
92 | ))}
93 |
94 |
95 | );
96 | }
97 |
--------------------------------------------------------------------------------
/frontend/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 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useAxios.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosPromise } from 'axios';
2 | import { useCallback, useEffect, useState } from 'react';
3 | import { useNavigate } from 'react-router-dom';
4 |
5 | export default function useAxios(
6 | axiosFunction: (arg?: any) => AxiosPromise,
7 | options?: { onMount?: boolean; arg?: any; errNavigate?: boolean },
8 | ): [
9 | (arg?: any) => Promise,
10 | boolean,
11 | {
12 | statusCode: number | undefined;
13 | message: any;
14 | error: string;
15 | } | null,
16 | T | null,
17 | ] {
18 | const onMount = options?.onMount || false;
19 | const argument = options?.arg || undefined;
20 | const errNavigate = options?.errNavigate ?? true;
21 | const [loading, setLoading] = useState(onMount);
22 | const [error, setError] = useState<{
23 | statusCode: number | undefined;
24 | message: any;
25 | error: string;
26 | } | null>(null);
27 | const [data, setData] = useState(null);
28 | const navigate = useNavigate();
29 |
30 | useEffect(() => {
31 | if (!error) return;
32 | if (errNavigate) navigate('/error', { state: error });
33 | }, [error]);
34 |
35 | const request = useCallback(
36 | async (arg?: any) => {
37 | setLoading(true);
38 | setError(null);
39 | setData(null);
40 | try {
41 | const response = await axiosFunction(arg);
42 | setData(response.data);
43 | } catch (err) {
44 | if (axios.isAxiosError(err)) {
45 | if (err.response) {
46 | setError(err.response.data);
47 | } else {
48 | setError({
49 | statusCode: undefined,
50 | message: err.message,
51 | error: err.message,
52 | });
53 | }
54 | }
55 | }
56 | setLoading(false);
57 | },
58 | [axiosFunction],
59 | );
60 |
61 | useEffect(() => {
62 | if (!onMount) return;
63 | request(argument);
64 | }, []);
65 |
66 | return [request, loading, error, data];
67 | }
68 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useInputValidation.ts:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 |
3 | export default function useInputValidation(
4 | callback: (value: string) => boolean,
5 | initialValue: string,
6 | ): [(e: React.ChangeEvent) => void, boolean] {
7 | const [isValidated, setIsValidated] = useState(callback(initialValue));
8 | const validateInputValue = (e: React.ChangeEvent) => {
9 | const { value } = e.currentTarget;
10 | setIsValidated(callback(value));
11 | };
12 | return [validateInputValue, isValidated];
13 | }
14 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useStudyRoomPage.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import useAxios from '@hooks/useAxios';
3 | import { userState } from 'recoil/atoms';
4 | import { useRecoilValue } from 'recoil';
5 | import { useParams } from 'react-router-dom';
6 | import { RoomInfoData } from 'types/studyRoom.types';
7 | import enterRoomRequest from '../axios/requests/enterRoomRequest';
8 | import getStudyRoomInfoRequest from '../axios/requests/getStudyRoomInfoRequest';
9 |
10 | export function useStudyRoomPage() {
11 | const { roomId } = useParams();
12 | const user = useRecoilValue(userState);
13 | const [activeSideBar, setActiveSideBar] = useState('');
14 | const [isActiveCanvas, setIsActiveCanvas] = useState(false);
15 | const [requestGetStudyRoomInfo, , , roomInfo] = useAxios(
16 | getStudyRoomInfoRequest,
17 | );
18 | const [, , , enterRoomData] = useAxios<''>(enterRoomRequest, {
19 | onMount: true,
20 | arg: {
21 | studyRoomId: roomId,
22 | userId: user?.userId,
23 | nickname: user?.nickname,
24 | isMaster: true,
25 | },
26 | });
27 |
28 | useEffect(() => {
29 | if (enterRoomData === null) return;
30 | requestGetStudyRoomInfo(roomId);
31 | }, [enterRoomData]);
32 |
33 | return {
34 | roomInfo,
35 | user,
36 | isActiveCanvas,
37 | activeSideBar,
38 | setIsActiveCanvas,
39 | setActiveSideBar,
40 | };
41 | }
42 |
--------------------------------------------------------------------------------
/frontend/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import { BrowserRouter } from 'react-router-dom';
4 | import { RecoilRoot } from 'recoil';
5 | import App from './App';
6 | import '@styles/reset.css';
7 | import '@styles/index.css';
8 |
9 | const root = ReactDOM.createRoot(
10 | document.getElementById('root') as HTMLElement,
11 | );
12 |
13 | root.render(
14 |
15 |
16 |
17 |
18 | ,
19 | );
20 |
--------------------------------------------------------------------------------
/frontend/src/pages/ErrorPage.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import CustomButton from '@components/common/CustomButton';
3 | import { useLocation, useNavigate, Navigate } from 'react-router-dom';
4 |
5 | const PageLayout = styled.div`
6 | height: 100vh;
7 | display: flex;
8 | flex-direction: column;
9 | justify-content: center;
10 | align-items: center;
11 |
12 | font-size: 30px;
13 |
14 | div + div {
15 | margin-top: 15px;
16 | }
17 | `;
18 |
19 | const MessageBox = styled.div`
20 | width: 600px;
21 | border-radius: 10px;
22 | padding: 30px;
23 | background-color: var(--orange2);
24 | `;
25 |
26 | export default function ErrorPage() {
27 | const navigate = useNavigate();
28 | const { state } = useLocation();
29 |
30 | if (!state) {
31 | return ;
32 | }
33 |
34 | const { message, statusCode, error } = state || {};
35 |
36 | const text =
37 | typeof message === 'object'
38 | ? Object.values(message || {}).join(', ')
39 | : message;
40 |
41 | return (
42 |
43 | 에러가 발생했습니다 :(
44 |
45 | {statusCode} {error}
46 |
47 | message : {text}
48 |
49 | {
51 | navigate('/home');
52 | }}
53 | width="130px"
54 | margin="100px 0 0">
55 | 홈으로 이동
56 |
57 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/frontend/src/pages/InitPage.tsx:
--------------------------------------------------------------------------------
1 | import CustomButton from '@components/common/CustomButton';
2 | import styled from 'styled-components';
3 | import { ReactComponent as TodyImage } from '@assets/tody.svg';
4 | import { useNavigate, Link } from 'react-router-dom';
5 |
6 | const InitPageLayout = styled.div`
7 | display: flex;
8 | flex-direction: column;
9 | justify-content: center;
10 | align-items: center;
11 | width: 100%;
12 | height: 100vh;
13 | background-color: #ffce70;
14 | `;
15 |
16 | const StyledTodyImage = styled(TodyImage)`
17 | width: 360px;
18 | `;
19 |
20 | const SignUpText = styled.div`
21 | margin-top: 18px;
22 | font-size: 18px;
23 |
24 | .bold {
25 | font-weight: 700;
26 | }
27 | `;
28 |
29 | const StyledLink = styled(Link)`
30 | font-weight: 700;
31 | text-decoration: none;
32 | `;
33 |
34 | export default function InitPage() {
35 | const navigate = useNavigate();
36 |
37 | const moveToLoginPage = () => {
38 | navigate('/login');
39 | };
40 |
41 | return (
42 |
43 |
44 |
45 | 로그인
46 |
47 |
48 | 회원이 아니신가요? 회원가입 GO
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/frontend/src/pages/LoginPage.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-alert */
2 | import CustomButton from '@components/common/CustomButton';
3 | import styled from 'styled-components';
4 | import CustomInput from '@components/common/CustomInput';
5 | import StyledHeader1 from '@components/common/StyledHeader1';
6 | import { useNavigate, Link } from 'react-router-dom';
7 | import useAxios from '@hooks/useAxios';
8 | import { useCallback, useEffect, useRef } from 'react';
9 | import Loader from '@components/common/Loader';
10 | import useInputValidation from '@hooks/useInputValidation';
11 | import { useRecoilState } from 'recoil';
12 | import { userState } from 'recoil/atoms';
13 | import loginRequest from '../axios/requests/loginRequest';
14 |
15 | const LoginPageLayout = styled.div`
16 | height: 100vh;
17 | display: flex;
18 | justify-content: center;
19 | align-items: center;
20 | `;
21 |
22 | const Wrapper = styled.div`
23 | width: 358px;
24 | display: flex;
25 | flex-direction: column;
26 | align-items: center;
27 | `;
28 |
29 | const SignUpText = styled.div`
30 | margin-top: 18px;
31 | font-size: 15px;
32 | text-align: center;
33 |
34 | .bold {
35 | font-weight: 700;
36 | }
37 | `;
38 |
39 | const StyledLink = styled(Link)`
40 | font-weight: 700;
41 | text-decoration: none;
42 | `;
43 |
44 | export default function LoginPage() {
45 | const navigate = useNavigate();
46 | const [requestLogin, loginLoading, loginError, loginData] = useAxios<{
47 | userId: string;
48 | nickname: string;
49 | }>(loginRequest, { errNavigate: false });
50 | const [user, setUser] = useRecoilState(userState);
51 | const idInputRef = useRef(null);
52 | const pwInputRef = useRef(null);
53 |
54 | useEffect(() => {
55 | if (user === null) return;
56 | navigate('/home', { replace: true });
57 | }, [user, navigate]);
58 |
59 | useEffect(() => {
60 | if (!loginError) return;
61 | if (loginError.statusCode === 400) {
62 | alert('로그인에 실패하였습니다.');
63 | } else {
64 | alert(loginError.message);
65 | }
66 | if (!idInputRef.current || !pwInputRef.current) return;
67 | idInputRef.current.value = '';
68 | pwInputRef.current.value = '';
69 | }, [loginError]);
70 |
71 | useEffect(() => {
72 | if (loginData === null) return;
73 | alert('로그인 성공');
74 | setUser(loginData);
75 | navigate('/home');
76 | }, [loginData, navigate, setUser]);
77 |
78 | const login = useCallback(
79 | (e: React.FormEvent) => {
80 | e.preventDefault();
81 | const formData = Object.fromEntries(new FormData(e.currentTarget));
82 | requestLogin(formData);
83 | },
84 | [requestLogin],
85 | );
86 |
87 | const [validateId, isIdValidated] = useInputValidation(
88 | (value) => !!value.length,
89 | '',
90 | );
91 | const [validatePw, isPwValidated] = useInputValidation(
92 | (value) => !!value.length,
93 | '',
94 | );
95 |
96 | return (
97 | <>
98 | {loginLoading && }
99 |
100 |
101 | 로그인
102 |
127 |
128 |
129 | >
130 | );
131 | }
132 |
--------------------------------------------------------------------------------
/frontend/src/pages/MainPage.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import MainSideBar from '@components/common/MainSideBar';
3 | import { ReactComponent as LogoWithName } from '@assets/logoWithName.svg';
4 |
5 | const MainPageLayout = styled.div`
6 | display: flex;
7 | `;
8 |
9 | const Content = styled.div`
10 | flex: 1;
11 | position: relative;
12 | padding: 45px 30px;
13 | overflow: hidden;
14 | `;
15 |
16 | const StyledLogo = styled(LogoWithName)`
17 | position: absolute;
18 | top: 50%;
19 | left: 50%;
20 | transform: translate(-50%, -50%);
21 | `;
22 |
23 | export default function MainPage() {
24 | return (
25 |
26 |
27 |
28 |
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/frontend/src/pages/NotFoundPage.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import NotFoundPageImage from '@assets/404.jpg';
3 | import CustomButton from '@components/common/CustomButton';
4 | import { useNavigate } from 'react-router-dom';
5 |
6 | const PageLayout = styled.div`
7 | height: 100vh;
8 | display: flex;
9 | flex-direction: column;
10 | justify-content: center;
11 | align-items: center;
12 |
13 | font-size: 30px;
14 |
15 | div + div {
16 | margin-top: 15px;
17 | }
18 | `;
19 |
20 | const StyledImage = styled.img`
21 | height: 300px;
22 | `;
23 |
24 | export default function NotFoundPage() {
25 | const navigate = useNavigate();
26 |
27 | return (
28 |
29 |
33 | 찾을 수 없는 페이지입니다.
34 | 요청하신 페이지는 사라졌거나, 잘못된 경로를 이용하셨어요 :(
35 |
36 | {
38 | navigate('home');
39 | }}
40 | width="130px"
41 | margin="100px 0 0">
42 | 홈으로 이동
43 |
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/frontend/src/pages/SfuPage.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import ChatSideBar from '@components/studyRoom/ChatSideBar';
3 | import RemoteVideo from '@components/studyRoom/RemoteVideo';
4 | import { useSfu } from '@hooks/useSfu';
5 | import { useStudyRoomPage } from '@hooks/useStudyRoomPage';
6 | import ParticipantsSideBar from '@components/studyRoom/ParticipantsSideBar';
7 | import Canvas from '@components/studyRoom/Canvas';
8 | import Loader from '@components/common/Loader';
9 | import BottomBar from '@components/studyRoom/BottomBar';
10 | import NicknameWrapper from '@components/studyRoom/NicknameWrapper';
11 |
12 | const StudyRoomPageLayout = styled.div`
13 | height: 100vh;
14 | display: flex;
15 | flex-direction: column;
16 | background-color: var(--yellow3);
17 | `;
18 |
19 | const RoomInfo = styled.div`
20 | position: absolute;
21 | top: 24px;
22 | left: 24px;
23 | display: flex;
24 | gap: 7px;
25 | `;
26 |
27 | const RoomTitle = styled.h1`
28 | font-family: 'yg-jalnan';
29 | font-size: 22px;
30 | font-weight: 700;
31 | `;
32 |
33 | const RoomStatus = styled.div`
34 | width: 42px;
35 | height: 24px;
36 | background-color: var(--grey);
37 | border-radius: 5px;
38 | display: flex;
39 | justify-content: center;
40 | align-items: center;
41 | `;
42 |
43 | const Content = styled.div`
44 | position: relative;
45 | flex: 1;
46 | display: flex;
47 | overflow: hidden;
48 | `;
49 | const VideoList = styled.div`
50 | flex: 1;
51 | display: flex;
52 | justify-content: center;
53 | align-content: center;
54 | flex-wrap: wrap;
55 | gap: 10px;
56 | overflow-y: auto;
57 | width: 100%;
58 |
59 | &.alone {
60 | width: 40%;
61 | }
62 |
63 | & > div {
64 | flex-basis: 200px;
65 | flex-grow: 1;
66 | height: auto;
67 | }
68 | `;
69 |
70 | const VideoItem = styled.video`
71 | width: 100%;
72 | border-radius: 12px;
73 | `;
74 |
75 | const VideoListLayout = styled.div`
76 | position: relative;
77 | flex: 1;
78 | display: flex;
79 | flex-direction: column;
80 | align-items: center;
81 | padding: 58px 10px 10px;
82 |
83 | &.activeCanvas {
84 | flex-direction: row-reverse;
85 | justify-content: space-evenly;
86 | gap: 25px;
87 |
88 | ${VideoList} {
89 | max-height: 100%;
90 | max-width: 284px;
91 | min-width: 135px;
92 | overflow-y: auto;
93 | }
94 | }
95 | `;
96 |
97 | const BlankBox = styled.div`
98 | background-color: var(--yellow);
99 | padding-top: 75%;
100 | border-radius: 12px;
101 | `;
102 |
103 | export default function SfuPage() {
104 | const {
105 | roomInfo,
106 | user,
107 | isActiveCanvas,
108 | activeSideBar,
109 | setIsActiveCanvas,
110 | setActiveSideBar,
111 | } = useStudyRoomPage();
112 |
113 | const {
114 | myStream,
115 | remoteStreams,
116 | userList,
117 | receiveDcs,
118 | sendDc,
119 | myVideoRef,
120 | isScreenShare,
121 | noCamPeerIds,
122 | nicknameRef,
123 | isCameraUsable,
124 | setIsScreenShare,
125 | } = useSfu(roomInfo, user);
126 |
127 | if (!roomInfo) {
128 | return ;
129 | }
130 |
131 | return (
132 |
133 |
134 |
135 | {roomInfo.name}
136 |
137 | {userList.length}/{roomInfo.maxPersonnel}
138 |
139 |
140 |
141 |
147 |
148 | {isCameraUsable || isScreenShare ? (
149 |
150 | ) : (
151 |
152 | )}
153 |
154 | {Object.entries(remoteStreams).map(([peerId, remoteStream]) => (
155 |
158 |
159 |
160 | ))}
161 | {noCamPeerIds.map((peerId) => (
162 |
165 |
166 |
167 | ))}
168 |
169 |
174 |
175 |
180 |
184 |
185 |
194 |
195 | );
196 | }
197 |
--------------------------------------------------------------------------------
/frontend/src/pages/StudyRoomListPage.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import styled from 'styled-components';
3 | import MainSideBar from '@components/common/MainSideBar';
4 | import SearchBar from '@components/common/SearchBar';
5 | import CreateButton from '@components/common/CreatButton';
6 | import GlobalChat from '@components/studyRoomList/GlobalChat';
7 | import CreateNewRoomModal from '@components/studyRoomList/CreateNewRoomModal';
8 | import SearchRoomResult from '@components/studyRoomList/SearchRoomResult';
9 |
10 | const StudyRoomListPageLayout = styled.div`
11 | display: flex;
12 | `;
13 |
14 | const Content = styled.div`
15 | position: relative;
16 | flex: 1;
17 | display: flex;
18 | flex-direction: column;
19 | padding: 45px 30px 0;
20 | height: 100vh;
21 | overflow: auto;
22 | `;
23 |
24 | const PageTitle = styled.h1`
25 | margin-bottom: 24px;
26 | font-family: 'yg-jalnan';
27 | font-size: 25px;
28 | font-weight: 700;
29 | `;
30 |
31 | export default function StudyRoomListPage() {
32 | const [modal, setModal] = useState(false);
33 |
34 | const openModal = () => {
35 | setModal(true);
36 | };
37 |
38 | return (
39 |
40 |
41 |
42 | 공부방 목록
43 | 공부방 생성
44 |
45 |
46 |
47 |
48 | {modal && }
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/frontend/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/frontend/src/recoil/atoms.ts:
--------------------------------------------------------------------------------
1 | import { atom } from 'recoil';
2 |
3 | export const userState = atom<{ userId: string; nickname: string } | null>({
4 | key: 'userState',
5 | default: null,
6 | });
7 |
--------------------------------------------------------------------------------
/frontend/src/routes/Router.tsx:
--------------------------------------------------------------------------------
1 | import { Route, Routes } from 'react-router-dom';
2 | import { lazy, Suspense } from 'react';
3 | import Loader from '@components/common/Loader';
4 |
5 | const InitPage = lazy(() => import('@pages/InitPage'));
6 | const SfuPage = lazy(() => import('@pages/SfuPage'));
7 | const LoginPage = lazy(() => import('@pages/LoginPage'));
8 | const SignupPage = lazy(() => import('@pages/SignupPage'));
9 | const StudyRoomListPage = lazy(() => import('@pages/StudyRoomListPage'));
10 | const MainPage = lazy(() => import('@pages/MainPage'));
11 | const PrivateRoute = lazy(() => import('@components/common/PrivateRoute'));
12 | const StudyRoomGuard = lazy(() => import('@components/common/StudyRoomGuard'));
13 | const NotFoundPage = lazy(() => import('@pages/NotFoundPage'));
14 | const ErrorPage = lazy(() => import('@pages/ErrorPage'));
15 |
16 | export default function Router() {
17 | return (
18 | }>
19 |
20 | } />
21 | } />
22 | } />
23 |
27 |
28 |
29 | }
30 | />
31 |
35 |
36 |
37 |
38 |
39 | }
40 | />
41 |
45 |
46 |
47 | }
48 | />
49 | } />
50 | } />
51 |
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/frontend/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/frontend/src/sockets/sfuSocket.ts:
--------------------------------------------------------------------------------
1 | import { io } from 'socket.io-client';
2 |
3 | const socket = io(process.env.REACT_APP_SFU_URL || '', {
4 | autoConnect: false,
5 | path: '/sfu/socket.io',
6 | });
7 |
8 | export default socket;
9 |
--------------------------------------------------------------------------------
/frontend/src/styles/index.css:
--------------------------------------------------------------------------------
1 | /* 폰트: 프리텐다드 */
2 | @font-face {
3 | font-family: 'Pretendard-Regular';
4 | src: url('https://cdn.jsdelivr.net/gh/Project-Noonnu/noonfonts_2107@1.1/Pretendard-Regular.woff')
5 | format('woff');
6 | font-weight: 400;
7 | font-style: normal;
8 | font-display: swap;
9 | }
10 |
11 | /* 폰트: 여기어때 잘난체 */
12 | @font-face {
13 | font-family: 'yg-jalnan';
14 | src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_four@1.2/JalnanOTF00.woff')
15 | format('woff');
16 | font-weight: normal;
17 | font-style: normal;
18 | font-display: swap;
19 | }
20 |
21 | :root {
22 | --black: #252525;
23 | --yellow: #ffce70;
24 | --yellow2: #ffb11a;
25 | --yellow3: #fffdf5;
26 | --red: #ff4545;
27 | --orange: #ff8a00;
28 | --orange2: #ffe7b9;
29 | --orange3: #ffcc6a;
30 | --orange4: #f9efdb;
31 | --white: #ffffff;
32 | --grey: #d9d9d9;
33 | --guideText: #a3a3a3;
34 | }
35 |
36 | * {
37 | font-family: 'Pretendard-Regular';
38 | color: var(--black);
39 | }
40 |
41 | html,
42 | body {
43 | font-family: 'Pretendard-Regular';
44 | }
45 |
46 | .flex-row {
47 | display: flex;
48 | gap: 6px;
49 | }
50 | .space-between {
51 | justify-content: space-between;
52 | }
53 |
--------------------------------------------------------------------------------
/frontend/src/styles/reset.css:
--------------------------------------------------------------------------------
1 | /* http://meyerweb.com/eric/tools/css/reset/
2 | v2.0 | 20110126
3 | License: none (public domain)
4 | */
5 |
6 | html,
7 | body,
8 | div,
9 | span,
10 | applet,
11 | object,
12 | iframe,
13 | h1,
14 | h2,
15 | h3,
16 | h4,
17 | h5,
18 | h6,
19 | p,
20 | blockquote,
21 | pre,
22 | a,
23 | abbr,
24 | acronym,
25 | address,
26 | big,
27 | cite,
28 | code,
29 | del,
30 | dfn,
31 | em,
32 | img,
33 | ins,
34 | kbd,
35 | q,
36 | s,
37 | samp,
38 | small,
39 | strike,
40 | strong,
41 | sub,
42 | sup,
43 | tt,
44 | var,
45 | b,
46 | u,
47 | i,
48 | center,
49 | dl,
50 | dt,
51 | dd,
52 | ol,
53 | ul,
54 | li,
55 | fieldset,
56 | form,
57 | label,
58 | legend,
59 | table,
60 | caption,
61 | tbody,
62 | tfoot,
63 | thead,
64 | tr,
65 | th,
66 | td,
67 | article,
68 | aside,
69 | canvas,
70 | details,
71 | embed,
72 | figure,
73 | figcaption,
74 | footer,
75 | header,
76 | hgroup,
77 | menu,
78 | nav,
79 | output,
80 | ruby,
81 | section,
82 | summary,
83 | time,
84 | mark,
85 | audio,
86 | video {
87 | margin: 0;
88 | padding: 0;
89 | border: 0;
90 | font-size: 100%;
91 | font: inherit;
92 | vertical-align: baseline;
93 | }
94 | /* HTML5 display-role reset for older browsers */
95 | article,
96 | aside,
97 | details,
98 | figcaption,
99 | figure,
100 | footer,
101 | header,
102 | hgroup,
103 | menu,
104 | nav,
105 | section {
106 | display: block;
107 | }
108 | body {
109 | line-height: 1;
110 | }
111 | ol,
112 | ul {
113 | list-style: none;
114 | }
115 | blockquote,
116 | q {
117 | quotes: none;
118 | }
119 | blockquote:before,
120 | blockquote:after,
121 | q:before,
122 | q:after {
123 | content: '';
124 | content: none;
125 | }
126 | table {
127 | border-collapse: collapse;
128 | border-spacing: 0;
129 | }
130 |
131 | * {
132 | box-sizing: border-box;
133 | }
134 |
135 | input:focus {
136 | outline: none;
137 | }
138 |
139 | button {
140 | border: none;
141 | cursor: pointer;
142 | }
143 |
--------------------------------------------------------------------------------
/frontend/src/types/chat.types.ts:
--------------------------------------------------------------------------------
1 | export interface Chat {
2 | id: string;
3 | type: string;
4 | message: string;
5 | sender: string;
6 | fromId: string;
7 | timestamp: string;
8 | }
9 |
--------------------------------------------------------------------------------
/frontend/src/types/recoil.types.ts:
--------------------------------------------------------------------------------
1 | export type UserData = {
2 | userId: string;
3 | nickname: string;
4 | } | null;
5 |
--------------------------------------------------------------------------------
/frontend/src/types/studyRoom.types.ts:
--------------------------------------------------------------------------------
1 | export type RoomInfoData = {
2 | studyRoomId: number;
3 | name: string;
4 | content: string;
5 | currentPersonnel: number;
6 | maxPersonnel: number;
7 | managerNickname: string;
8 | tags: string[];
9 | nickNameOfParticipants: string[];
10 | created: string;
11 | } | null;
12 |
--------------------------------------------------------------------------------
/frontend/src/types/studyRoomList.types.ts:
--------------------------------------------------------------------------------
1 | export interface RoomListData {
2 | keyword: string;
3 | currentPage: number;
4 | pageCount: number;
5 | totalCount: number;
6 | studyRoomList: RoomItemData[];
7 | }
8 |
9 | export interface RoomItemData {
10 | studyRoomId: number;
11 | name: string;
12 | content: string;
13 | currentPersonnel: number;
14 | maxPersonnel: number;
15 | managerNickname: string;
16 | tags: string[];
17 | nickNameOfParticipants: string[];
18 | created: string;
19 | }
20 |
21 | export interface NewRoomInfoData {
22 | name: string;
23 | content: string;
24 | maxPersonnel: number;
25 | }
26 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "esModuleInterop": true,
8 | "allowSyntheticDefaultImports": true,
9 | "strict": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "noFallthroughCasesInSwitch": true,
12 | "module": "esnext",
13 | "moduleResolution": "node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx",
18 | "baseUrl": "./src",
19 | "paths": {
20 | "@components/*": ["components/*"],
21 | "@pages/*": ["pages/*"],
22 | "@hooks/*": ["hooks/*"],
23 | "@styles/*": ["styles/*"],
24 | "@utils/*": ["utils/*"],
25 | "@assets/*": ["assets/*"]
26 | }
27 | },
28 | "include": ["src"]
29 | }
30 |
--------------------------------------------------------------------------------
/media-server/.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 |
--------------------------------------------------------------------------------
/media-server/.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
--------------------------------------------------------------------------------
/media-server/.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 | }
--------------------------------------------------------------------------------
/media-server/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
6 | [circleci-url]: https://circleci.com/gh/nestjs/nest
7 |
8 | A progressive Node.js framework for building efficient and scalable server-side applications.
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
24 |
25 | ## Description
26 |
27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
28 |
29 | ## Installation
30 |
31 | ```bash
32 | $ npm install
33 | ```
34 |
35 | ## Running the app
36 |
37 | ```bash
38 | # development
39 | $ npm run start
40 |
41 | # watch mode
42 | $ npm run start:dev
43 |
44 | # production mode
45 | $ npm run start:prod
46 | ```
47 |
48 | ## Test
49 |
50 | ```bash
51 | # unit tests
52 | $ npm run test
53 |
54 | # e2e tests
55 | $ npm run test:e2e
56 |
57 | # test coverage
58 | $ npm run test:cov
59 | ```
60 |
61 | ## Support
62 |
63 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
64 |
65 | ## Stay in touch
66 |
67 | - Author - [Kamil Myśliwiec](https://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 |
--------------------------------------------------------------------------------
/media-server/nest-cli.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/nest-cli",
3 | "collection": "@nestjs/schematics",
4 | "sourceRoot": "src"
5 | }
6 |
--------------------------------------------------------------------------------
/media-server/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": "nest start --watch",
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/platform-express": "^9.0.0",
29 | "@nestjs/platform-socket.io": "^9.2.0",
30 | "@nestjs/platform-ws": "^9.2.0",
31 | "@nestjs/swagger": "^6.1.3",
32 | "@nestjs/websockets": "^9.2.1",
33 | "axios": "^1.2.1",
34 | "cache-manager": "^5.1.3",
35 | "cache-manager-ioredis": "^2.1.0",
36 | "cross-env": "^7.0.3",
37 | "reflect-metadata": "^0.1.13",
38 | "rimraf": "^3.0.2",
39 | "rxjs": "^7.2.0",
40 | "socket.io": "^4.5.4",
41 | "uuid": "^9.0.0",
42 | "wrtc": "^0.4.7"
43 | },
44 | "devDependencies": {
45 | "@nestjs/cli": "^9.0.0",
46 | "@nestjs/schematics": "^9.0.0",
47 | "@nestjs/testing": "^9.0.0",
48 | "@types/cache-manager": "^4.0.2",
49 | "@types/cache-manager-ioredis": "^2.0.3",
50 | "@types/express": "^4.17.13",
51 | "@types/jest": "28.1.8",
52 | "@types/node": "^16.0.0",
53 | "@types/socket.io": "^3.0.2",
54 | "@types/supertest": "^2.0.11",
55 | "@types/ws": "^8.5.3",
56 | "@typescript-eslint/eslint-plugin": "^5.0.0",
57 | "@typescript-eslint/parser": "^5.0.0",
58 | "eslint": "^8.0.1",
59 | "eslint-config-prettier": "^8.3.0",
60 | "eslint-plugin-prettier": "^4.0.0",
61 | "jest": "28.1.3",
62 | "prettier": "^2.3.2",
63 | "source-map-support": "^0.5.20",
64 | "supertest": "^6.1.3",
65 | "ts-jest": "28.0.8",
66 | "ts-loader": "^9.2.3",
67 | "ts-node": "^10.0.0",
68 | "tsconfig-paths": "4.1.0",
69 | "typescript": "^4.7.4"
70 | },
71 | "jest": {
72 | "moduleFileExtensions": [
73 | "js",
74 | "json",
75 | "ts"
76 | ],
77 | "rootDir": "src",
78 | "testRegex": ".*\\.spec\\.ts$",
79 | "transform": {
80 | "^.+\\.(t|j)s$": "ts-jest"
81 | },
82 | "collectCoverageFrom": [
83 | "**/*.(t|j)s"
84 | ],
85 | "coverageDirectory": "../coverage",
86 | "testEnvironment": "node"
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/media-server/src/config/cors.config.ts:
--------------------------------------------------------------------------------
1 | const origin =
2 | process.env.NODE_ENV === 'development'
3 | ? 'http://localhost:3000'
4 | : 'https://j221.tk';
5 |
6 | export default { origin };
7 |
--------------------------------------------------------------------------------
/media-server/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 |
--------------------------------------------------------------------------------
/media-server/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 |
--------------------------------------------------------------------------------
/media-server/src/main.ts:
--------------------------------------------------------------------------------
1 | import { NestFactory } from '@nestjs/core';
2 | //import { MediaServerModule } from './mediaServer/mediaServer.module';
3 | import { SfuModule } from './sfu/sfu.module';
4 |
5 | async function bootstrap() {
6 | const port = 9000;
7 | console.log(`Server is running on port ${port}`);
8 | //const mediaServerApp = await NestFactory.create(MediaServerModule);
9 | const mediaServerApp = await NestFactory.create(SfuModule);
10 | await mediaServerApp.listen(port);
11 | }
12 | bootstrap();
13 |
--------------------------------------------------------------------------------
/media-server/src/mediaServer/mediaServer.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 | import corsConfig from 'src/config/cors.config';
13 | import * as wrtc from 'wrtc';
14 |
15 | const receivePcs: { [id: string]: RTCPeerConnection } = {};
16 | const sendPcs: { [id: string]: { [targetId: string]: RTCPeerConnection } } = {};
17 | const streams: { [id: string]: MediaStream[] } = {};
18 | const RTCConfiguration = {
19 | iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
20 | };
21 |
22 | @WebSocketGateway({ cors: corsConfig })
23 | export class MediaServerGateway
24 | implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
25 | {
26 | @WebSocketServer() server: Server;
27 |
28 | afterInit(server: Server) {
29 | console.log('Socket server is running');
30 | }
31 |
32 | async handleConnection(@ConnectedSocket() client: Socket) {
33 | console.log(`connected: ${client.id}`);
34 | client.on('disconnecting', () => {
35 | const roomName = [...client.rooms].filter(
36 | (roomName) => roomName !== client.id,
37 | )[0];
38 | if (receivePcs[client.id]) {
39 | receivePcs[client.id].close();
40 | delete receivePcs[client.id];
41 | }
42 | if (sendPcs[client.id]) {
43 | Object.values(sendPcs[client.id]).forEach((sendPc) => sendPc.close());
44 | delete sendPcs[client.id];
45 | }
46 | client.to(roomName).emit('someone-left-room', client.id);
47 | });
48 | }
49 |
50 | async handleDisconnect(client: Socket) {
51 | console.log(`disconnect: ${client.id}`);
52 | }
53 |
54 | @SubscribeMessage('join')
55 | async handleJoin(
56 | @ConnectedSocket()
57 | client: Socket,
58 | @MessageBody() roomName: any,
59 | ) {
60 | client.join(roomName);
61 | const socketsInRoom = await this.server.in(roomName).fetchSockets();
62 | const peerIdsInRoom = socketsInRoom
63 | .filter((socket) => socket.id !== client.id)
64 | .map((socket) => socket.id);
65 | client.emit('notice-all-peers', peerIdsInRoom);
66 | }
67 |
68 | @SubscribeMessage('senderOffer')
69 | async handleSenderOffer(
70 | @ConnectedSocket() client: Socket,
71 | @MessageBody() { offer }: any,
72 | ) {
73 | const receivePc = new wrtc.RTCPeerConnection(RTCConfiguration);
74 | receivePcs[client.id] = receivePc;
75 | receivePc.onicecandidate = (ice: RTCPeerConnectionIceEvent) => {
76 | client.emit('receiverIcecandidate', { icecandidate: ice.candidate });
77 | };
78 | receivePc.ontrack = async (track: RTCTrackEvent) => {
79 | const roomName = [...client.rooms].filter(
80 | (roomName) => roomName !== client.id,
81 | )[0];
82 | streams[client.id]
83 | ? streams[client.id].push(track.streams[0])
84 | : (streams[client.id] = [track.streams[0]]);
85 | if (streams[client.id].length > 1) return;
86 | client.broadcast.to(roomName).emit('new-peer', { peerId: client.id });
87 | };
88 |
89 | await receivePc.setRemoteDescription(offer);
90 | const answer = await receivePc.createAnswer({
91 | offerToReceiveAudio: true,
92 | offerToReceiveVideo: true,
93 | });
94 | await receivePc.setLocalDescription(answer);
95 | client.emit('senderAnswer', { answer });
96 | }
97 |
98 | @SubscribeMessage('receiverOffer')
99 | async handleReceiverOffer(
100 | @ConnectedSocket() client: Socket,
101 | @MessageBody() { offer, targetId }: any,
102 | ) {
103 | const sendPc = new wrtc.RTCPeerConnection(RTCConfiguration);
104 | await sendPc.setRemoteDescription(offer);
105 | sendPcs[client.id]
106 | ? (sendPcs[client.id][targetId] = sendPc)
107 | : (sendPcs[client.id] = { [targetId]: sendPc });
108 |
109 | sendPc.onicecandidate = (ice: RTCPeerConnectionIceEvent) => {
110 | client.emit('senderIcecandidate', {
111 | icecandidate: ice.candidate,
112 | targetId,
113 | });
114 | };
115 |
116 | const streamToSend = streams[targetId][0];
117 | streamToSend.getTracks().forEach((track: MediaStreamTrack) => {
118 | sendPc.addTrack(track, streamToSend);
119 | });
120 |
121 | const answer = await sendPc.createAnswer({
122 | offerToReceiveAudio: false,
123 | offerToReceiveVideo: false,
124 | });
125 | await sendPc.setLocalDescription(answer);
126 | client.emit('receiverAnswer', { answer, targetId });
127 | }
128 |
129 | @SubscribeMessage('senderIcecandidate')
130 | async handleSenderIcecandidate(
131 | @ConnectedSocket() client: Socket,
132 | @MessageBody() { icecandidate }: any,
133 | ) {
134 | if (!icecandidate) return;
135 | const receivePc = receivePcs[client.id];
136 | await receivePc.addIceCandidate(icecandidate);
137 | }
138 |
139 | @SubscribeMessage('receiverIcecandidate')
140 | async handleReceiverIcecandidate(
141 | @ConnectedSocket() client: Socket,
142 | @MessageBody() { icecandidate, targetId }: any,
143 | ) {
144 | const sendPc = sendPcs[client.id]?.[targetId];
145 | if (!icecandidate || !sendPc) return;
146 | await sendPc.addIceCandidate(icecandidate);
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/media-server/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 |
--------------------------------------------------------------------------------
/media-server/src/sfu/sfu.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { ConfigModule } from '@nestjs/config';
3 | import { SfuGateway } from './sfu.gateway';
4 |
5 | @Module({
6 | imports: [
7 | ConfigModule.forRoot({
8 | envFilePath: `.env.${process.env.NODE_ENV}`,
9 | isGlobal: true,
10 | }),
11 | ],
12 | providers: [SfuGateway],
13 | })
14 | export class SfuModule {}
15 |
--------------------------------------------------------------------------------
/media-server/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 |
--------------------------------------------------------------------------------
/media-server/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 |
--------------------------------------------------------------------------------
/media-server/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 |
--------------------------------------------------------------------------------
/media-server/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
4 | }
5 |
--------------------------------------------------------------------------------
/media-server/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 |
--------------------------------------------------------------------------------
/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "TODY",
3 | "lockfileVersion": 2,
4 | "requires": true,
5 | "packages": {}
6 | }
7 |
--------------------------------------------------------------------------------