├── .github └── workflows │ └── cicd.yml ├── README.md ├── backend ├── .env.github.sample ├── .env.redis.sample ├── .env.sample ├── .env.timezone ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── README.md ├── nest-cli.json ├── package-lock.json ├── package.json ├── src │ ├── app.module.ts │ ├── app.service.ts │ ├── cam │ │ ├── cam-inner.service.ts │ │ ├── cam.controller.ts │ │ ├── cam.dto.ts │ │ ├── cam.entity.ts │ │ ├── cam.gateway.ts │ │ ├── cam.module.ts │ │ ├── cam.repository.ts │ │ └── cam.service.ts │ ├── channel │ │ ├── channel-form.dto.ts │ │ ├── channel.controller.ts │ │ ├── channel.entity.ts │ │ ├── channel.module.ts │ │ ├── channel.repository.ts │ │ ├── channel.service.ts │ │ └── dto │ │ │ └── channel-response.dto.ts │ ├── comment │ │ ├── comment.controller.ts │ │ ├── comment.dto.ts │ │ ├── comment.entity.ts │ │ ├── comment.module.ts │ │ ├── comment.repository.ts │ │ ├── comment.schema.ts │ │ └── comment.service.ts │ ├── common │ │ └── response-entity.ts │ ├── config │ │ ├── github.config.ts │ │ └── ormconfig.ts │ ├── emoticon │ │ ├── emoticon.entity.ts │ │ └── emoticon.module.ts │ ├── image │ │ ├── image.module.ts │ │ ├── image.service.spec.ts │ │ └── image.service.ts │ ├── login │ │ ├── login.controller.spec.ts │ │ ├── login.controller.ts │ │ ├── login.guard.ts │ │ ├── login.module.ts │ │ ├── login.service.spec.ts │ │ └── login.service.ts │ ├── main.ts │ ├── message.adapter.ts │ ├── message.gateway.ts │ ├── message │ │ ├── message.controller.ts │ │ ├── message.dto.ts │ │ ├── message.entity.ts │ │ ├── message.module.ts │ │ ├── message.repository.ts │ │ ├── message.scheme.ts │ │ └── message.service.ts │ ├── server │ │ ├── dto │ │ │ ├── request-server.dto.ts │ │ │ └── response-server-users.dto.ts │ │ ├── server.controller.ts │ │ ├── server.entity.ts │ │ ├── server.module.ts │ │ ├── server.repository.ts │ │ ├── server.schema.ts │ │ ├── server.service.spec.ts │ │ └── server.service.ts │ ├── session.ts │ ├── types │ │ ├── cam.ts │ │ └── session.d.ts │ ├── user-channel │ │ ├── user-channel.controller.ts │ │ ├── user-channel.entity.ts │ │ ├── user-channel.module.ts │ │ ├── user-channel.repository.ts │ │ └── user-channel.service.ts │ ├── user-server │ │ ├── dto │ │ │ └── user-server-list.dto.ts │ │ ├── user-server.controller.ts │ │ ├── user-server.entity.ts │ │ ├── user-server.module.ts │ │ ├── user-server.repository.ts │ │ ├── user-server.service.spec.ts │ │ └── user-server.service.ts │ └── user │ │ ├── user.controller.spec.ts │ │ ├── user.controller.ts │ │ ├── user.dto.ts │ │ ├── user.entity.ts │ │ ├── user.module.ts │ │ ├── user.repository.ts │ │ └── user.service.ts ├── test │ ├── app.e2e-spec.ts │ └── jest-e2e.json ├── tsconfig.build.json └── tsconfig.json └── frontend ├── .env ├── .env.development ├── .env.production ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── package-lock.json ├── package.json ├── patches └── peerjs+1.3.2.patch ├── public ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json ├── pepes │ ├── pepe-1.jpg │ ├── pepe-2.jpg │ ├── pepe-3.jpg │ ├── pepe-4.jpg │ └── pepe-5.jpg └── robots.txt ├── src ├── App.spec.tsx ├── App.tsx ├── assets │ ├── hmm.gif │ ├── icons │ │ ├── chat.svg │ │ ├── close.svg │ │ ├── copy.svg │ │ ├── cover_new.png │ │ ├── exit.svg │ │ ├── github.svg │ │ ├── hash.svg │ │ ├── identification.svg │ │ ├── listarrow.svg │ │ ├── mic-disabled.svg │ │ ├── mic.svg │ │ ├── plus.svg │ │ ├── presentation.svg │ │ ├── slack.svg │ │ ├── sparkle.svg │ │ ├── speech-disabled.svg │ │ ├── speech.svg │ │ ├── users.svg │ │ ├── video-disabled.svg │ │ └── video.svg │ ├── loading.png │ └── loading.svg ├── atoms │ └── user.ts ├── components │ ├── Cam │ │ ├── CamMain.tsx │ │ ├── CamStore.tsx │ │ ├── Menu │ │ │ ├── ButtonBar.tsx │ │ │ ├── ChattingTab.tsx │ │ │ ├── NicknameModal.tsx │ │ │ └── UserListTab.tsx │ │ ├── Page │ │ │ ├── CamDefaultPage.tsx │ │ │ ├── CamErrorPage.tsx │ │ │ ├── CamLoadingPage.tsx │ │ │ ├── CamNickNameInputPage.tsx │ │ │ ├── CamNotAvailablePage.tsx │ │ │ └── CamNotFoundPage.tsx │ │ ├── STT │ │ │ ├── STTScreen.tsx │ │ │ └── STTStore.tsx │ │ ├── Screen │ │ │ ├── ControlMenu.tsx │ │ │ ├── DefaultScreen.tsx │ │ │ ├── LocalUserScreen.tsx │ │ │ ├── MainScreen.tsx │ │ │ ├── StreamStatusIndicator.tsx │ │ │ └── UserScreen.tsx │ │ ├── SharedScreen │ │ │ ├── SharedScreen.tsx │ │ │ └── SharedScreenStore.tsx │ │ └── ToggleStore.tsx │ ├── Login │ │ ├── LoginCallback.tsx │ │ ├── LoginMain.tsx │ │ └── OAuthLogin.tsx │ ├── Main │ │ ├── AlertModal.tsx │ │ ├── Cam │ │ │ ├── List │ │ │ │ ├── CamList.tsx │ │ │ │ ├── CamListHeader.tsx │ │ │ │ └── CamListItem.tsx │ │ │ └── Modal │ │ │ │ ├── CamDeleteModal.tsx │ │ │ │ └── CreateCamModal.tsx │ │ ├── Channel │ │ │ ├── List │ │ │ │ ├── ChannelList.tsx │ │ │ │ ├── ChannelListHeader.tsx │ │ │ │ └── ChannelListItem.tsx │ │ │ └── Modal │ │ │ │ ├── AlertDeleteChannel.tsx │ │ │ │ ├── CreateChannelModal.tsx │ │ │ │ ├── JoinChannelModal.tsx │ │ │ │ ├── NoAuthModal.tsx │ │ │ │ ├── QuitChannelModal .tsx │ │ │ │ └── UpdateChannelModal.tsx │ │ ├── ContentsSection │ │ │ ├── ContentsSection.tsx │ │ │ ├── ContentsSectionCommon.tsx │ │ │ ├── MessageSection.tsx │ │ │ ├── NoChannelSection.tsx │ │ │ ├── NotFoundChannel.tsx │ │ │ ├── ServerJoinSection.tsx │ │ │ ├── ThreadSection.tsx │ │ │ └── UserListModal.tsx │ │ ├── Main.tsx │ │ ├── MainHeader.tsx │ │ ├── MainPage.tsx │ │ ├── MainSection.tsx │ │ ├── MainStore.tsx │ │ ├── RoomListSection.tsx │ │ ├── Server │ │ │ ├── List │ │ │ │ └── ServerListTab.tsx │ │ │ └── Modal │ │ │ │ ├── CreateServerModal.tsx │ │ │ │ ├── JoinServerModal.tsx │ │ │ │ ├── QuitServerModal.tsx │ │ │ │ ├── ServerDeleteCheckModal.tsx │ │ │ │ ├── ServerInfoModal.tsx │ │ │ │ └── ServerSettingModal.tsx │ │ └── ToggleStore.tsx │ └── core │ │ ├── Draggable.tsx │ │ ├── Dropdown.tsx │ │ ├── DropdownMenu.tsx │ │ ├── Loading.tsx │ │ ├── MainDropdown.tsx │ │ ├── MainModal.tsx │ │ ├── OkCancelModal.tsx │ │ └── RightClickDropdown.tsx ├── hooks │ ├── useSTT.ts │ └── useUserMedia.ts ├── index.tsx ├── react-app-env.d.ts ├── types │ ├── cam.ts │ ├── channel.ts │ ├── comment.ts │ ├── date.ts │ ├── dropdown.ts │ ├── fetch.ts │ ├── join-channel-request.ts │ ├── main.ts │ ├── message.ts │ ├── modal.ts │ ├── server.ts │ └── user.ts └── utils │ ├── fetchMethods.ts │ ├── getCurrentDate.ts │ ├── sharedScreenReceiver.ts │ ├── sharedScreenSender.ts │ ├── styledComponentFunc.ts │ └── svgIcons.ts └── tsconfig.json /backend/.env.github.sample: -------------------------------------------------------------------------------- 1 | CLIENT_ID_GITHUB=028b90a23e9500c30c28 2 | CLIENT_SECRET_GITHUB=SECRET 3 | CALLBACK_URL_GITHUB=http://localhost:3000/login/github 4 | -------------------------------------------------------------------------------- /backend/.env.redis.sample: -------------------------------------------------------------------------------- 1 | REDIS_HOST=127.0.0.1 2 | -------------------------------------------------------------------------------- /backend/.env.sample: -------------------------------------------------------------------------------- 1 | DATABASE_USER=scott 2 | DATABASE_PASSWORD=tiger 3 | DATABASE_NAME=boostcam 4 | DATABASE_HOST=localhost 5 | NCP_STORAGE_ACCESS_KEY= 6 | NCP_STORAGE_SECRET_KEY= 7 | NCP_STORAGE_BUCKET_NAME= 8 | NCP_STORAGE_REGION= 9 | SESSION=redis 10 | SESSION_SECRET=my_secret 11 | -------------------------------------------------------------------------------- /backend/.env.timezone: -------------------------------------------------------------------------------- 1 | TZ=Asia/Seoul 2 | -------------------------------------------------------------------------------- /backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: './tsconfig.json', 5 | sourceType: 'module', 6 | tsconfigRootDir: __dirname, 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 | # .env 31 | .env 32 | .env.github 33 | .env.redis 34 | 35 | # IDE - VSCode 36 | .vscode/* 37 | !.vscode/settings.json 38 | !.vscode/tasks.json 39 | !.vscode/launch.json 40 | !.vscode/extensions.json 41 | -------------------------------------------------------------------------------- /backend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "endOfLine": "auto" 5 | } 6 | -------------------------------------------------------------------------------- /backend/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /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": "nest start", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "test:cov": "jest --coverage", 20 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 21 | "test:e2e": "jest --config ./test/jest-e2e.json" 22 | }, 23 | "dependencies": { 24 | "@nestjs/common": "^8.0.0", 25 | "@nestjs/config": "^1.1.0", 26 | "@nestjs/core": "^8.0.0", 27 | "@nestjs/platform-express": "^8.0.0", 28 | "@nestjs/platform-socket.io": "^8.1.2", 29 | "@nestjs/swagger": "^5.1.5", 30 | "@nestjs/typeorm": "^8.0.2", 31 | "@nestjs/websockets": "^8.1.2", 32 | "aws-sdk": "^2.1033.0", 33 | "axios": "^0.24.0", 34 | "connect-redis": "^6.0.0", 35 | "express-session": "^1.17.2", 36 | "mysql2": "^2.3.3", 37 | "peer": "^0.6.1", 38 | "redis": "^3.1.2", 39 | "reflect-metadata": "^0.1.13", 40 | "rimraf": "^3.0.2", 41 | "rxjs": "^7.2.0", 42 | "socket.io": "^4.3.1", 43 | "swagger-ui-express": "^4.1.6", 44 | "typeorm": "^0.2.40" 45 | }, 46 | "devDependencies": { 47 | "@nestjs/cli": "^8.0.0", 48 | "@nestjs/schematics": "^8.0.0", 49 | "@nestjs/testing": "^8.0.0", 50 | "@types/connect-redis": "^0.0.17", 51 | "@types/express": "^4.17.13", 52 | "@types/express-session": "^1.17.4", 53 | "@types/jest": "^27.0.1", 54 | "@types/multer": "^1.4.7", 55 | "@types/node": "^16.0.0", 56 | "@types/redis": "^2.8.32", 57 | "@types/supertest": "^2.0.11", 58 | "@types/uuid": "^8.3.3", 59 | "@typescript-eslint/eslint-plugin": "^5.0.0", 60 | "@typescript-eslint/parser": "^5.0.0", 61 | "eslint": "^8.0.1", 62 | "eslint-config-prettier": "^8.3.0", 63 | "eslint-plugin-prettier": "^4.0.0", 64 | "jest": "^27.2.5", 65 | "prettier": "^2.3.2", 66 | "source-map-support": "^0.5.20", 67 | "supertest": "^6.1.3", 68 | "ts-jest": "^27.0.3", 69 | "ts-loader": "^9.2.3", 70 | "ts-node": "^10.0.0", 71 | "tsconfig-paths": "^3.10.1", 72 | "typescript": "^4.3.5" 73 | }, 74 | "jest": { 75 | "moduleFileExtensions": [ 76 | "js", 77 | "json", 78 | "ts" 79 | ], 80 | "rootDir": "src", 81 | "testRegex": ".*\\.spec\\.ts$", 82 | "transform": { 83 | "^.+\\.(t|j)s$": "ts-jest" 84 | }, 85 | "collectCoverageFrom": [ 86 | "**/*.(t|j)s" 87 | ], 88 | "coverageDirectory": "../coverage", 89 | "testEnvironment": "node" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /backend/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | 4 | import ormConfig from './config/ormconfig'; 5 | import { AppService } from './app.service'; 6 | import { CamModule } from './cam/cam.module'; 7 | import { TypeOrmModule } from '@nestjs/typeorm'; 8 | import { UserModule } from './user/user.module'; 9 | import { CommentModule } from './comment/comment.module'; 10 | import { ChannelModule } from './channel/channel.module'; 11 | import { MessageModule } from './message/message.module'; 12 | import { EmoticonModule } from './emoticon/emoticon.module'; 13 | import { ServerModule } from './server/server.module'; 14 | import { UserServerModule } from './user-server/user-server.module'; 15 | import { LoginModule } from './login/login.module'; 16 | import { UserChannelModule } from './user-channel/user-channel.module'; 17 | import { ImageModule } from './image/image.module'; 18 | import { MessageGateway } from './message.gateway'; 19 | import githubConfig from './config/github.config'; 20 | 21 | @Module({ 22 | imports: [ 23 | ConfigModule.forRoot({ 24 | load: [githubConfig], 25 | envFilePath: ['.env', '.env.github', '.env.redis', '.env.timezone'], 26 | isGlobal: true, 27 | }), 28 | TypeOrmModule.forRoot(ormConfig()), 29 | CamModule, 30 | UserModule, 31 | CommentModule, 32 | ChannelModule, 33 | MessageModule, 34 | EmoticonModule, 35 | ServerModule, 36 | UserServerModule, 37 | LoginModule, 38 | UserChannelModule, 39 | ImageModule, 40 | ], 41 | providers: [AppService, MessageGateway], 42 | }) 43 | export class AppModule {} 44 | -------------------------------------------------------------------------------- /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/cam/cam.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Post, 4 | Body, 5 | Get, 6 | Param, 7 | Session, 8 | Delete, 9 | ForbiddenException, 10 | UseGuards, 11 | ParseIntPipe, 12 | } from '@nestjs/common'; 13 | 14 | import ResponseEntity from '../common/response-entity'; 15 | import { LoginGuard } from '../login/login.guard'; 16 | import { ExpressSession } from '../types/session'; 17 | import { RequestCamDto } from './cam.dto'; 18 | import { Cam } from './cam.entity'; 19 | import { CamService } from './cam.service'; 20 | 21 | @Controller('/api/cam') 22 | export class CamController { 23 | constructor(private camService: CamService) {} 24 | 25 | @UseGuards(LoginGuard) 26 | @Post() 27 | async createCam( 28 | @Body() cam: RequestCamDto, 29 | @Session() session: ExpressSession, 30 | ): Promise> { 31 | cam.userId = session.user?.id; 32 | const savedCam = await this.camService.createCam(cam); 33 | 34 | return ResponseEntity.created(savedCam.id); 35 | } 36 | 37 | @Get('/:url') async checkCam( 38 | @Param('url') url: string, 39 | ): Promise> { 40 | const cam = await this.camService.findOne(url); 41 | 42 | return ResponseEntity.ok(cam); 43 | } 44 | 45 | @UseGuards(LoginGuard) 46 | @Delete('/:id') async deleteCam( 47 | @Param('id', new ParseIntPipe()) id: number, 48 | @Session() session: ExpressSession, 49 | ): Promise> { 50 | const cam = await this.camService.findOneById(id); 51 | const isOwner = session.user?.id == cam.ownerId; 52 | 53 | if (!isOwner) { 54 | throw new ForbiddenException(); 55 | } 56 | 57 | await this.camService.deleteCam(id); 58 | 59 | return ResponseEntity.noContent(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /backend/src/cam/cam.dto.ts: -------------------------------------------------------------------------------- 1 | import { Cam } from './cam.entity'; 2 | 3 | export type RequestCamDto = { 4 | name: string; 5 | serverId: number; 6 | userId: number | null; 7 | }; 8 | 9 | export class ResponseCamDto { 10 | id: number; 11 | name: string; 12 | url: string; 13 | constructor(id: number, name: string, url: string) { 14 | this.id = id; 15 | this.name = name; 16 | this.url = url; 17 | } 18 | 19 | static fromEntry(cam: Cam) { 20 | return new ResponseCamDto(cam.id, cam.name, cam.url); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /backend/src/cam/cam.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | Column, 4 | PrimaryGeneratedColumn, 5 | ManyToOne, 6 | RelationId, 7 | } from 'typeorm'; 8 | 9 | import { Server } from '../server/server.entity'; 10 | import { User } from '../user/user.entity'; 11 | 12 | @Entity() 13 | export class Cam { 14 | @PrimaryGeneratedColumn() 15 | id: number; 16 | 17 | @Column() 18 | name: string; 19 | 20 | @Column() 21 | url: string; 22 | 23 | @ManyToOne(() => Server, { onDelete: 'CASCADE' }) 24 | server: Server; 25 | 26 | @ManyToOne(() => User, { onDelete: 'CASCADE' }) 27 | owner: User; 28 | 29 | @RelationId((cam: Cam) => cam.server) 30 | serverId: number; 31 | 32 | @RelationId((cam: Cam) => cam.owner) 33 | ownerId: number; 34 | } 35 | -------------------------------------------------------------------------------- /backend/src/cam/cam.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { CamGateway } from './cam.gateway'; 5 | import { CamInnerService } from './cam-inner.service'; 6 | import { CamController } from './cam.controller'; 7 | import { CamService } from './cam.service'; 8 | import { Cam } from './cam.entity'; 9 | import { ServerRepository } from '../server/server.repository'; 10 | import { CamRepository } from './cam.repository'; 11 | import { Server } from '../server/server.entity'; 12 | import { UserRepository } from '../user/user.repository'; 13 | import { User } from '../user/user.entity'; 14 | 15 | @Module({ 16 | imports: [ 17 | TypeOrmModule.forFeature([ 18 | Cam, 19 | Server, 20 | CamRepository, 21 | ServerRepository, 22 | User, 23 | UserRepository, 24 | ]), 25 | ], 26 | providers: [CamGateway, CamInnerService, CamService], 27 | controllers: [CamController], 28 | exports: [CamService], 29 | }) 30 | export class CamModule {} 31 | -------------------------------------------------------------------------------- /backend/src/cam/cam.repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, Repository } from 'typeorm'; 2 | 3 | import { Cam } from './cam.entity'; 4 | 5 | @EntityRepository(Cam) 6 | export class CamRepository extends Repository { 7 | findByServerId(serverId: number) { 8 | return this.createQueryBuilder('cam') 9 | .where('cam.serverId = :serverId', { 10 | serverId, 11 | }) 12 | .getMany(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /backend/src/cam/cam.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | ForbiddenException, 4 | Injectable, 5 | NotFoundException, 6 | } from '@nestjs/common'; 7 | import { v4 } from 'uuid'; 8 | 9 | import { ServerRepository } from '../server/server.repository'; 10 | import { RequestCamDto, ResponseCamDto } from './cam.dto'; 11 | import { Cam } from './cam.entity'; 12 | import { CamRepository } from './cam.repository'; 13 | import { CamInnerService } from './cam-inner.service'; 14 | import { UserRepository } from '../user/user.repository'; 15 | 16 | @Injectable() 17 | export class CamService { 18 | constructor( 19 | private camRepository: CamRepository, 20 | private serverRepository: ServerRepository, 21 | private userRepository: UserRepository, 22 | private readonly camInnerService: CamInnerService, 23 | ) { 24 | this.camRepository.clear(); 25 | } 26 | 27 | async findOne(url: string): Promise { 28 | const cam = await this.camRepository.findOne({ url: url }); 29 | 30 | if (!cam) { 31 | throw new NotFoundException(); 32 | } 33 | 34 | const available = this.camInnerService.checkRoomAvailable(url); 35 | 36 | if (!available) { 37 | throw new ForbiddenException(); 38 | } 39 | 40 | return cam; 41 | } 42 | 43 | async findOneById(id: number): Promise { 44 | const cam = await this.camRepository.findOne({ id: id }); 45 | 46 | if (!cam) { 47 | throw new NotFoundException(); 48 | } 49 | 50 | return cam; 51 | } 52 | 53 | async createCam(cam: RequestCamDto): Promise { 54 | const camEntity = this.camRepository.create(); 55 | const server = await this.serverRepository.findOne({ 56 | id: cam.serverId, 57 | }); 58 | 59 | if (!server) { 60 | throw new BadRequestException(); 61 | } 62 | 63 | const user = await this.userRepository.findOne({ id: cam?.userId }); 64 | 65 | if (!cam.userId || !user) { 66 | throw new ForbiddenException(); 67 | } 68 | 69 | camEntity.name = cam.name; 70 | camEntity.server = server; 71 | camEntity.url = v4(); 72 | camEntity.owner = user; 73 | 74 | const savedCam = await this.camRepository.save(camEntity); 75 | this.camInnerService.createRoom(camEntity.url); 76 | 77 | return savedCam; 78 | } 79 | 80 | async deleteCam(id: number): Promise { 81 | const camEntity = await this.camRepository.findOne({ id: id }); 82 | 83 | if (!camEntity) { 84 | throw new BadRequestException(); 85 | } 86 | 87 | this.camInnerService.deleteRoom(camEntity.url); 88 | 89 | await this.camRepository.delete({ id: camEntity.id }); 90 | } 91 | 92 | async getCamList(serverId: number): Promise { 93 | const res = await this.camRepository.findByServerId(serverId); 94 | return res.map((entry) => ResponseCamDto.fromEntry(entry)); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /backend/src/channel/channel-form.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class ChannelFormDto { 4 | @ApiProperty() 5 | name: string; 6 | 7 | @ApiProperty() 8 | description: string; 9 | 10 | @ApiProperty() 11 | serverId: number; 12 | } 13 | -------------------------------------------------------------------------------- /backend/src/channel/channel.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | Param, 7 | Post, 8 | Patch, 9 | Session, 10 | UseGuards, 11 | ParseIntPipe, 12 | } from '@nestjs/common'; 13 | import { LoginGuard } from '../login/login.guard'; 14 | import { ExpressSession } from '../types/session'; 15 | 16 | import { ChannelService } from './channel.service'; 17 | import { Channel } from './channel.entity'; 18 | import { ChannelFormDto } from './channel-form.dto'; 19 | import { UserChannelService } from '../user-channel/user-channel.service'; 20 | import ResponseEntity from '../common/response-entity'; 21 | 22 | @Controller('/api/channels') 23 | @UseGuards(LoginGuard) 24 | export class ChannelController { 25 | constructor( 26 | private channelService: ChannelService, 27 | private userChannelService: UserChannelService, 28 | ) { 29 | this.channelService = channelService; 30 | this.userChannelService = userChannelService; 31 | } 32 | @Get(':channelId') async findOne( 33 | @Param('channelId', new ParseIntPipe()) id: number, 34 | ): Promise> { 35 | const foundChannel = await this.channelService.findOne(id); 36 | return ResponseEntity.ok(foundChannel); 37 | } 38 | 39 | @Get(':channelId/auth') async checkAuthority( 40 | @Param('channelId', new ParseIntPipe()) id: number, 41 | @Session() session: ExpressSession, 42 | ): Promise> { 43 | const foundChannel = await this.channelService.findOne(id); 44 | return ResponseEntity.ok(foundChannel.ownerId === session.user.id); 45 | } 46 | 47 | @Post() async saveChannel( 48 | @Body() channel: ChannelFormDto, 49 | @Session() session: ExpressSession, 50 | ): Promise> { 51 | const savedChannel = await this.channelService.createChannel( 52 | channel, 53 | session.user.id, 54 | ); 55 | await this.userChannelService.addNewChannel(savedChannel, session.user.id); 56 | return ResponseEntity.ok(savedChannel); 57 | } 58 | @Patch(':channelId') async updateChannel( 59 | @Param('channelId', new ParseIntPipe()) id: number, 60 | @Body() channel: ChannelFormDto, 61 | @Session() session: ExpressSession, 62 | ): Promise> { 63 | const changedChannel = await this.channelService.updateChannel( 64 | id, 65 | channel, 66 | session.user.id, 67 | ); 68 | return ResponseEntity.ok(changedChannel); 69 | } 70 | 71 | @Delete(':channelId') async deleteChannel( 72 | @Param('channelId', new ParseIntPipe()) id: number, 73 | ): Promise> { 74 | await this.channelService.deleteChannel(id); 75 | return ResponseEntity.noContent(); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /backend/src/channel/channel.entity.ts: -------------------------------------------------------------------------------- 1 | import { Server } from '../server/server.entity'; 2 | import { 3 | Entity, 4 | Column, 5 | PrimaryGeneratedColumn, 6 | ManyToOne, 7 | RelationId, 8 | OneToMany, 9 | } from 'typeorm'; 10 | import { User } from '../user/user.entity'; 11 | import { UserChannel } from '../user-channel/user-channel.entity'; 12 | 13 | @Entity() 14 | export class Channel { 15 | @PrimaryGeneratedColumn() 16 | id: number; 17 | 18 | @Column() 19 | name: string; 20 | 21 | @Column() 22 | description: string; 23 | 24 | @ManyToOne(() => Server, { onDelete: 'CASCADE' }) 25 | server: Server; 26 | 27 | @ManyToOne(() => User) 28 | owner: User; 29 | 30 | @OneToMany(() => UserChannel, (userChannels) => userChannels.channel) 31 | userChannels: UserChannel[]; 32 | 33 | @RelationId((channel: Channel) => channel.owner) 34 | ownerId: number; 35 | 36 | @RelationId((channel: Channel) => channel.server) 37 | serverId: number; 38 | 39 | static newInstance( 40 | name: string, 41 | description: string, 42 | server: Server, 43 | owner: User, 44 | ) { 45 | const newChannel = new Channel(); 46 | newChannel.name = name; 47 | newChannel.description = description; 48 | newChannel.server = server; 49 | newChannel.owner = owner; 50 | return newChannel; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /backend/src/channel/channel.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { Channel } from './channel.entity'; 5 | import { ChannelController } from './channel.controller'; 6 | import { ChannelService } from './channel.service'; 7 | import { Server } from '../server/server.entity'; 8 | import { UserChannelService } from '../user-channel/user-channel.service'; 9 | import { UserChannelRepository } from '../user-channel/user-channel.repository'; 10 | import { UserRepository } from '../user/user.repository'; 11 | import { ChannelRepository } from './channel.repository'; 12 | import { User } from '../user/user.entity'; 13 | 14 | @Module({ 15 | imports: [ 16 | TypeOrmModule.forFeature([ 17 | Channel, 18 | Server, 19 | User, 20 | UserChannelRepository, 21 | UserRepository, 22 | ChannelRepository, 23 | ]), 24 | ], 25 | providers: [ChannelService, UserChannelService], 26 | controllers: [ChannelController], 27 | }) 28 | export class ChannelModule {} 29 | -------------------------------------------------------------------------------- /backend/src/channel/channel.repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, Repository } from 'typeorm'; 2 | import { Channel } from './channel.entity'; 3 | 4 | @EntityRepository(Channel) 5 | export class ChannelRepository extends Repository { 6 | getAllList() { 7 | return this.createQueryBuilder('channel').getMany(); 8 | } 9 | 10 | getChannelListByServerId(serverId: number) { 11 | return this.createQueryBuilder('channel') 12 | .where('channel.serverId = :serverId', { serverId }) 13 | .getMany(); 14 | } 15 | 16 | getJoinedChannelList(userId: number, serverId: number) { 17 | return this.createQueryBuilder('channel') 18 | .leftJoin('channel.userChannels', 'user_channel') 19 | .where('channel.serverId = :serverId', { serverId }) 20 | .andWhere('user_channel.userId = :userId', { userId }) 21 | .getMany(); 22 | } 23 | 24 | getNotJoinedChannelList(userId: number, serverId: number) { 25 | return this.createQueryBuilder('channel') 26 | .where('channel.serverId = :serverId', { serverId }) 27 | .andWhere( 28 | 'channel.id NOT IN (select uc.channelId from user_channel uc where uc.userId = :userId)', 29 | { userId }, 30 | ) 31 | .getMany(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /backend/src/channel/channel.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm/index'; 4 | 5 | import { ChannelFormDto } from './channel-form.dto'; 6 | import { Channel } from './channel.entity'; 7 | import { Server } from '../server/server.entity'; 8 | import { ChannelRepository } from './channel.repository'; 9 | import { UserRepository } from '../user/user.repository'; 10 | import { User } from '../user/user.entity'; 11 | 12 | @Injectable() 13 | export class ChannelService { 14 | constructor( 15 | @InjectRepository(Channel) private channelRepository: ChannelRepository, 16 | @InjectRepository(User) private userRepository: UserRepository, 17 | @InjectRepository(Server) private serverRepository: Repository, 18 | ) {} 19 | async findOne(id: number): Promise { 20 | const response = await this.channelRepository.findOne( 21 | { id: id }, 22 | { relations: ['server', 'owner'] }, 23 | ); 24 | if (!response) throw new NotFoundException('서버가 존재하지 않습니다!'); 25 | return response; 26 | } 27 | async createChannel( 28 | channel: ChannelFormDto, 29 | userId: number, 30 | ): Promise { 31 | const channelEntity = await this.createChannelEntity(channel, userId); 32 | const savedChannel = await this.channelRepository.save(channelEntity); 33 | 34 | return savedChannel; 35 | } 36 | 37 | async updateChannel( 38 | id: number, 39 | channel: ChannelFormDto, 40 | userId: number, 41 | ): Promise { 42 | const channelEntity = await this.createChannelEntity(channel, userId); 43 | await this.channelRepository.update(id, channelEntity); 44 | return channelEntity; 45 | } 46 | 47 | async deleteChannel(id: number): Promise { 48 | await this.channelRepository.delete({ id: id }); 49 | } 50 | 51 | async createChannelEntity( 52 | channel: ChannelFormDto, 53 | userId: number, 54 | ): Promise { 55 | const { name, description, serverId } = channel; 56 | const server = await this.serverRepository.findOne({ 57 | id: serverId, 58 | }); 59 | const user = await this.userRepository.findOne({ 60 | id: userId, 61 | }); 62 | 63 | if (!server) throw new NotFoundException('서버가 존재하지 않습니다!'); 64 | if (!user) throw new NotFoundException('사용자가 존재하지 않습니다!'); 65 | 66 | const newChannel = Channel.newInstance(name, description, server, user); 67 | return newChannel; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /backend/src/channel/dto/channel-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { Channel } from '../channel.entity'; 2 | 3 | class ChannelResponseDto { 4 | id: number; 5 | name: string; 6 | description: string; 7 | ownerId: number; 8 | serverId: number; 9 | 10 | constructor( 11 | id: number, 12 | name: string, 13 | description: string, 14 | ownerId: number, 15 | serverId: number, 16 | ) { 17 | this.id = id; 18 | this.name = name; 19 | this.description = description; 20 | this.ownerId = ownerId; 21 | this.serverId = serverId; 22 | } 23 | 24 | static fromEntity(channel: Channel) { 25 | return new ChannelResponseDto( 26 | channel.id, 27 | channel.name, 28 | channel.description, 29 | channel.ownerId, 30 | channel.serverId, 31 | ); 32 | } 33 | } 34 | 35 | export default ChannelResponseDto; 36 | -------------------------------------------------------------------------------- /backend/src/comment/comment.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | ParseIntPipe, 5 | Query, 6 | UseGuards, 7 | } from '@nestjs/common'; 8 | import { ApiExtraModels, ApiOkResponse } from '@nestjs/swagger'; 9 | import ResponseEntity from '../common/response-entity'; 10 | import { LoginGuard } from '../login/login.guard'; 11 | import { CommentDto } from './comment.dto'; 12 | import { commentDtoSchema } from './comment.schema'; 13 | import { CommentService } from './comment.service'; 14 | 15 | @Controller('/api/comments') 16 | @ApiExtraModels(ResponseEntity) 17 | @ApiExtraModels(CommentDto) 18 | @UseGuards(LoginGuard) 19 | export class CommentController { 20 | constructor(private commentService: CommentService) {} 21 | 22 | @ApiOkResponse(commentDtoSchema) 23 | @Get() 24 | async findCommentsByMessageId( 25 | @Query('messageId', new ParseIntPipe()) messageId: number, 26 | ) { 27 | const comments = await this.commentService.findCommentsByMessageId( 28 | messageId, 29 | ); 30 | return ResponseEntity.ok(comments); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /backend/src/comment/comment.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { UserDto } from '../user/user.dto'; 3 | import { Comment } from './comment.entity'; 4 | 5 | export class CommentDto { 6 | @ApiProperty() 7 | id: number; 8 | 9 | @ApiProperty() 10 | contents: string; 11 | 12 | @ApiProperty() 13 | createdAt: Date; 14 | 15 | @ApiProperty() 16 | sender: UserDto; 17 | 18 | @ApiProperty() 19 | messageId: number; 20 | 21 | static newInstance( 22 | id: number, 23 | contents: string, 24 | createdAt: Date, 25 | sender: UserDto, 26 | messageId: number, 27 | ) { 28 | const newInstance = new CommentDto(); 29 | newInstance.id = id; 30 | newInstance.contents = contents; 31 | newInstance.createdAt = createdAt; 32 | newInstance.sender = sender; 33 | newInstance.messageId = messageId; 34 | return newInstance; 35 | } 36 | 37 | static fromEntity(comment: Comment) { 38 | return CommentDto.newInstance( 39 | comment.id, 40 | comment.contents, 41 | comment.createdAt, 42 | UserDto.fromEntity(comment.sender), 43 | comment.messageId, 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /backend/src/comment/comment.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | Column, 4 | PrimaryGeneratedColumn, 5 | ManyToOne, 6 | CreateDateColumn, 7 | RelationId, 8 | } from 'typeorm'; 9 | import { Message } from '../message/message.entity'; 10 | import { User } from '../user/user.entity'; 11 | 12 | @Entity() 13 | export class Comment { 14 | @PrimaryGeneratedColumn() 15 | id: number; 16 | 17 | @Column() 18 | contents: string; 19 | 20 | @CreateDateColumn() 21 | createdAt: Date; 22 | 23 | @ManyToOne(() => User) 24 | sender: User; 25 | 26 | @RelationId((comment: Comment) => comment.sender) 27 | senderId: number; 28 | 29 | @ManyToOne(() => Message, { onDelete: 'CASCADE' }) 30 | message: Message; 31 | 32 | @RelationId((comment: Comment) => comment.message) 33 | messageId: number; 34 | 35 | static newInstance(sender: User, message: Message, contents: string) { 36 | const newInstance = new Comment(); 37 | newInstance.contents = contents; 38 | newInstance.sender = sender; 39 | newInstance.message = message; 40 | return newInstance; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /backend/src/comment/comment.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { Comment } from './comment.entity'; 4 | import { CommentRepository } from './comment.repository'; 5 | import { CommentService } from './comment.service'; 6 | import { CommentController } from './comment.controller'; 7 | import { MessageRepository } from '../message/message.repository'; 8 | import { User } from '../user/user.entity'; 9 | import { UserServerModule } from '../user-server/user-server.module'; 10 | 11 | @Module({ 12 | imports: [ 13 | TypeOrmModule.forFeature([ 14 | Comment, 15 | CommentRepository, 16 | MessageRepository, 17 | User, 18 | ]), 19 | UserServerModule, 20 | ], 21 | providers: [CommentService], 22 | controllers: [CommentController], 23 | exports: [CommentService], 24 | }) 25 | export class CommentModule {} 26 | -------------------------------------------------------------------------------- /backend/src/comment/comment.repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, Repository } from 'typeorm'; 2 | import { Comment } from './comment.entity'; 3 | 4 | @EntityRepository(Comment) 5 | export class CommentRepository extends Repository { 6 | async findByMessageId(messageId: number): Promise { 7 | return this.createQueryBuilder('comment') 8 | .innerJoinAndSelect('comment.sender', 'user') 9 | .where('comment.messageId = :messageId', { messageId }) 10 | .orderBy('comment.id') 11 | .getMany(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /backend/src/comment/comment.schema.ts: -------------------------------------------------------------------------------- 1 | import { getSchemaPath } from '@nestjs/swagger'; 2 | 3 | import ResponseEntity from '../common/response-entity'; 4 | import { CommentDto } from './comment.dto'; 5 | 6 | export const commentDtoSchema = { 7 | schema: { 8 | allOf: [ 9 | { $ref: getSchemaPath(ResponseEntity) }, 10 | { 11 | properties: { 12 | data: { 13 | type: 'array', 14 | items: { $ref: getSchemaPath(CommentDto) }, 15 | }, 16 | }, 17 | }, 18 | ], 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /backend/src/comment/comment.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | import { MessageRepository } from '../message/message.repository'; 5 | import { UserServerService } from '../user-server/user-server.service'; 6 | import { User } from '../user/user.entity'; 7 | import { CommentDto } from './comment.dto'; 8 | import { Comment } from './comment.entity'; 9 | import { CommentRepository } from './comment.repository'; 10 | 11 | @Injectable() 12 | export class CommentService { 13 | constructor( 14 | private commentRepository: CommentRepository, 15 | private messageRepository: MessageRepository, 16 | private readonly userServerService: UserServerService, 17 | @InjectRepository(User) private userRepository: Repository, 18 | ) {} 19 | 20 | async sendComment( 21 | senderId: number, 22 | channelId: number, 23 | messageId: number, 24 | contents: string, 25 | ): Promise { 26 | const sender = await this.userRepository.findOne(senderId); 27 | const message = await this.messageRepository.findOne(messageId); 28 | 29 | if (!message) { 30 | throw new NotFoundException('메시지가 존재하지 않습니다.'); 31 | } 32 | 33 | await this.userServerService.checkUserChannelAccess(senderId, channelId); 34 | 35 | const newComment = await this.commentRepository.save( 36 | Comment.newInstance(sender, message, contents), 37 | ); 38 | 39 | return CommentDto.fromEntity(newComment); 40 | } 41 | 42 | async findCommentsByMessageId(messageId: number) { 43 | const comments = await this.commentRepository.findByMessageId(messageId); 44 | return comments.map(CommentDto.fromEntity); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /backend/src/common/response-entity.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | class ResponseEntity { 4 | @ApiProperty() 5 | statusCode: number; 6 | @ApiProperty() 7 | message: string; 8 | data: T; 9 | constructor(statusCode: number, message: string, data: T) { 10 | this.statusCode = statusCode; 11 | this.message = message; 12 | this.data = data; 13 | } 14 | 15 | static ok(data: T): ResponseEntity { 16 | return new ResponseEntity(200, null, data); 17 | } 18 | 19 | static created(id: number): ResponseEntity { 20 | return new ResponseEntity(201, null, id); 21 | } 22 | static noContent(): ResponseEntity { 23 | return new ResponseEntity(204, null, null); 24 | } 25 | } 26 | 27 | export default ResponseEntity; 28 | -------------------------------------------------------------------------------- /backend/src/config/github.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | interface GitHubConfig { 4 | clientID: string; 5 | clientSecret: string; 6 | callbackURL: string; 7 | } 8 | 9 | export default registerAs( 10 | 'github', 11 | (): GitHubConfig => ({ 12 | clientID: process.env.CLIENT_ID_GITHUB, 13 | clientSecret: process.env.CLIENT_SECRET_GITHUB, 14 | callbackURL: process.env.CALLBACK_URL_GITHUB, 15 | }), 16 | ); 17 | -------------------------------------------------------------------------------- /backend/src/config/ormconfig.ts: -------------------------------------------------------------------------------- 1 | import { TypeOrmModuleOptions } from '@nestjs/typeorm'; 2 | 3 | const ormConfig = (): TypeOrmModuleOptions => { 4 | return { 5 | type: 'mysql', 6 | host: process.env.DATABASE_HOST, 7 | port: 3306, 8 | username: process.env.DATABASE_USER, 9 | password: process.env.DATABASE_PASSWORD, 10 | database: process.env.DATABASE_NAME, 11 | entities: ['dist/**/*.entity{.ts,.js}'], 12 | synchronize: true, 13 | logging: true, 14 | }; 15 | }; 16 | 17 | export default ormConfig; 18 | -------------------------------------------------------------------------------- /backend/src/emoticon/emoticon.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; 2 | 3 | @Entity() 4 | export class Emoticon { 5 | @PrimaryGeneratedColumn() 6 | id: number; 7 | 8 | @Column() 9 | name: string; 10 | 11 | @Column() 12 | url: string; 13 | } 14 | -------------------------------------------------------------------------------- /backend/src/emoticon/emoticon.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { Emoticon } from './emoticon.entity'; 5 | 6 | @Module({ 7 | imports: [TypeOrmModule.forFeature([Emoticon])], 8 | }) 9 | export class EmoticonModule {} 10 | -------------------------------------------------------------------------------- /backend/src/image/image.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ImageService } from './image.service'; 3 | 4 | @Module({ 5 | providers: [ImageService], 6 | exports: [ImageService], 7 | }) 8 | export class ImageModule {} 9 | -------------------------------------------------------------------------------- /backend/src/image/image.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ImageService } from './image.service'; 3 | 4 | describe('ImageService', () => { 5 | let service: ImageService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [ImageService], 10 | }).compile(); 11 | 12 | service = module.get(ImageService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /backend/src/image/image.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, Injectable } from '@nestjs/common'; 2 | 3 | import * as AWS from 'aws-sdk'; 4 | 5 | @Injectable() 6 | export class ImageService { 7 | async uploadFile(file: Express.Multer.File) { 8 | const endpoint = new AWS.Endpoint('https://kr.object.ncloudstorage.com'); 9 | 10 | const S3 = new AWS.S3({ 11 | endpoint: endpoint, 12 | region: process.env.NCP_STORAGE_REGION, 13 | credentials: { 14 | accessKeyId: process.env.NCP_STORAGE_ACCESS_KEY, 15 | secretAccessKey: process.env.NCP_STORAGE_SECRET_KEY, 16 | }, 17 | }); 18 | 19 | const bucket_name = process.env.NCP_STORAGE_BUCKET_NAME; 20 | const object_name = `${Date.now().toString()}-${file.originalname}`; 21 | const options = { 22 | partSize: 5 * 1024 * 1024, 23 | }; 24 | 25 | try { 26 | return await S3.upload( 27 | { 28 | Bucket: bucket_name, 29 | Body: file.buffer, 30 | Key: object_name, 31 | ACL: 'public-read', 32 | }, 33 | options, 34 | ).promise(); 35 | } catch (error) { 36 | throw new HttpException('서버 아이콘 업로드에 실패했습니다.', 403); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /backend/src/login/login.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Repository } from 'typeorm'; 2 | import { LoginController } from './login.controller'; 3 | import { LoginService } from './login.service'; 4 | 5 | import { User } from '../user/user.entity'; 6 | 7 | describe('LoginController', () => { 8 | let controller: LoginController; 9 | let service: LoginService; 10 | let repository: Repository; 11 | 12 | beforeEach(async () => { 13 | const githubConfig = { 14 | clientID: 'string', 15 | clientSecret: 'string', 16 | callbackURL: 'string', 17 | }; 18 | repository = null; 19 | service = new LoginService(githubConfig, repository); 20 | controller = new LoginController(service); 21 | }); 22 | 23 | it('should be defined', () => { 24 | expect(controller).toBeDefined(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /backend/src/login/login.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Query, Session } from '@nestjs/common'; 2 | import { ApiOkResponse } from '@nestjs/swagger'; 3 | import ResponseEntity from '../common/response-entity'; 4 | import { ExpressSession } from '../types/session'; 5 | import { UserDto } from '../user/user.dto'; 6 | 7 | import { LoginService } from './login.service'; 8 | 9 | @Controller('/api/login') 10 | export class LoginController { 11 | constructor(private loginService: LoginService) {} 12 | 13 | @ApiOkResponse({ type: UserDto }) 14 | @Get('/github') 15 | async githubLogin( 16 | @Session() 17 | session: ExpressSession, 18 | @Query('code') code: string, 19 | ): Promise> { 20 | const user = await this.loginService.githubLogin(code); 21 | session.user = user; 22 | session.save(); 23 | return ResponseEntity.noContent(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /backend/src/login/login.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; 2 | import { Request } from 'express'; 3 | 4 | @Injectable() 5 | export class LoginGuard implements CanActivate { 6 | canActivate(context: ExecutionContext): boolean { 7 | const req: Request = context.switchToHttp().getRequest(); 8 | const session = req.session; 9 | if (session.user) { 10 | return true; 11 | } 12 | return false; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /backend/src/login/login.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | 4 | import { UserModule } from '../user/user.module'; 5 | import { LoginController } from './login.controller'; 6 | import { LoginGuard } from './login.guard'; 7 | import { LoginService } from './login.service'; 8 | 9 | @Module({ 10 | imports: [ConfigModule, UserModule], 11 | controllers: [LoginController], 12 | providers: [LoginService, LoginGuard], 13 | exports: [LoginGuard], 14 | }) 15 | export class LoginModule {} 16 | -------------------------------------------------------------------------------- /backend/src/login/login.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../user/user.entity'; 2 | import { Repository } from 'typeorm'; 3 | import { LoginService } from './login.service'; 4 | 5 | describe('LoginService', () => { 6 | let service: LoginService; 7 | let userRepository: Repository; 8 | 9 | beforeEach(async () => { 10 | const githubConfig = { 11 | clientID: 'string', 12 | clientSecret: 'string', 13 | callbackURL: 'string', 14 | }; 15 | userRepository = null; 16 | service = new LoginService(githubConfig, userRepository); 17 | }); 18 | 19 | it('should be defined', () => { 20 | expect(service).toBeDefined(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /backend/src/login/login.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Inject, 3 | Injectable, 4 | InternalServerErrorException, 5 | } from '@nestjs/common'; 6 | import { ConfigType } from '@nestjs/config'; 7 | import { InjectRepository } from '@nestjs/typeorm'; 8 | import axios from 'axios'; 9 | import { Repository } from 'typeorm'; 10 | 11 | import { User } from '../user/user.entity'; 12 | 13 | import githubConfig from '../config/github.config'; 14 | 15 | type GitHubUser = { 16 | githubId: number; 17 | nickname: string; 18 | profile: string; 19 | }; 20 | 21 | @Injectable() 22 | export class LoginService { 23 | constructor( 24 | @Inject(githubConfig.KEY) private config: ConfigType, 25 | @InjectRepository(User) private userRepository: Repository, 26 | ) {} 27 | async githubLogin(code: string): Promise { 28 | try { 29 | const accessToken = await this.getAccessToken(code); 30 | const githubUser = await this.getGitHubUser(accessToken); 31 | 32 | let user = await this.userRepository.findOne({ 33 | where: { githubId: githubUser.githubId }, 34 | }); 35 | 36 | if (!user) { 37 | user = await this.registerByGitHubUser(githubUser); 38 | } 39 | return user; 40 | } catch (error) { 41 | throw new InternalServerErrorException(); 42 | } 43 | } 44 | 45 | private async getAccessToken(code: string): Promise { 46 | const accessTokenResponse = await axios.post( 47 | 'https://github.com/login/oauth/access_token', 48 | { 49 | client_id: this.config.clientID, 50 | client_secret: this.config.clientSecret, 51 | redirect_uri: this.config.callbackURL, 52 | code, 53 | }, 54 | { 55 | headers: { Accept: 'application/json' }, 56 | }, 57 | ); 58 | const { access_token: accessToken } = accessTokenResponse.data; 59 | return accessToken; 60 | } 61 | 62 | private async getGitHubUser(accessToken: string): Promise { 63 | const githubUserResponse = await axios.get('https://api.github.com/user', { 64 | headers: { Authorization: `token ${accessToken}` }, 65 | }); 66 | 67 | const { id, avatar_url, login } = githubUserResponse.data; 68 | return { 69 | githubId: id, 70 | nickname: login, 71 | profile: avatar_url, 72 | }; 73 | } 74 | 75 | private async registerByGitHubUser(githubUser: GitHubUser) { 76 | const { githubId, nickname, profile } = githubUser; 77 | const newUser = this.userRepository.create(); 78 | newUser.githubId = githubId; 79 | newUser.nickname = nickname; 80 | newUser.profile = profile; 81 | return await this.userRepository.save(newUser); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /backend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { ExpressPeerServer } from 'peer'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; 5 | 6 | import { AppModule } from './app.module'; 7 | import { createSessionMiddleware } from './session'; 8 | import { MessageSessionAdapter } from './message.adapter'; 9 | 10 | async function bootstrap() { 11 | const app = await NestFactory.create(AppModule); 12 | const server = app.getHttpServer(); 13 | const peerServer = ExpressPeerServer(server); 14 | const configService = app.get(ConfigService); 15 | const session = createSessionMiddleware(configService); 16 | 17 | const config = new DocumentBuilder() 18 | .setTitle('boostCam API') 19 | .setDescription('boostCam API description') 20 | .build(); 21 | const document = SwaggerModule.createDocument(app, config); 22 | SwaggerModule.setup('api', app, document); 23 | 24 | app.use(session); 25 | app.useWebSocketAdapter(new MessageSessionAdapter(app, session)); 26 | app.use('/peerjs', peerServer); 27 | await app.listen(9000); 28 | } 29 | bootstrap(); 30 | -------------------------------------------------------------------------------- /backend/src/message.adapter.ts: -------------------------------------------------------------------------------- 1 | import { IoAdapter } from '@nestjs/platform-socket.io'; 2 | import { Server } from 'socket.io'; 3 | import { INestApplication } from '@nestjs/common'; 4 | 5 | export class MessageSessionAdapter extends IoAdapter { 6 | private sessionMiddleware; 7 | 8 | constructor(app: INestApplication, sessionMiddleware) { 9 | super(app); 10 | this.sessionMiddleware = sessionMiddleware; 11 | } 12 | 13 | createIOServer(port: number, options?: any): any { 14 | const server: Server = super.createIOServer(port, options); 15 | server.use((socket, next) => { 16 | this.sessionMiddleware(socket.request, {}, next); 17 | }); 18 | 19 | return server; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /backend/src/message.gateway.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SubscribeMessage, 3 | WebSocketGateway, 4 | WebSocketServer, 5 | } from '@nestjs/websockets'; 6 | import { Server, Socket } from 'socket.io'; 7 | import { CommentService } from './comment/comment.service'; 8 | import { MessageDto } from './message/message.dto'; 9 | import { MessageService } from './message/message.service'; 10 | import { ExpressSession } from './types/session'; 11 | import { UserChannelService } from './user-channel/user-channel.service'; 12 | 13 | declare module 'http' { 14 | interface IncomingMessage { 15 | session: ExpressSession; 16 | } 17 | } 18 | 19 | @WebSocketGateway() 20 | export class MessageGateway { 21 | @WebSocketServer() 22 | private server: Server; 23 | 24 | constructor( 25 | private userChannelService: UserChannelService, 26 | private messageService: MessageService, 27 | private commentService: CommentService, 28 | ) {} 29 | 30 | @SubscribeMessage('joinChannels') 31 | async handleConnect(client: Socket, payload: any) { 32 | if (!this.checkLoginSession(client)) { 33 | client.disconnect(); 34 | return; 35 | } 36 | const user = client.request.session.user; 37 | const channels = await this.userChannelService.findChannelsByUserId( 38 | user.id, 39 | ); 40 | 41 | client.join(channels); 42 | } 43 | 44 | @SubscribeMessage('joinChannel') 45 | handleConnectOne(client: Socket, payload: { channelId: number }) { 46 | if (!this.checkLoginSession(client)) { 47 | return; 48 | } 49 | const channelRoom = payload.channelId.toString(); 50 | client.join(channelRoom); 51 | } 52 | 53 | @SubscribeMessage('sendMessage') 54 | async handleSendedMessage( 55 | client: Socket, 56 | payload: { channelId: number; contents: string }, 57 | ) { 58 | if (!this.checkLoginSession(client)) { 59 | return; 60 | } 61 | const { channelId, contents } = payload; 62 | const user = client.request.session.user; 63 | const newMessage = await this.messageService.sendMessage( 64 | user.id, 65 | channelId, 66 | contents, 67 | ); 68 | this.emitMessage(channelId, newMessage); 69 | } 70 | 71 | @SubscribeMessage('sendComment') 72 | async handleSendComment( 73 | client: Socket, 74 | payload: { channelId: number; messageId: number; contents: string }, 75 | ) { 76 | if (!this.checkLoginSession(client)) { 77 | return; 78 | } 79 | const { channelId, messageId, contents } = payload; 80 | const sender = client.request.session.user; 81 | const newComment = await this.commentService.sendComment( 82 | sender.id, 83 | channelId, 84 | messageId, 85 | contents, 86 | ); 87 | this.server.to(`${channelId}`).emit('receiveComment', newComment); 88 | } 89 | 90 | private checkLoginSession(client: Socket): boolean { 91 | const user = client.request.session.user; 92 | return !!user; 93 | } 94 | 95 | emitMessage(channelId: number, message: MessageDto) { 96 | this.server.to(`${channelId}`).emit('receiveMessage', message); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /backend/src/message/message.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | ParseIntPipe, 5 | Query, 6 | Session, 7 | UseGuards, 8 | } from '@nestjs/common'; 9 | import { ApiExtraModels, ApiOkResponse } from '@nestjs/swagger'; 10 | import ResponseEntity from '../common/response-entity'; 11 | import { LoginGuard } from '../login/login.guard'; 12 | import { ExpressSession } from '../types/session'; 13 | import { MessageDto } from './message.dto'; 14 | import { MessageDtoSchema } from './message.scheme'; 15 | import { MessageService } from './message.service'; 16 | 17 | @Controller('/api/messages') 18 | @UseGuards(LoginGuard) 19 | @ApiExtraModels(ResponseEntity) 20 | @ApiExtraModels(MessageDto) 21 | export class MessageController { 22 | constructor(private messageService: MessageService) {} 23 | 24 | @ApiOkResponse(MessageDtoSchema) 25 | @Get() 26 | async findMessagesByChannelId( 27 | @Session() session: ExpressSession, 28 | @Query('channelId', new ParseIntPipe()) channelId: number, 29 | ): Promise> { 30 | const sender = session.user; 31 | const channelMessages = await this.messageService.findMessagesByChannelId( 32 | sender.id, 33 | channelId, 34 | ); 35 | return ResponseEntity.ok(channelMessages); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /backend/src/message/message.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { UserDto } from '../user/user.dto'; 3 | import { Message } from './message.entity'; 4 | 5 | export class MessageDto { 6 | @ApiProperty() 7 | id: number; 8 | @ApiProperty() 9 | contents: string; 10 | @ApiProperty() 11 | channelId: number; 12 | @ApiProperty() 13 | createdAt: Date; 14 | @ApiProperty() 15 | sender: UserDto; 16 | 17 | static newInstance( 18 | id: number, 19 | contents: string, 20 | channelId: number, 21 | createdAt: Date, 22 | sender: UserDto, 23 | ) { 24 | const newInstance = new MessageDto(); 25 | newInstance.id = id; 26 | newInstance.contents = contents; 27 | newInstance.channelId = channelId; 28 | newInstance.createdAt = createdAt; 29 | newInstance.sender = sender; 30 | return newInstance; 31 | } 32 | 33 | static fromEntity(message: Message) { 34 | return MessageDto.newInstance( 35 | message.id, 36 | message.contents, 37 | message.channelId, 38 | message.createdAt, 39 | UserDto.fromEntity(message.sender), 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /backend/src/message/message.entity.ts: -------------------------------------------------------------------------------- 1 | import { Channel } from '../channel/channel.entity'; 2 | import { User } from '../user/user.entity'; 3 | import { 4 | Entity, 5 | PrimaryGeneratedColumn, 6 | Column, 7 | CreateDateColumn, 8 | ManyToOne, 9 | RelationId, 10 | } from 'typeorm'; 11 | 12 | @Entity() 13 | export class Message { 14 | @PrimaryGeneratedColumn() 15 | id: number; 16 | 17 | @Column() 18 | contents: string; 19 | 20 | @ManyToOne(() => Channel, { onDelete: 'CASCADE' }) 21 | channel: Channel; 22 | 23 | @RelationId((message: Message) => message.channel) 24 | channelId: number; 25 | 26 | @CreateDateColumn() 27 | createdAt: Date; 28 | 29 | @ManyToOne(() => User) 30 | sender: User; 31 | 32 | static newInstace(contents: string, channel: Channel, sender: User): Message { 33 | const newMessage = new Message(); 34 | newMessage.contents = contents; 35 | newMessage.channel = channel; 36 | newMessage.sender = sender; 37 | return newMessage; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /backend/src/message/message.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { Message } from './message.entity'; 5 | import { MessageController } from './message.controller'; 6 | import { MessageService } from './message.service'; 7 | import { User } from '../user/user.entity'; 8 | import { Channel } from '../channel/channel.entity'; 9 | import { UserServer } from '../user-server/user-server.entity'; 10 | import { UserServerModule } from '../user-server/user-server.module'; 11 | import { MessageRepository } from './message.repository'; 12 | 13 | @Module({ 14 | imports: [ 15 | TypeOrmModule.forFeature([ 16 | Message, 17 | User, 18 | Channel, 19 | UserServer, 20 | MessageRepository, 21 | ]), 22 | UserServerModule, 23 | ], 24 | exports: [MessageService], 25 | controllers: [MessageController], 26 | providers: [MessageService], 27 | }) 28 | export class MessageModule {} 29 | -------------------------------------------------------------------------------- /backend/src/message/message.repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, Repository } from 'typeorm'; 2 | import { Message } from './message.entity'; 3 | 4 | @EntityRepository(Message) 5 | export class MessageRepository extends Repository { 6 | async findByChannelId(channelId: number): Promise { 7 | return this.createQueryBuilder('message') 8 | .innerJoinAndSelect('message.sender', 'user') 9 | .where('message.channelId = :channelId', { channelId }) 10 | .orderBy('message.id') 11 | .getMany(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /backend/src/message/message.scheme.ts: -------------------------------------------------------------------------------- 1 | import { getSchemaPath } from '@nestjs/swagger'; 2 | 3 | import ResponseEntity from '../common/response-entity'; 4 | import { MessageDto } from './message.dto'; 5 | 6 | export const MessageDtoSchema = { 7 | schema: { 8 | allOf: [ 9 | { $ref: getSchemaPath(ResponseEntity) }, 10 | { 11 | properties: { 12 | data: { 13 | type: 'array', 14 | items: { $ref: getSchemaPath(MessageDto) }, 15 | }, 16 | }, 17 | }, 18 | ], 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /backend/src/message/message.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | import { Channel } from '../channel/channel.entity'; 5 | import { UserServerService } from '../user-server/user-server.service'; 6 | import { User } from '../user/user.entity'; 7 | import { MessageDto } from './message.dto'; 8 | import { Message } from './message.entity'; 9 | import { MessageRepository } from './message.repository'; 10 | 11 | @Injectable() 12 | export class MessageService { 13 | constructor( 14 | @InjectRepository(User) private userRepository: Repository, 15 | @InjectRepository(Channel) private channelReposiotry: Repository, 16 | private readonly userServerService: UserServerService, 17 | private messageRepository: MessageRepository, 18 | ) {} 19 | 20 | async sendMessage( 21 | senderId: number, 22 | channelId: number, 23 | contents: string, 24 | ): Promise { 25 | await this.userServerService.checkUserChannelAccess(senderId, channelId); 26 | 27 | const sender = await this.userRepository.findOne(senderId); 28 | const channel = await this.channelReposiotry.findOne(channelId); 29 | 30 | const newMessage = await this.messageRepository.save( 31 | Message.newInstace(contents, channel, sender), 32 | ); 33 | return MessageDto.fromEntity(newMessage); 34 | } 35 | 36 | async findMessagesByChannelId(senderId: number, channelId: number) { 37 | await this.userServerService.checkUserChannelAccess(senderId, channelId); 38 | const messages = await this.messageRepository.findByChannelId(channelId); 39 | return messages.map(MessageDto.fromEntity); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /backend/src/server/dto/request-server.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Server } from '../server.entity'; 3 | 4 | class RequestServerDto { 5 | @ApiProperty() 6 | name: string; 7 | 8 | @ApiProperty() 9 | description: string; 10 | 11 | constructor(name: string, description: string) { 12 | this.name = name; 13 | this.description = description; 14 | } 15 | 16 | toServerEntity = () => { 17 | return Server.newInstance(this.description, this.name); 18 | }; 19 | } 20 | 21 | export default RequestServerDto; 22 | -------------------------------------------------------------------------------- /backend/src/server/dto/response-server-users.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Server } from '../../server/server.entity'; 3 | import { UserDto } from '../../user/user.dto'; 4 | 5 | class ServerWithUsersDto { 6 | @ApiProperty() 7 | description: string; 8 | 9 | @ApiProperty() 10 | name: string; 11 | 12 | @ApiProperty() 13 | imgUrl: string; 14 | 15 | @ApiProperty() 16 | users: UserDto[]; 17 | 18 | constructor( 19 | description: string, 20 | name: string, 21 | imgUrl: string, 22 | users: UserDto[], 23 | ) { 24 | this.description = description; 25 | this.name = name; 26 | this.imgUrl = imgUrl; 27 | this.users = users; 28 | } 29 | 30 | static fromEntity(server: Server) { 31 | return new ServerWithUsersDto( 32 | server.description, 33 | server.name, 34 | server.imgUrl, 35 | server.userServer.map((userServer) => 36 | UserDto.newInstance( 37 | userServer.user.id, 38 | userServer.user.nickname, 39 | userServer.user.profile, 40 | ), 41 | ), 42 | ); 43 | } 44 | } 45 | 46 | export default ServerWithUsersDto; 47 | -------------------------------------------------------------------------------- /backend/src/server/server.entity.ts: -------------------------------------------------------------------------------- 1 | import { UserServer } from '../user-server/user-server.entity'; 2 | import { 3 | Entity, 4 | Column, 5 | PrimaryGeneratedColumn, 6 | ManyToOne, 7 | JoinColumn, 8 | OneToMany, 9 | RelationId, 10 | } from 'typeorm'; 11 | import { User } from '../user/user.entity'; 12 | 13 | @Entity() 14 | export class Server { 15 | @PrimaryGeneratedColumn() 16 | id: number; 17 | 18 | @Column() 19 | description: string; 20 | 21 | @Column() 22 | name: string; 23 | 24 | @Column() 25 | imgUrl: string; 26 | 27 | @Column() 28 | code: string; 29 | 30 | @ManyToOne(() => User) 31 | @JoinColumn({ referencedColumnName: 'id' }) 32 | owner: User; 33 | 34 | @RelationId((server: Server) => server.owner) 35 | ownerId: number; 36 | 37 | @OneToMany(() => UserServer, (userServer) => userServer.server) 38 | userServer: UserServer[]; 39 | 40 | static newInstance(description: string, name: string): Server { 41 | const server = new Server(); 42 | server.description = description; 43 | server.name = name; 44 | return server; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /backend/src/server/server.module.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { User } from '../user/user.entity'; 5 | import { ServerService } from './server.service'; 6 | import { ServerController } from './server.controller'; 7 | import { UserServerModule } from '../user-server/user-server.module'; 8 | import { ImageModule } from '../image/image.module'; 9 | import { ServerRepository } from './server.repository'; 10 | import { CamModule } from '../cam/cam.module'; 11 | 12 | @Module({ 13 | imports: [ 14 | ImageModule, 15 | forwardRef(() => UserServerModule), 16 | TypeOrmModule.forFeature([User, ServerRepository]), 17 | CamModule, 18 | ], 19 | providers: [ServerService], 20 | controllers: [ServerController], 21 | exports: [ServerService], 22 | }) 23 | export class ServerModule {} 24 | -------------------------------------------------------------------------------- /backend/src/server/server.repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, Repository } from 'typeorm'; 2 | import { Server } from './server.entity'; 3 | 4 | @EntityRepository(Server) 5 | export class ServerRepository extends Repository { 6 | findOneWithUsers(serverId: number) { 7 | return this.createQueryBuilder('server') 8 | .leftJoinAndSelect('server.userServer', 'user_server') 9 | .leftJoinAndSelect('user_server.user', 'user') 10 | .where('server.id = :serverId', { serverId: serverId }) 11 | .getOne(); 12 | } 13 | 14 | findOneWithOwner(serverId: number) { 15 | return this.createQueryBuilder('server') 16 | .leftJoinAndSelect('server.owner', 'user') 17 | .where('server.id = :serverId', { serverId: serverId }) 18 | .getOne(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /backend/src/server/server.schema.ts: -------------------------------------------------------------------------------- 1 | import { getSchemaPath } from '@nestjs/swagger'; 2 | 3 | import ResponseEntity from '../common/response-entity'; 4 | import { UserDto } from '../user/user.dto'; 5 | import ServerWithUsersDto from './dto/response-server-users.dto'; 6 | 7 | export const serverWithUserDtoSchema = { 8 | schema: { 9 | allOf: [ 10 | { $ref: getSchemaPath(ResponseEntity) }, 11 | { 12 | properties: { 13 | data: { 14 | allOf: [ 15 | { $ref: getSchemaPath(ServerWithUsersDto) }, 16 | { 17 | properties: { 18 | users: { 19 | type: 'array', 20 | items: { $ref: getSchemaPath(UserDto) }, 21 | }, 22 | }, 23 | }, 24 | ], 25 | }, 26 | }, 27 | }, 28 | ], 29 | }, 30 | }; 31 | 32 | export const serverCodeSchema = { 33 | schema: { 34 | allOf: [ 35 | { $ref: getSchemaPath(ResponseEntity) }, 36 | { 37 | properties: { 38 | data: { 39 | type: 'string', 40 | }, 41 | }, 42 | }, 43 | ], 44 | }, 45 | }; 46 | 47 | export const emptyResponseSchema = { 48 | schema: { 49 | allOf: [ 50 | { $ref: getSchemaPath(ResponseEntity) }, 51 | { 52 | properties: { 53 | data: { 54 | type: null, 55 | }, 56 | }, 57 | }, 58 | ], 59 | }, 60 | }; 61 | -------------------------------------------------------------------------------- /backend/src/session.ts: -------------------------------------------------------------------------------- 1 | import * as session from 'express-session'; 2 | import * as redis from 'redis'; 3 | import * as createRedisStore from 'connect-redis'; 4 | import { ConfigService } from '@nestjs/config'; 5 | 6 | export function createSessionMiddleware(configService: ConfigService) { 7 | const sessionOption: session.SessionOptions = { 8 | secret: configService.get('SESSION_SECRET'), 9 | resave: false, 10 | saveUninitialized: false, 11 | }; 12 | 13 | if (configService.get('SESSION') === 'redis') { 14 | const redisClient = redis.createClient({ 15 | host: configService.get('REDIS_HOST'), 16 | }); 17 | const RedisStore = createRedisStore(session); 18 | sessionOption.store = new RedisStore({ client: redisClient }); 19 | } 20 | 21 | return session(sessionOption); 22 | } 23 | -------------------------------------------------------------------------------- /backend/src/types/cam.ts: -------------------------------------------------------------------------------- 1 | type Status = { 2 | video: boolean; 3 | audio: boolean; 4 | stream: boolean; 5 | speaking: boolean; 6 | }; 7 | type CurrentDate = { 8 | year: number; 9 | month: number; 10 | date: number; 11 | hour: number; 12 | minutes: number; 13 | }; 14 | 15 | type MessageInfo = { 16 | msg: string; 17 | room: string | null; 18 | user: string; 19 | date: CurrentDate; 20 | }; 21 | 22 | type CamMap = { 23 | userId: string; 24 | socketId: string; 25 | userNickname: string; 26 | status: Status; 27 | }; 28 | 29 | export type { Status, MessageInfo, CamMap }; 30 | -------------------------------------------------------------------------------- /backend/src/types/session.d.ts: -------------------------------------------------------------------------------- 1 | import * as Session from 'express-session'; 2 | 3 | import { User } from '../user/user.entity'; 4 | 5 | declare module 'express-session' { 6 | interface SessionData { 7 | user: User; 8 | } 9 | } 10 | 11 | type ExpressSession = Session.Session & Partial; 12 | -------------------------------------------------------------------------------- /backend/src/user-channel/user-channel.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryGeneratedColumn, ManyToOne, RelationId } from 'typeorm'; 2 | import { User } from '../user/user.entity'; 3 | import { Server } from '../server/server.entity'; 4 | import { Channel } from '../channel/channel.entity'; 5 | 6 | @Entity() 7 | export class UserChannel { 8 | @PrimaryGeneratedColumn() 9 | id: number; 10 | 11 | @ManyToOne(() => User, { onDelete: 'CASCADE' }) 12 | user: User; 13 | 14 | @ManyToOne(() => Channel, { onDelete: 'CASCADE' }) 15 | channel: Channel; 16 | 17 | @ManyToOne(() => Server, { onDelete: 'CASCADE' }) 18 | server: Server; 19 | 20 | @RelationId((userChannel: UserChannel) => userChannel.channel) 21 | channelId: number; 22 | } 23 | -------------------------------------------------------------------------------- /backend/src/user-channel/user-channel.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { Channel } from '../channel/channel.entity'; 5 | import { ChannelService } from '../channel/channel.service'; 6 | import { User } from '../user/user.entity'; 7 | import { UserRepository } from '../user/user.repository'; 8 | import { UserChannelController } from './user-channel.controller'; 9 | import { UserChannel } from './user-channel.entity'; 10 | import { UserChannelRepository } from './user-channel.repository'; 11 | import { UserChannelService } from './user-channel.service'; 12 | import { ServerRepository } from '../server/server.repository'; 13 | import { Server } from '../server/server.entity'; 14 | import { ChannelRepository } from '../channel/channel.repository'; 15 | 16 | @Module({ 17 | imports: [ 18 | TypeOrmModule.forFeature([ 19 | UserChannel, 20 | User, 21 | Channel, 22 | Server, 23 | UserChannelRepository, 24 | UserRepository, 25 | ServerRepository, 26 | ChannelRepository, 27 | ]), 28 | ], 29 | providers: [UserChannelService, ChannelService], 30 | controllers: [UserChannelController], 31 | exports: [UserChannelService, TypeOrmModule], 32 | }) 33 | export class UserChannelModule {} 34 | -------------------------------------------------------------------------------- /backend/src/user-channel/user-channel.repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, Repository } from 'typeorm'; 2 | import { UserChannel } from './user-channel.entity'; 3 | 4 | @EntityRepository(UserChannel) 5 | export class UserChannelRepository extends Repository { 6 | getAllList(serverId: number) { 7 | return this.createQueryBuilder('user_channel') 8 | .leftJoinAndSelect('user_channel.channel', 'channel') 9 | .where('user_channel.server = :serverId', { serverId: serverId }) 10 | .getMany(); 11 | } 12 | 13 | getUserChannelListByUserId(userId: number) { 14 | return this.createQueryBuilder('user_channel') 15 | .where('user_channel.user = :userId', { userId: userId }) 16 | .getMany(); 17 | } 18 | 19 | getJoinedUserListByChannelId(serverId: number, channelId: number) { 20 | return this.createQueryBuilder('user_channel') 21 | .innerJoinAndSelect('user_channel.user', 'user') 22 | .where('user_channel.channelId = :channelId', { channelId: channelId }) 23 | .andWhere('user_channel.server = :serverId', { serverId: serverId }) 24 | .getMany(); 25 | } 26 | 27 | getUserChannelByUserIdAndChannelId(userId: number, channelId: number) { 28 | return this.createQueryBuilder('user_channel') 29 | .where('user_channel.user = :userId', { userId: userId }) 30 | .andWhere('user_channel.channelId = :channelId', { 31 | channelId: channelId, 32 | }) 33 | .getOne(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /backend/src/user-channel/user-channel.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { DeleteResult } from 'typeorm'; 4 | 5 | import { ChannelRepository } from '../channel/channel.repository'; 6 | import { UserChannelRepository } from './user-channel.repository'; 7 | import { UserChannel } from './user-channel.entity'; 8 | import { Channel } from '../channel/channel.entity'; 9 | import { UserRepository } from '../user/user.repository'; 10 | import ChannelResponseDto from '../channel/dto/channel-response.dto'; 11 | 12 | @Injectable() 13 | export class UserChannelService { 14 | constructor( 15 | @InjectRepository(ChannelRepository) 16 | private channelRepository: ChannelRepository, 17 | @InjectRepository(UserChannelRepository) 18 | private userChannelRepository: UserChannelRepository, 19 | @InjectRepository(UserRepository) private userRepository: UserRepository, 20 | ) { 21 | this.userChannelRepository = userChannelRepository; 22 | this.userRepository = userRepository; 23 | } 24 | 25 | async addNewChannel(channel: Channel, userId: number): Promise { 26 | const user = await this.userRepository.findOne({ id: userId }); 27 | const userChannel = this.userChannelRepository.create(); 28 | userChannel.channel = channel; 29 | userChannel.server = channel.server; 30 | userChannel.user = user; 31 | return await this.userChannelRepository.save(userChannel); 32 | } 33 | 34 | deleteByChannelId(channelId: number): Promise { 35 | return this.userChannelRepository.delete({ channelId }); 36 | } 37 | 38 | async getJoinedChannelListByUserId( 39 | serverId: number, 40 | userId: number, 41 | ): Promise { 42 | const joinedChannelList = await this.channelRepository.getJoinedChannelList( 43 | userId, 44 | serverId, 45 | ); 46 | 47 | return joinedChannelList.map(ChannelResponseDto.fromEntity); 48 | } 49 | 50 | async getNotJoinedChannelListByUserId( 51 | serverId: number, 52 | userId: number, 53 | ): Promise { 54 | const notJoinedChannelList = 55 | await this.channelRepository.getNotJoinedChannelList(userId, serverId); 56 | 57 | return notJoinedChannelList.map(ChannelResponseDto.fromEntity); 58 | } 59 | 60 | async deleteByUserIdAndChannelId(userId: number, channelId: number) { 61 | const res = 62 | await this.userChannelRepository.getUserChannelByUserIdAndChannelId( 63 | userId, 64 | channelId, 65 | ); 66 | this.userChannelRepository.delete({ id: res.id }); 67 | } 68 | 69 | async findChannelsByUserId(userId: number) { 70 | const userChannels = 71 | await this.userChannelRepository.getUserChannelListByUserId(userId); 72 | return userChannels.map((uc) => uc.channelId.toString()); 73 | } 74 | 75 | async findJoinedUserListByChannelId(serverId: number, channelId: number) { 76 | const joinedUserList = 77 | await this.userChannelRepository.getJoinedUserListByChannelId( 78 | serverId, 79 | channelId, 80 | ); 81 | if (!joinedUserList) 82 | throw new NotFoundException('채널에 사용자가 존재하지 않습니다!'); 83 | const userList = joinedUserList.map((data) => data.user); 84 | return userList; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /backend/src/user-server/dto/user-server-list.dto.ts: -------------------------------------------------------------------------------- 1 | import { Server } from '../../server/server.entity'; 2 | 3 | class UserServerDto { 4 | id: number; 5 | server: Server; 6 | 7 | constructor(id: number, server: Server) { 8 | this.id = id; 9 | this.server = server; 10 | } 11 | 12 | static fromEntity(userServerEntity: UserServerDto) { 13 | return new UserServerDto(userServerEntity.id, userServerEntity.server); 14 | } 15 | } 16 | 17 | export default UserServerDto; 18 | -------------------------------------------------------------------------------- /backend/src/user-server/user-server.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Post, 4 | Body, 5 | Delete, 6 | Param, 7 | Session, 8 | UseGuards, 9 | HttpCode, 10 | HttpStatus, 11 | Get, 12 | ParseIntPipe, 13 | } from '@nestjs/common'; 14 | import { LoginGuard } from '../login/login.guard'; 15 | import { ExpressSession } from '../types/session'; 16 | import { UserServerService } from './user-server.service'; 17 | import ResponseEntity from '../common/response-entity'; 18 | import UserServerListDto from './dto/user-server-list.dto'; 19 | 20 | @Controller('/api/user/servers') 21 | @UseGuards(LoginGuard) 22 | export class UserServerController { 23 | constructor(private userServerService: UserServerService) {} 24 | 25 | @Get() 26 | async getServersByUserId( 27 | @Session() 28 | session: ExpressSession, 29 | ): Promise> { 30 | const userId = session.user.id; 31 | const data = await this.userServerService.getServerListByUserId(userId); 32 | 33 | return ResponseEntity.ok(data); 34 | } 35 | 36 | @Post() 37 | async createUserServer( 38 | @Session() 39 | session: ExpressSession, 40 | @Body() { code }, 41 | ) { 42 | const user = session.user; 43 | const newUserServerId = await this.userServerService.create(user, code); 44 | return ResponseEntity.created(newUserServerId); 45 | } 46 | 47 | @Delete('/:id') 48 | @HttpCode(HttpStatus.NO_CONTENT) 49 | async delete( 50 | @Session() 51 | session: ExpressSession, 52 | @Param('id', new ParseIntPipe()) id: number, 53 | ) { 54 | const userId = session.user.id; 55 | await this.userServerService.deleteById(id, userId); 56 | return ResponseEntity.noContent(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /backend/src/user-server/user-server.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryGeneratedColumn, ManyToOne } from 'typeorm'; 2 | import { User } from '../user/user.entity'; 3 | import { Server } from '../server/server.entity'; 4 | 5 | @Entity() 6 | export class UserServer { 7 | @PrimaryGeneratedColumn() 8 | id: number; 9 | 10 | @ManyToOne(() => User, { onDelete: 'CASCADE' }) 11 | user: User; 12 | 13 | @ManyToOne(() => Server, { onDelete: 'CASCADE' }) 14 | server: Server; 15 | } 16 | -------------------------------------------------------------------------------- /backend/src/user-server/user-server.module.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { UserServerController } from './user-server.controller'; 4 | import { UserServerRepository } from './user-server.repository'; 5 | import { UserServerService } from './user-server.service'; 6 | import { UserServer } from './user-server.entity'; 7 | import { ServerModule } from '../server/server.module'; 8 | 9 | @Module({ 10 | imports: [ 11 | forwardRef(() => ServerModule), 12 | TypeOrmModule.forFeature([UserServer, UserServerRepository]), 13 | ], 14 | providers: [UserServerService], 15 | controllers: [UserServerController], 16 | exports: [UserServerService], 17 | }) 18 | export class UserServerModule {} 19 | -------------------------------------------------------------------------------- /backend/src/user-server/user-server.repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, Repository } from 'typeorm'; 2 | import { Channel } from '../channel/channel.entity'; 3 | import { UserServer } from './user-server.entity'; 4 | 5 | @EntityRepository(UserServer) 6 | export class UserServerRepository extends Repository { 7 | getServerListByUserId(userId: number) { 8 | return this.createQueryBuilder('user_server') 9 | .leftJoinAndSelect('user_server.server', 'server') 10 | .where('user_server.user = :userId', { userId: userId }) 11 | .getMany(); 12 | } 13 | 14 | deleteByUserIdAndServerId(userId: number, serverId: number) { 15 | return this.createQueryBuilder('user_server') 16 | .where('user_server.user = :userId', { userId: userId }) 17 | .andWhere('user_server.server = :serverId', { serverId: serverId }) 18 | .delete(); 19 | } 20 | 21 | findByUserIdAndServerId(userId: number, serverId: number) { 22 | return this.createQueryBuilder('user_server') 23 | .where('user_server.user = :userId', { userId: userId }) 24 | .andWhere('user_server.server = :serverId', { serverId: serverId }) 25 | .getOne(); 26 | } 27 | 28 | findWithServerOwner(id: number) { 29 | return this.createQueryBuilder('user_server') 30 | .leftJoinAndSelect('user_server.server', 'server') 31 | .leftJoinAndSelect('server.owner', 'user') 32 | .where('user_server.id = :id', { id: id }) 33 | .getOne(); 34 | } 35 | 36 | async userCanAccessChannel(userId: number, channelId: number) { 37 | const userServer = await this.createQueryBuilder('userServer') 38 | .innerJoin(Channel, 'channel', 'channel.serverId = userServer.serverId') 39 | .where('channel.id = :channelId', { channelId }) 40 | .andWhere('userServer.userId = :userId', { userId }) 41 | .getOne(); 42 | 43 | return !!userServer; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /backend/src/user-server/user-server.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | ForbiddenException, 4 | forwardRef, 5 | Inject, 6 | Injectable, 7 | } from '@nestjs/common'; 8 | import { InjectRepository } from '@nestjs/typeorm'; 9 | import { UserServerRepository } from './user-server.repository'; 10 | import { UserServer } from './user-server.entity'; 11 | import { DeleteResult } from 'typeorm'; 12 | import { User } from '../user/user.entity'; 13 | import { ServerService } from '../server/server.service'; 14 | import UserServerDto from './dto/user-server-list.dto'; 15 | 16 | @Injectable() 17 | export class UserServerService { 18 | constructor( 19 | @Inject(forwardRef(() => ServerService)) 20 | private readonly serverService: ServerService, 21 | @InjectRepository(UserServerRepository) 22 | private userServerRepository: UserServerRepository, 23 | ) {} 24 | 25 | async create(user: User, code: string): Promise { 26 | const newUserServer = new UserServer(); 27 | newUserServer.user = user; 28 | newUserServer.server = await this.serverService.findByCode(code); 29 | 30 | if (newUserServer.server == undefined) { 31 | throw new BadRequestException('존재하지 않는 서버입니다.'); 32 | } 33 | const userServer = await this.userServerRepository.findByUserIdAndServerId( 34 | user.id, 35 | newUserServer.server.id, 36 | ); 37 | if (userServer !== undefined) { 38 | throw new BadRequestException('이미 등록된 서버입니다.'); 39 | } 40 | 41 | const newUserServerId = await this.userServerRepository.save(newUserServer); 42 | return newUserServerId.id; 43 | } 44 | 45 | async deleteById(id: number, userId: number): Promise { 46 | const userServer = await this.userServerRepository.findWithServerOwner(id); 47 | 48 | if (!userServer) { 49 | throw new BadRequestException('해당 서버에 참가하고 있지 않습니다.'); 50 | } 51 | if (userServer.server.owner.id === userId) { 52 | throw new BadRequestException('서버 생성자는 서버에서 나갈 수 없습니다.'); 53 | } 54 | 55 | return this.userServerRepository.delete(id); 56 | } 57 | 58 | async getServerListByUserId(userId: number): Promise { 59 | const userServerList = 60 | await this.userServerRepository.getServerListByUserId(userId); 61 | userServerList.map(UserServerDto.fromEntity); 62 | return userServerList; 63 | } 64 | 65 | async checkUserChannelAccess(senderId: number, channelId: number) { 66 | const userServer = await this.userCanAccessChannel(senderId, channelId); 67 | 68 | if (!userServer) { 69 | throw new ForbiddenException('서버나 채널에 참여하지 않았습니다.'); 70 | } 71 | } 72 | 73 | private async userCanAccessChannel(userId: number, channelId: number) { 74 | return await this.userServerRepository.userCanAccessChannel( 75 | userId, 76 | channelId, 77 | ); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /backend/src/user/user.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { getRepositoryToken } from '@nestjs/typeorm'; 3 | import { ServerRepository } from '../server/server.repository'; 4 | import { ServerService } from '../server/server.service'; 5 | import { UserServerRepository } from '../user-server/user-server.repository'; 6 | import { UserServerService } from '../user-server/user-server.service'; 7 | import { UserController } from './user.controller'; 8 | 9 | const mockUserServerRepository = () => ({ 10 | save: jest.fn(), 11 | delete: jest.fn(), 12 | deleteByUserIdAndServerId: jest.fn(), 13 | }); 14 | const mockServerRepository = () => ({ 15 | findOne: jest.fn(), 16 | }); 17 | 18 | describe('UserController', () => { 19 | let controller: UserController; 20 | 21 | beforeEach(async () => { 22 | const module: TestingModule = await Test.createTestingModule({ 23 | providers: [ 24 | UserServerService, 25 | { 26 | provide: getRepositoryToken(UserServerRepository), 27 | useValue: mockUserServerRepository(), 28 | }, 29 | ServerService, 30 | { 31 | provide: getRepositoryToken(ServerRepository), 32 | useValue: mockServerRepository(), 33 | }, 34 | ], 35 | controllers: [UserController], 36 | }).compile(); 37 | 38 | controller = module.get(UserController); 39 | }); 40 | 41 | it('should be defined', () => { 42 | expect(controller).toBeDefined(); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /backend/src/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, UseGuards, Session } from '@nestjs/common'; 2 | import ResponseEntity from '../common/response-entity'; 3 | import { LoginGuard } from '../login/login.guard'; 4 | import { ExpressSession } from '../types/session'; 5 | import { UserDto } from './user.dto'; 6 | 7 | @Controller('/api/user') 8 | @UseGuards(LoginGuard) 9 | export class UserController { 10 | @Get() 11 | getUser(@Session() session: ExpressSession) { 12 | return ResponseEntity.ok(UserDto.fromEntity(session.user)); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /backend/src/user/user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { User } from './user.entity'; 3 | 4 | export class UserDto { 5 | @ApiProperty() 6 | id: number; 7 | @ApiProperty() 8 | nickname: string; 9 | @ApiProperty() 10 | profile: string; 11 | 12 | static newInstance(id: number, nickname: string, profile: string) { 13 | const newInstance = new UserDto(); 14 | newInstance.id = id; 15 | newInstance.nickname = nickname; 16 | newInstance.profile = profile; 17 | return newInstance; 18 | } 19 | 20 | static fromEntity(user: User): UserDto { 21 | return UserDto.newInstance(user.id, user.nickname, user.profile); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /backend/src/user/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; 2 | 3 | @Entity() 4 | export class User { 5 | @PrimaryGeneratedColumn() 6 | id: number; 7 | 8 | @Column() 9 | githubId: number; 10 | 11 | @Column() 12 | nickname: string; 13 | 14 | @Column() 15 | profile: string; 16 | } 17 | -------------------------------------------------------------------------------- /backend/src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { Server } from '../server/server.entity'; 4 | import { UserService } from './user.service'; 5 | import { User } from './user.entity'; 6 | import { UserController } from './user.controller'; 7 | import { UserServerModule } from '../user-server/user-server.module'; 8 | 9 | @Module({ 10 | imports: [UserServerModule, TypeOrmModule.forFeature([User, Server])], 11 | providers: [UserService], 12 | controllers: [UserController], 13 | exports: [UserService, TypeOrmModule], 14 | }) 15 | export class UserModule {} 16 | -------------------------------------------------------------------------------- /backend/src/user/user.repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, Repository } from 'typeorm'; 2 | import { User } from './user.entity'; 3 | 4 | @EntityRepository(User) 5 | export class UserRepository extends Repository {} 6 | -------------------------------------------------------------------------------- /backend/src/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { User } from './user.entity'; 4 | import { Repository } from 'typeorm'; 5 | 6 | @Injectable() 7 | export class UserService { 8 | constructor( 9 | @InjectRepository(User) private userRepository: Repository, 10 | ) {} 11 | } 12 | -------------------------------------------------------------------------------- /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 | "incremental": true, 13 | "skipLibCheck": true, 14 | "strictNullChecks": false, 15 | "noImplicitAny": false, 16 | "strictBindCallApply": false, 17 | "forceConsistentCasingInFileNames": false, 18 | "noFallthroughCasesInSwitch": false 19 | }, 20 | "include": ["src"] 21 | } 22 | -------------------------------------------------------------------------------- /frontend/.env: -------------------------------------------------------------------------------- 1 | PORT=3000 2 | -------------------------------------------------------------------------------- /frontend/.env.development: -------------------------------------------------------------------------------- 1 | REACT_APP_PEERJS_PORT=9000 2 | REACT_APP_GITHUB_CLIENT_ID=028b90a23e9500c30c28 3 | REACT_APP_GITHUB_REDIRECT_URL=http://localhost:3000/login/github 4 | -------------------------------------------------------------------------------- /frontend/.env.production: -------------------------------------------------------------------------------- 1 | REACT_APP_PEERJS_PORT=443 2 | REACT_APP_GITHUB_CLIENT_ID=bd704088357090104ffd 3 | REACT_APP_GITHUB_REDIRECT_URL=https://boostcam.ml/login/github 4 | -------------------------------------------------------------------------------- /frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'airbnb', 4 | 'airbnb-typescript', 5 | 'prettier', 6 | 'plugin:react/recommended', // eslint-plugin-react에서 추천하는 리액트 린팅 설정 7 | 'plugin:@typescript-eslint/recommended', // @typescript-eslint/recommended의 추천 룰 사용 8 | 'plugin:prettier/recommended', 9 | ], 10 | plugins: ['prettier', 'react', '@typescript-eslint', 'react-hooks'], // 해당 플러그인을 사용할것이라고 등록 11 | env: { 12 | browser: true, 13 | es6: true, 14 | jest: true, 15 | }, 16 | parser: '@typescript-eslint/parser', 17 | parserOptions: { 18 | ecmaFeatures: { 19 | jsx: true, // jsx 활성화 20 | }, 21 | ecmaVersion: 2021, 22 | sourceType: 'module', // import 사용 23 | project: './tsconfig.json', 24 | tsconfigRootDir: __dirname, 25 | }, 26 | ignorePatterns: ['.eslintrc.js'], 27 | rules: { 28 | 'react/react-in-jsx-scope': 'off', 29 | 'react/jsx-filename-extension': [1, { extensions: ['.js', '.jsx', '.ts', '.tsx'] }], 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true, 4 | "useTabs": false, 5 | "tabWidth": 2, 6 | "trailingComma": "all", 7 | "printWidth": 120, 8 | "endOfLine": "auto" 9 | } 10 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.14.1", 7 | "@testing-library/react": "^11.2.7", 8 | "@testing-library/user-event": "^12.8.3", 9 | "@types/jest": "^26.0.24", 10 | "@types/node": "^12.20.36", 11 | "@types/react": "^17.0.33", 12 | "@types/react-dom": "^17.0.10", 13 | "@types/react-router-dom": "^5.3.2", 14 | "peerjs": "^1.3.2", 15 | "react": "^17.0.2", 16 | "react-dom": "^17.0.2", 17 | "react-hook-form": "^7.19.5", 18 | "react-router-dom": "^6.0.1", 19 | "react-scripts": "4.0.3", 20 | "recoil": "^0.4.1", 21 | "socket.io-client": "^4.3.2", 22 | "styled-components": "^5.3.3", 23 | "typescript": "^4.4.4", 24 | "web-vitals": "^1.1.2" 25 | }, 26 | "scripts": { 27 | "postinstall": "patch-package", 28 | "start": "react-scripts start", 29 | "build": "react-scripts build", 30 | "test": "react-scripts test", 31 | "eject": "react-scripts eject" 32 | }, 33 | "proxy": "http://localhost:9000", 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/styled-components": "^5.1.15", 48 | "eslint-config-airbnb": "^18.2.1", 49 | "eslint-config-airbnb-typescript": "^14.0.1", 50 | "eslint-config-prettier": "^8.3.0", 51 | "eslint-plugin-jsx-a11y": "^6.4.1", 52 | "eslint-plugin-prettier": "^4.0.0", 53 | "patch-package": "^6.4.7", 54 | "prettier": "^2.4.1" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /frontend/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2021/web07-boostCam/ae00c59a1ed27807170354dde20934e291d4775b/frontend/public/favicon-16x16.png -------------------------------------------------------------------------------- /frontend/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2021/web07-boostCam/ae00c59a1ed27807170354dde20934e291d4775b/frontend/public/favicon-32x32.png -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2021/web07-boostCam/ae00c59a1ed27807170354dde20934e291d4775b/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 24 | boostCam 25 | 26 | 27 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /frontend/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2021/web07-boostCam/ae00c59a1ed27807170354dde20934e291d4775b/frontend/public/logo192.png -------------------------------------------------------------------------------- /frontend/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2021/web07-boostCam/ae00c59a1ed27807170354dde20934e291d4775b/frontend/public/logo512.png -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "boostCam", 3 | "name": "BoostCamp Group Project boostCam", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /frontend/public/pepes/pepe-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2021/web07-boostCam/ae00c59a1ed27807170354dde20934e291d4775b/frontend/public/pepes/pepe-1.jpg -------------------------------------------------------------------------------- /frontend/public/pepes/pepe-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2021/web07-boostCam/ae00c59a1ed27807170354dde20934e291d4775b/frontend/public/pepes/pepe-2.jpg -------------------------------------------------------------------------------- /frontend/public/pepes/pepe-3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2021/web07-boostCam/ae00c59a1ed27807170354dde20934e291d4775b/frontend/public/pepes/pepe-3.jpg -------------------------------------------------------------------------------- /frontend/public/pepes/pepe-4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2021/web07-boostCam/ae00c59a1ed27807170354dde20934e291d4775b/frontend/public/pepes/pepe-4.jpg -------------------------------------------------------------------------------- /frontend/public/pepes/pepe-5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2021/web07-boostCam/ae00c59a1ed27807170354dde20934e291d4775b/frontend/public/pepes/pepe-5.jpg -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /frontend/src/App.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | }); 9 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from 'react'; 2 | import { RecoilRoot } from 'recoil'; 3 | import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; 4 | 5 | import LoginMain from './components/Login/LoginMain'; 6 | import CamMain from './components/Cam/CamMain'; 7 | import LoginCallback from './components/Login/LoginCallback'; 8 | import Main from './components/Main/Main'; 9 | import Loading from './components/core/Loading'; 10 | 11 | function App(): JSX.Element { 12 | return ( 13 | 14 | 15 | }> 16 | 17 | } /> 18 | } /> 19 | } /> 20 | } /> 21 | 22 | 23 | 24 | 25 | ); 26 | } 27 | 28 | export default App; 29 | -------------------------------------------------------------------------------- /frontend/src/assets/hmm.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2021/web07-boostCam/ae00c59a1ed27807170354dde20934e291d4775b/frontend/src/assets/hmm.gif -------------------------------------------------------------------------------- /frontend/src/assets/icons/chat.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/copy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/cover_new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2021/web07-boostCam/ae00c59a1ed27807170354dde20934e291d4775b/frontend/src/assets/icons/cover_new.png -------------------------------------------------------------------------------- /frontend/src/assets/icons/exit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/hash.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/identification.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/listarrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/mic-disabled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/mic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/plus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/presentation.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/sparkle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/speech-disabled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/speech.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/users.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/video-disabled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/video.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /frontend/src/assets/loading.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2021/web07-boostCam/ae00c59a1ed27807170354dde20934e291d4775b/frontend/src/assets/loading.png -------------------------------------------------------------------------------- /frontend/src/assets/loading.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 7 | 9 | 12 | 13 | 16 | 19 | 20 | 22 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /frontend/src/atoms/user.ts: -------------------------------------------------------------------------------- 1 | import { selector } from 'recoil'; 2 | 3 | import { User } from '../types/user'; 4 | import { fetchData } from '../utils/fetchMethods'; 5 | 6 | const userState = selector({ 7 | key: 'user', 8 | get: async () => { 9 | const { data } = await fetchData('GET', '/api/user'); 10 | return data; 11 | }, 12 | }); 13 | 14 | export default userState; 15 | -------------------------------------------------------------------------------- /frontend/src/components/Cam/CamMain.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react'; 2 | import styled from 'styled-components'; 3 | import { useRecoilValue } from 'recoil'; 4 | 5 | import ButtonBar from './Menu/ButtonBar'; 6 | import ChattingTab from './Menu/ChattingTab'; 7 | import MainScreen from './Screen/MainScreen'; 8 | import CamStore from './CamStore'; 9 | import UserListTab from './Menu/UserListTab'; 10 | import ToggleStore from './ToggleStore'; 11 | import { UserInfo } from '../../types/cam'; 12 | import STTStore from './STT/STTStore'; 13 | import SharedScreenStore from './SharedScreen/SharedScreenStore'; 14 | import CamNickNameInputPage from './Page/CamNickNameInputPage'; 15 | import CamNotFoundPage from './Page/CamNotFoundPage'; 16 | import CamLoadingPage from './Page/CamLoadingPage'; 17 | import CamNotAvailablePage from './Page/CamNotAvailablePage'; 18 | import CamErrorPage from './Page/CamErrorPage'; 19 | import userState from '../../atoms/user'; 20 | import { fetchData } from '../../utils/fetchMethods'; 21 | import { flex } from '../../utils/styledComponentFunc'; 22 | 23 | const Container = styled.div` 24 | background-color: black; 25 | width: 100vw; 26 | height: 100vh; 27 | ${flex('row', 'space-between')}; 28 | position: relative; 29 | `; 30 | 31 | function CamMain(): JSX.Element { 32 | const user = useRecoilValue(userState); 33 | const [userInfo, setUserInfo] = useState({ roomId: null, nickname: null }); 34 | const [statusCode, setStatusCode] = useState(0); 35 | 36 | const camRef = useRef(null); 37 | 38 | const checkRoomExist = async (roomId: string) => { 39 | const { statusCode: newStatusCode } = await fetchData('GET', `/api/cam/${roomId}`); 40 | setStatusCode(newStatusCode); 41 | }; 42 | 43 | useEffect(() => { 44 | const roomId = new URLSearchParams(new URL(window.location.href).search).get('roomid'); 45 | 46 | if (roomId) { 47 | checkRoomExist(roomId); 48 | } 49 | 50 | setUserInfo((prev) => ({ ...prev, roomId })); 51 | }, []); 52 | 53 | useEffect(() => { 54 | if (!user) { 55 | return; 56 | } 57 | setUserInfo((prev) => ({ ...prev, nickname: user.nickname })); 58 | }, [user]); 59 | 60 | switch (statusCode) { 61 | case 0: 62 | return ; 63 | case 403: 64 | return ; 65 | case 404: 66 | return ; 67 | case 200: 68 | if (!userInfo?.nickname) { 69 | return ; 70 | } 71 | return ( 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | ); 87 | default: 88 | return ; 89 | } 90 | } 91 | 92 | export default CamMain; 93 | -------------------------------------------------------------------------------- /frontend/src/components/Cam/CamStore.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useEffect, useRef } from 'react'; 2 | import { io, Socket } from 'socket.io-client'; 3 | 4 | import useUserMedia from '../../hooks/useUserMedia'; 5 | import { UserInfo } from '../../types/cam'; 6 | 7 | type CamStoreProps = { 8 | children: React.ReactChild[] | React.ReactChild; 9 | userInfo: UserInfo; 10 | setUserInfo: React.Dispatch>; 11 | }; 12 | 13 | export const CamStoreContext = createContext(null); 14 | 15 | function CamStore(props: CamStoreProps): JSX.Element { 16 | const { children, userInfo, setUserInfo } = props; 17 | const currentURL = new URL(window.location.href); 18 | const roomId = currentURL.searchParams.get('roomid'); 19 | 20 | const socketRef = useRef(); 21 | 22 | if (!socketRef.current) { 23 | const socket = io('/cam', { 24 | withCredentials: true, 25 | forceNew: true, 26 | transports: ['polling'], 27 | }); 28 | 29 | socketRef.current = socket; 30 | } 31 | 32 | const { localStatus, localStream, setLocalStatus, screenList } = useUserMedia({ 33 | socket: socketRef.current, 34 | roomId, 35 | userInfo, 36 | }); 37 | 38 | useEffect(() => { 39 | const socketDisconnect = () => { 40 | socketRef?.current?.disconnect(); 41 | }; 42 | window.addEventListener('popstate', socketDisconnect); 43 | return () => { 44 | window.removeEventListener('popstate', socketDisconnect); 45 | }; 46 | }, []); 47 | 48 | return ( 49 | 52 | {children} 53 | 54 | ); 55 | } 56 | 57 | export default CamStore; 58 | -------------------------------------------------------------------------------- /frontend/src/components/Cam/Menu/NicknameModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import { UserInfo } from '../../../types/cam'; 5 | import { flex } from '../../../utils/styledComponentFunc'; 6 | import { CamStoreContext } from '../CamStore'; 7 | 8 | const Container = styled.div` 9 | position: fixed; 10 | width: 100vw; 11 | height: 100vh; 12 | left: 0px; 13 | right: 0px; 14 | ${flex('column', 'space-around', 'center')} 15 | z-index: 2; 16 | `; 17 | 18 | const ModalBackground = styled.div` 19 | position: fixed; 20 | left: 0px; 21 | right: 0px; 22 | width: 100%; 23 | height: 100%; 24 | background-color: rgb(0, 0, 0, 0.5); 25 | `; 26 | 27 | const ModalBox = styled.div` 28 | width: 30%; 29 | height: 20%; 30 | background-color: white; 31 | ${flex('column', 'space-around', 'center')} 32 | padding: 20px; 33 | border-radius: 20px; 34 | box-shadow: 0px 5px 22px -2px #000000; 35 | z-index: 3; 36 | `; 37 | 38 | const Form = styled.form` 39 | border-radius: 20px; 40 | ${flex('row', 'space-around', 'center')} 41 | `; 42 | 43 | const Input = styled.input` 44 | border: 1px solid grey; 45 | outline: none; 46 | padding: 8px 10px; 47 | margin-right: 20px; 48 | border-radius: 10px; 49 | `; 50 | 51 | const SubmitButton = styled.button` 52 | width: 30%; 53 | height: 35px; 54 | background: none; 55 | 56 | outline: 0; 57 | 58 | border: 1px solid grey; 59 | border-radius: 10px; 60 | cursor: pointer; 61 | text-align: center; 62 | 63 | a { 64 | text-decoration: none; 65 | } 66 | `; 67 | 68 | const Title = styled.div``; 69 | 70 | type NicknameModalProps = { 71 | setUserInfo: React.Dispatch>; 72 | setIsActiveNicknameModal: React.Dispatch>; 73 | }; 74 | 75 | function NicknameModal(props: NicknameModalProps): JSX.Element { 76 | const { setUserInfo, setIsActiveNicknameModal } = props; 77 | const { socket } = useContext(CamStoreContext); 78 | 79 | const onSubmitNicknameForm = (e: React.FormEvent) => { 80 | e.preventDefault(); 81 | const { currentTarget } = e; 82 | const nickname = new FormData(currentTarget).get('nickname')?.toString().trim(); 83 | if (!nickname) return; 84 | setUserInfo((prev) => ({ ...prev, nickname })); 85 | socket.emit('changeNickname', { userNickname: nickname }); 86 | setIsActiveNicknameModal(false); 87 | }; 88 | 89 | const onClickModalBackground = () => { 90 | setIsActiveNicknameModal(false); 91 | }; 92 | 93 | return ( 94 | 95 | 96 | 97 | 닉네임 변경 98 |
99 | 100 | 입력 101 |
102 |
103 |
104 | ); 105 | } 106 | 107 | export default NicknameModal; 108 | -------------------------------------------------------------------------------- /frontend/src/components/Cam/Menu/UserListTab.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import UserScreen from '../Screen/UserScreen'; 5 | import { CamStoreContext } from '../CamStore'; 6 | import LocalUserScreen from '../Screen/LocalUserScreen'; 7 | import Draggable from '../../core/Draggable'; 8 | import type { Screen } from '../../../types/cam'; 9 | import { ToggleStoreContext } from '../ToggleStore'; 10 | import { customScroll } from '../../../utils/styledComponentFunc'; 11 | 12 | const Container = styled.div<{ isActive: boolean }>` 13 | position: absolute; 14 | width: 18vw; 15 | max-height: 70vh; 16 | padding: 10px; 17 | background-color: black; 18 | display: ${(props) => (props.isActive ? 'block' : 'none')}; 19 | overflow-y: auto; 20 | 21 | ${customScroll()}; 22 | `; 23 | 24 | function UserListTab(): JSX.Element { 25 | const { screenList } = useContext(CamStoreContext); 26 | const { isUserListTabActive } = useContext(ToggleStoreContext); 27 | 28 | return ( 29 | 36 | 37 | 38 | {screenList.map((screen: Screen) => ( 39 | 40 | ))} 41 | 42 | 43 | ); 44 | } 45 | 46 | export default UserListTab; 47 | -------------------------------------------------------------------------------- /frontend/src/components/Cam/Page/CamDefaultPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { flex } from '../../../utils/styledComponentFunc'; 4 | 5 | type CamDefaultPageProps = { 6 | backgroundSrc: string; 7 | children: React.ReactChild | React.ReactChild[]; 8 | }; 9 | 10 | const Container = styled.div` 11 | position: fixed; 12 | width: 100vw; 13 | height: 100vh; 14 | left: 0px; 15 | right: 0px; 16 | ${flex('row', 'center', 'center')} 17 | background-color: white; 18 | `; 19 | 20 | const Background = styled.img` 21 | position: absolute; 22 | width: 70%; 23 | height: auto; 24 | margin: 0 auto; 25 | opacity: 0.1; 26 | z-index: -1; 27 | `; 28 | 29 | function CamDefaultPage(props: CamDefaultPageProps): JSX.Element { 30 | const { children, backgroundSrc } = props; 31 | return ( 32 | 33 | 34 | {children} 35 | 36 | ); 37 | } 38 | 39 | export default CamDefaultPage; 40 | -------------------------------------------------------------------------------- /frontend/src/components/Cam/Page/CamErrorPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { flex } from '../../../utils/styledComponentFunc'; 4 | import CamDefaultPage from './CamDefaultPage'; 5 | 6 | const Title = styled.div` 7 | background-color: rgba(0, 0, 0, 0.7); 8 | width: 100%; 9 | height: 20%; 10 | ${flex('row', 'center', 'center')}; 11 | color: white; 12 | font-size: 44px; 13 | `; 14 | 15 | function CamErrorPage(): JSX.Element { 16 | return ( 17 | 18 | 알 수 없는 오류가 발생했습니다. 19 | 20 | ); 21 | } 22 | 23 | export default CamErrorPage; 24 | -------------------------------------------------------------------------------- /frontend/src/components/Cam/Page/CamLoadingPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { flex } from '../../../utils/styledComponentFunc'; 4 | import CamDefaultPage from './CamDefaultPage'; 5 | 6 | const Title = styled.div` 7 | background-color: rgba(0, 0, 0, 0.7); 8 | width: 100%; 9 | height: 20%; 10 | ${flex('row', 'center', 'center')}; 11 | color: white; 12 | font-size: 44px; 13 | `; 14 | 15 | function CamLoadingPage(): JSX.Element { 16 | return ( 17 | 18 | Loading... 19 | 20 | ); 21 | } 22 | 23 | export default CamLoadingPage; 24 | -------------------------------------------------------------------------------- /frontend/src/components/Cam/Page/CamNickNameInputPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { UserInfo } from '../../../types/cam'; 4 | import { flex } from '../../../utils/styledComponentFunc'; 5 | import CamDefaultPage from './CamDefaultPage'; 6 | 7 | const Form = styled.form` 8 | background-color: rgba(0, 0, 0, 0.7); 9 | width: 100%; 10 | height: 20%; 11 | ${flex('row', 'center', 'center')}; 12 | color: white; 13 | font-size: 44px; 14 | `; 15 | 16 | const Input = styled.input` 17 | border: none; 18 | outline: none; 19 | padding: 10px; 20 | border-radius: 10px; 21 | width: 30%; 22 | `; 23 | 24 | const SubmitButton = styled.button` 25 | padding: 10px 30px; 26 | border: 0; 27 | outline: 0; 28 | border-radius: 10px; 29 | cursor: pointer; 30 | text-align: center; 31 | margin-left: 20px; 32 | 33 | a { 34 | text-decoration: none; 35 | } 36 | `; 37 | 38 | type CamNickNameInputPageProps = { 39 | setUserInfo: React.Dispatch>; 40 | }; 41 | 42 | function CamNickNameInputPage(props: CamNickNameInputPageProps): JSX.Element { 43 | const { setUserInfo } = props; 44 | 45 | const onSubmitNicknameForm = (e: React.FormEvent) => { 46 | e.preventDefault(); 47 | const { currentTarget } = e; 48 | const nickname = new FormData(currentTarget).get('nickname')?.toString().trim(); 49 | if (!nickname) { 50 | return; 51 | } 52 | setUserInfo((prev) => ({ ...prev, nickname })); 53 | }; 54 | 55 | return ( 56 | 57 |
58 | 59 | 입력 60 |
61 |
62 | ); 63 | } 64 | 65 | export default CamNickNameInputPage; 66 | -------------------------------------------------------------------------------- /frontend/src/components/Cam/Page/CamNotAvailablePage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { flex } from '../../../utils/styledComponentFunc'; 4 | import CamDefaultPage from './CamDefaultPage'; 5 | 6 | const Title = styled.div` 7 | background-color: rgba(0, 0, 0, 0.7); 8 | width: 100%; 9 | height: 20%; 10 | ${flex('row', 'center', 'center')}; 11 | color: white; 12 | font-size: 44px; 13 | `; 14 | 15 | function CamNotAvailablePage(): JSX.Element { 16 | return ( 17 | 18 | 참여 인원을 초과하였습니다. 19 | 20 | ); 21 | } 22 | 23 | export default CamNotAvailablePage; 24 | -------------------------------------------------------------------------------- /frontend/src/components/Cam/Page/CamNotFoundPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { flex } from '../../../utils/styledComponentFunc'; 4 | import CamDefaultPage from './CamDefaultPage'; 5 | 6 | const Title = styled.div` 7 | background-color: rgba(0, 0, 0, 0.7); 8 | width: 100%; 9 | height: 20%; 10 | ${flex('row', 'center', 'center')}; 11 | color: white; 12 | font-size: 44px; 13 | `; 14 | 15 | function CamNotFoundPage(): JSX.Element { 16 | return ( 17 | 18 | 존재하지 않는 방입니다. 19 | 20 | ); 21 | } 22 | 23 | export default CamNotFoundPage; 24 | -------------------------------------------------------------------------------- /frontend/src/components/Cam/STT/STTScreen.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useContext } from 'react'; 2 | import styled from 'styled-components'; 3 | import { Status } from '../../../types/cam'; 4 | import { flex } from '../../../utils/styledComponentFunc'; 5 | import { STTStoreContext } from './STTStore'; 6 | 7 | const Container = styled.div` 8 | box-sizing: border-box; 9 | padding: 5px 10px; 10 | color: #999999; 11 | font-size: 10px; 12 | height: 80px; 13 | border-bottom: 2px solid #999999; 14 | margin-top: 5px; 15 | ${flex('column')} 16 | `; 17 | 18 | const Title = styled.div` 19 | font-weight: bold; 20 | `; 21 | 22 | const Content = styled.div` 23 | margin: 5px 0; 24 | overflow: auto; 25 | `; 26 | 27 | type STTScreenProps = { 28 | sendMessage: (msg: string) => void; 29 | setLocalStatus: React.Dispatch>; 30 | }; 31 | 32 | function STTScreen(props: STTScreenProps): JSX.Element { 33 | const { sendMessage, setLocalStatus } = props; 34 | const { lastResult, isSTTActive, isSpeaking } = useContext(STTStoreContext); 35 | 36 | useEffect(() => { 37 | if (lastResult.isFinal) { 38 | sendMessage(lastResult.text); 39 | } 40 | }, [lastResult]); 41 | 42 | useEffect(() => { 43 | lastResult.text = ''; 44 | }, [isSTTActive]); 45 | 46 | useEffect(() => { 47 | setLocalStatus((prev) => ({ ...prev, speaking: isSpeaking })); 48 | }, [isSpeaking]); 49 | 50 | return isSTTActive ? ( 51 | 52 | STT Monitor 53 | {lastResult.text} 54 | 55 | ) : ( 56 | <> 57 | ); 58 | } 59 | 60 | export default STTScreen; 61 | -------------------------------------------------------------------------------- /frontend/src/components/Cam/STT/STTStore.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext } from 'react'; 2 | import useSTT from '../../../hooks/useSTT'; 3 | 4 | export const STTStoreContext = createContext(null); 5 | 6 | type STTStoreProps = { 7 | children: React.ReactChild[] | React.ReactChild; 8 | }; 9 | 10 | function STTStore(props: STTStoreProps): JSX.Element { 11 | const { children } = props; 12 | const { lastResult, isSTTActive, isSpeaking, toggleSTTActive } = useSTT(); 13 | 14 | return ( 15 | 16 | {children} 17 | 18 | ); 19 | } 20 | 21 | export default STTStore; 22 | -------------------------------------------------------------------------------- /frontend/src/components/Cam/Screen/ControlMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { Control } from '../../../types/cam'; 4 | 5 | type ControlMenuProps = { 6 | control: Control; 7 | setControl: React.Dispatch>; 8 | controlPosition: { x: number; y: number }; 9 | }; 10 | 11 | const Container = styled.div<{ position: { x: number; y: number } }>` 12 | position: fixed; 13 | background-color: white; 14 | top: ${(props) => props.position.y}px; 15 | left: ${(props) => props.position.x}px; 16 | padding: 5px; 17 | border-radius: 2px; 18 | box-shadow: 0px 6px 15px 1px rgba(0, 0, 0, 0.5); 19 | cursor: pointer; 20 | `; 21 | 22 | const ControlRow = styled.div` 23 | display: flex; 24 | width: 80px; 25 | justify-content: space-between; 26 | `; 27 | 28 | const Hr = styled.div` 29 | height: 1px; 30 | width: 100%; 31 | background-color: gray; 32 | margin: 2px 0; 33 | `; 34 | 35 | function ControlMenu(props: ControlMenuProps): JSX.Element { 36 | const { control, setControl, controlPosition } = props; 37 | 38 | const toggleAudio = () => { 39 | setControl((prev) => ({ ...prev, audio: !prev.audio })); 40 | }; 41 | 42 | const toggleVideo = () => { 43 | setControl((prev) => ({ ...prev, video: !prev.video })); 44 | }; 45 | 46 | return ( 47 | 48 | 49 |
Audio
50 |
{control.audio ? '✔' : ' '}
51 |
52 |
53 | 54 |
Video
55 |
{control.video ? '✔' : ' '}
56 |
57 |
58 | ); 59 | } 60 | 61 | export default ControlMenu; 62 | -------------------------------------------------------------------------------- /frontend/src/components/Cam/Screen/DefaultScreen.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { flex } from '../../../utils/styledComponentFunc'; 3 | 4 | const Container = styled.div` 5 | width: 90%; 6 | max-height: 100%; 7 | ${flex('row', 'center')}; 8 | `; 9 | 10 | const DefaultImg = styled.img` 11 | max-width: 100%; 12 | max-height: 100%; 13 | `; 14 | 15 | function DefaultScreen(): JSX.Element { 16 | return ( 17 | 18 | 19 | 20 | ); 21 | } 22 | 23 | export default DefaultScreen; 24 | -------------------------------------------------------------------------------- /frontend/src/components/Cam/Screen/LocalUserScreen.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useRef } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import DefaultScreen from './DefaultScreen'; 5 | import { CamStoreContext } from '../CamStore'; 6 | import StreamStatusIndicator from './StreamStatusIndicator'; 7 | import { flex } from '../../../utils/styledComponentFunc'; 8 | 9 | const Container = styled.div<{ numOfScreen: number }>` 10 | position: relative; 11 | width: calc(100% / ${(props) => Math.ceil(props.numOfScreen ** 0.5)}); 12 | height: calc(100% / ${(props) => Math.floor((props.numOfScreen + 1) ** 0.5)}); 13 | ${flex('column', 'center', 'center')} 14 | aspect-ratio: 16/9; 15 | overflow: hidden; 16 | `; 17 | 18 | const Video = styled.video` 19 | max-height: 100%; 20 | width: 90%; 21 | `; 22 | 23 | type LocalUserScreenProps = { 24 | numOfScreen: number; 25 | }; 26 | 27 | function LocalUserScreen(props: LocalUserScreenProps): JSX.Element { 28 | const { numOfScreen } = props; 29 | const videoRef = useRef(null); 30 | const { localStream, localStatus, userInfo } = useContext(CamStoreContext); 31 | 32 | useEffect(() => { 33 | const video = videoRef.current; 34 | 35 | if (!video || !localStream?.active || video?.srcObject) { 36 | return; 37 | } 38 | video.srcObject = localStream; 39 | }); 40 | 41 | return ( 42 | 43 | {localStatus.stream && localStatus.video ? ( 44 | 47 | ) : ( 48 | 49 | )} 50 | 55 | 56 | ); 57 | } 58 | 59 | export default LocalUserScreen; 60 | -------------------------------------------------------------------------------- /frontend/src/components/Cam/Screen/MainScreen.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import { ToggleStoreContext } from '../ToggleStore'; 5 | import SharedScreen from '../SharedScreen/SharedScreen'; 6 | import { CamStoreContext } from '../CamStore'; 7 | import type { Screen } from '../../../types/cam'; 8 | import LocalUserScreen from './LocalUserScreen'; 9 | import UserScreen from './UserScreen'; 10 | import { SharedScreenStoreContext } from '../SharedScreen/SharedScreenStore'; 11 | 12 | const Container = styled.div<{ activeTab: string[] }>` 13 | width: ${(props) => props.activeTab[0]}; 14 | height: 90vh; 15 | background-color: black; 16 | display: flex; 17 | align-items: center; 18 | flex-wrap: wrap; 19 | transition: all 0.5s ease; 20 | `; 21 | 22 | function MainScreen(): JSX.Element { 23 | const { screenList } = useContext(CamStoreContext); 24 | const { isChattingTabActive } = useContext(ToggleStoreContext); 25 | const { sharedScreen } = useContext(SharedScreenStoreContext); 26 | 27 | const countActiveTab = (): string[] => { 28 | if (isChattingTabActive) return ['70vw', '98vw']; 29 | return ['98vw', '70vw']; 30 | }; 31 | 32 | const handleAnimationEnd = (e: React.AnimationEvent) => { 33 | e.currentTarget.style.animation = 'none'; 34 | }; 35 | 36 | if (sharedScreen !== null) { 37 | return ( 38 | 39 | 40 | 41 | ); 42 | } 43 | 44 | return ( 45 | 46 | 47 | {screenList.map((screen: Screen) => ( 48 | 54 | ))} 55 | 56 | ); 57 | } 58 | 59 | export default MainScreen; 60 | -------------------------------------------------------------------------------- /frontend/src/components/Cam/Screen/StreamStatusIndicator.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import { ReactComponent as MicIcon } from '../../../assets/icons/mic.svg'; 5 | import { ReactComponent as VideoIcon } from '../../../assets/icons/video.svg'; 6 | 7 | const Container = styled.div` 8 | position: relative; 9 | bottom: 20px; 10 | display: flex; 11 | align-items: center; 12 | background-color: rgba(0, 0, 0, 0.5); 13 | `; 14 | 15 | const Button = styled.div<{ status?: boolean }>` 16 | width: 16px; 17 | height: 16px; 18 | margin: 0 3px; 19 | 20 | display: flex; 21 | flex-direction: column; 22 | justify-content: flex-start; 23 | align-items: center; 24 | color: ${(props) => (props.status ? '#00ff2e' : 'red')}; 25 | `; 26 | 27 | const Nickname = styled.div` 28 | color: white; 29 | `; 30 | 31 | type StreamStatusIndicatorProps = { 32 | micStatus: boolean; 33 | videoStatus: boolean; 34 | nickname: string; 35 | }; 36 | function StreamStatusIndicator(props: StreamStatusIndicatorProps): JSX.Element { 37 | const { micStatus, videoStatus, nickname } = props; 38 | 39 | return ( 40 | 41 | {nickname} 42 | 45 | 48 | 49 | ); 50 | } 51 | 52 | export default StreamStatusIndicator; 53 | -------------------------------------------------------------------------------- /frontend/src/components/Cam/SharedScreen/SharedScreen.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | const Container = styled.div` 5 | width: 100%; 6 | height: 100%; 7 | `; 8 | 9 | const Video = styled.video` 10 | width: 100%; 11 | height: 100%; 12 | `; 13 | 14 | type SharedScreenProps = { 15 | stream: MediaStream | null; 16 | }; 17 | 18 | function SharedScreen(props: SharedScreenProps): JSX.Element | null { 19 | const { stream } = props; 20 | if (!stream) { 21 | return null; 22 | } 23 | 24 | const videoRef = useRef(null); 25 | useEffect(() => { 26 | const video = videoRef.current; 27 | if (!video) { 28 | return; 29 | } 30 | video.srcObject = stream; 31 | }, []); 32 | 33 | return ( 34 | 35 | 38 | 39 | ); 40 | } 41 | 42 | export default SharedScreen; 43 | -------------------------------------------------------------------------------- /frontend/src/components/Cam/SharedScreen/SharedScreenStore.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useEffect, useRef, useState } from 'react'; 2 | 3 | import SharedScreenReceiver from '../../../utils/sharedScreenReceiver'; 4 | import SharedScreenSender from '../../../utils/sharedScreenSender'; 5 | import { ToggleStoreContext } from '../ToggleStore'; 6 | import { CamStoreContext } from '../CamStore'; 7 | 8 | type SharedScreenStoreProps = { 9 | children: React.ReactChild[] | React.ReactChild; 10 | }; 11 | 12 | export const SharedScreenStoreContext = createContext(null); 13 | 14 | function SharedScreenStore(props: SharedScreenStoreProps): JSX.Element { 15 | const { children } = props; 16 | const { socket } = useContext(CamStoreContext); 17 | 18 | const [sharedScreen, setSharedScreen] = useState(null); 19 | const [sharedFromMe, setSharedFromMe] = useState(false); 20 | 21 | const sharedScreenReceiverRef = useRef(); 22 | const sharedScreenSenderRef = useRef(); 23 | 24 | const { setUserListTabActive } = useContext(ToggleStoreContext); 25 | 26 | const currentURL = new URL(window.location.href); 27 | const roomId = currentURL.searchParams.get('roomid'); 28 | 29 | useEffect(() => { 30 | if (!roomId) { 31 | return undefined; 32 | } 33 | 34 | sharedScreenReceiverRef.current = new SharedScreenReceiver(socket, roomId, setSharedScreen); 35 | return () => sharedScreenReceiverRef.current?.close(); 36 | }, []); 37 | 38 | const tryShareScreen = async () => { 39 | if (!roomId) { 40 | return; 41 | } 42 | try { 43 | const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }); 44 | stream.getVideoTracks()[0].addEventListener('ended', () => { 45 | setSharedScreen(null); 46 | sharedScreenSenderRef.current?.stopSharingScreen(); 47 | }); 48 | setSharedScreen(stream); 49 | setSharedFromMe(true); 50 | sharedScreenSenderRef.current = new SharedScreenSender(socket, roomId); 51 | sharedScreenSenderRef.current.prepareScreenShare(stream); 52 | } catch (error) { 53 | // do nothing 54 | } 55 | }; 56 | 57 | const handleScreenShareActive = (): void => { 58 | if (!sharedScreen) { 59 | tryShareScreen(); 60 | } 61 | 62 | if (sharedScreen && sharedFromMe) { 63 | setSharedScreen(null); 64 | setSharedFromMe(false); 65 | sharedScreenSenderRef.current?.stopSharingScreen(); 66 | sharedScreenSenderRef.current = undefined; 67 | } 68 | }; 69 | 70 | useEffect(() => { 71 | if (sharedScreen) { 72 | setUserListTabActive(true); 73 | } else { 74 | setUserListTabActive(false); 75 | } 76 | }, [sharedScreen]); 77 | 78 | return ( 79 | 80 | {children} 81 | 82 | ); 83 | } 84 | 85 | export default SharedScreenStore; 86 | -------------------------------------------------------------------------------- /frontend/src/components/Cam/ToggleStore.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useState } from 'react'; 2 | 3 | export const ToggleStoreContext = createContext(null); 4 | 5 | type CamToggleStoreProps = { 6 | children: React.ReactChild[] | React.ReactChild; 7 | }; 8 | 9 | function ToggleStore(props: CamToggleStoreProps): JSX.Element { 10 | const { children } = props; 11 | 12 | const [isUserListTabActive, setUserListTabActive] = useState(false); 13 | const [isChattingTabActive, setChattingTabActive] = useState(true); 14 | 15 | const handleUserListTabActive = (): void => { 16 | setUserListTabActive(!isUserListTabActive); 17 | }; 18 | 19 | const handleChattingTabActive = (): void => { 20 | setChattingTabActive(!isChattingTabActive); 21 | }; 22 | 23 | return ( 24 | 33 | {children} 34 | 35 | ); 36 | } 37 | 38 | export default ToggleStore; 39 | -------------------------------------------------------------------------------- /frontend/src/components/Login/LoginCallback.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react'; 2 | import { Navigate } from 'react-router-dom'; 3 | import styled from 'styled-components'; 4 | 5 | import { flex } from '../../utils/styledComponentFunc'; 6 | 7 | const Container = styled.div` 8 | width: 100vw; 9 | height: 100vh; 10 | background-color: #492148; 11 | ${flex('row', 'center')} 12 | 13 | padding-top: 100px; 14 | font-size: 36px; 15 | font-weight: bold; 16 | color: #eeeeee; 17 | `; 18 | 19 | const requestLogin = async (code: string, service: string): Promise => { 20 | const response = await fetch(`/api/login/${service}?code=${code}`); 21 | if (!response.ok) { 22 | throw new Error(`${response.status}`); 23 | } 24 | await response.json(); 25 | }; 26 | 27 | type LoginCallbackProps = { 28 | service: 'github'; 29 | }; 30 | 31 | function LoginCallback(props: LoginCallbackProps): JSX.Element { 32 | const { service } = props; 33 | const [loading, setLoading] = useState(true); 34 | const [isSuccess, setIsSuccess] = useState(false); 35 | const [loginStatus, setLoginStatus] = useState('로그인 중'); 36 | const loginStatusRef = useRef('로그인 중'); 37 | 38 | const urlParams = new URLSearchParams(window.location.search); 39 | const code = urlParams.get('code'); 40 | 41 | if (!code) { 42 | return ; 43 | } 44 | 45 | useEffect(() => { 46 | const tryLogin = async () => { 47 | try { 48 | await requestLogin(code, service); 49 | setIsSuccess(true); 50 | } catch (e) { 51 | setIsSuccess(false); 52 | } finally { 53 | setLoading(false); 54 | } 55 | }; 56 | 57 | tryLogin(); 58 | }, [code]); 59 | 60 | const changeLoginStatusMessage = () => { 61 | loginStatusRef.current += '.'; 62 | if (loginStatusRef.current.length > 8) { 63 | loginStatusRef.current = loginStatusRef.current.substring(0, 5); 64 | } 65 | setLoginStatus(loginStatusRef.current); 66 | }; 67 | 68 | useEffect(() => { 69 | const intervalId = setInterval(changeLoginStatusMessage, 200); 70 | 71 | return () => { 72 | clearInterval(intervalId); 73 | }; 74 | }, []); 75 | 76 | if (isSuccess) { 77 | return ; 78 | } 79 | 80 | if (loading) { 81 | return ( 82 | 83 |
{loginStatus}
84 |
85 | ); 86 | } 87 | 88 | return ; 89 | } 90 | 91 | export default LoginCallback; 92 | -------------------------------------------------------------------------------- /frontend/src/components/Login/LoginMain.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import OAuthLogin from './OAuthLogin'; 5 | import boostCamIcon from '../../assets/icons/cover_new.png'; 6 | import { flex } from '../../utils/styledComponentFunc'; 7 | 8 | const Container = styled.div` 9 | width: 100vw; 10 | height: 100vh; 11 | background-color: #492148; 12 | ${flex('column', 'space-between', 'center')} 13 | `; 14 | 15 | const LoginBox = styled.div` 16 | min-width: 500px; 17 | min-height: 600px; 18 | background-color: white; 19 | border-radius: 20px; 20 | margin: 30px 0px; 21 | padding: 30px 0px; 22 | ${flex('column', 'space-around', 'center')} 23 | `; 24 | 25 | const WelcomeMessage = styled.span` 26 | font-size: 30px; 27 | color: black; 28 | `; 29 | 30 | const Im = styled.img` 31 | height: 200px; 32 | `; 33 | function LoginMain(): JSX.Element { 34 | return ( 35 | 36 | 37 | Welcome to boostCam! 38 | 39 | 40 | 41 | 42 | ); 43 | } 44 | 45 | export default LoginMain; 46 | -------------------------------------------------------------------------------- /frontend/src/components/Login/OAuthLogin.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { flex } from '../../utils/styledComponentFunc'; 4 | import { BoostCamMainIcons } from '../../utils/svgIcons'; 5 | 6 | const { Github } = BoostCamMainIcons; 7 | 8 | const Container = styled.div` 9 | ${flex('row', 'center', 'center')} 10 | width: 100%; 11 | max-width: 500px; 12 | `; 13 | 14 | const OAuthLoginButton = styled.div` 15 | width: 70%; 16 | margin-top: 15px; 17 | height: 25px; 18 | background-color: #92508f; 19 | border-radius: 10px; 20 | padding: 8px 10px; 21 | cursor: pointer; 22 | text-align: center; 23 | box-shadow: 5px 3px 3px #7c7b7b; 24 | transition: all 0.3s; 25 | color: white; 26 | 27 | ${flex('row', 'center', 'center')}; 28 | `; 29 | 30 | const GithubIcon = styled(Github)` 31 | width: 24px; 32 | margin: -3px 20px 0 0; 33 | position: absolute; 34 | left: -40px; 35 | `; 36 | 37 | const Content = styled.div` 38 | position: relative; 39 | `; 40 | 41 | function OAuthLogin(): JSX.Element { 42 | const CLIENT_ID = process.env.REACT_APP_GITHUB_CLIENT_ID; 43 | const REDIRECT_URL = process.env.REACT_APP_GITHUB_REDIRECT_URL; 44 | 45 | const onClick = () => { 46 | const url = `https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URL}`; 47 | window.location.href = url; 48 | }; 49 | 50 | return ( 51 | 52 | 53 | 54 | 55 | Log in with Github 56 | 57 | 58 | 59 | ); 60 | } 61 | 62 | export default OAuthLogin; 63 | -------------------------------------------------------------------------------- /frontend/src/components/Main/AlertModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import styled from 'styled-components'; 3 | import { flex } from '../../utils/styledComponentFunc'; 4 | 5 | import { ToggleStoreContext } from './ToggleStore'; 6 | 7 | const Container = styled.div` 8 | position: fixed; 9 | width: 100vw; 10 | height: 100vh; 11 | left: 0px; 12 | right: 0px; 13 | 14 | ${flex('column', 'space-around', 'center')} 15 | z-index: 10; 16 | `; 17 | 18 | const ModalBackground = styled.div` 19 | position: fixed; 20 | left: 0px; 21 | right: 0px; 22 | width: 100%; 23 | height: 100%; 24 | background-color: rgb(0, 0, 0, 0.5); 25 | `; 26 | 27 | function AlertModal(): JSX.Element { 28 | const { setIsAlertModalOpen, alertModalContents } = useContext(ToggleStoreContext); 29 | 30 | return ( 31 | 32 | setIsAlertModalOpen(false)} /> 33 | {alertModalContents} 34 | 35 | ); 36 | } 37 | 38 | export default AlertModal; 39 | -------------------------------------------------------------------------------- /frontend/src/components/Main/Cam/List/CamList.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState } from 'react'; 2 | import styled from 'styled-components'; 3 | import { flex } from '../../../../utils/styledComponentFunc'; 4 | import { MainStoreContext } from '../../MainStore'; 5 | 6 | import CamListHeader from './CamListHeader'; 7 | import CamListItem from './CamListItem'; 8 | 9 | const Container = styled.div` 10 | width: 100%; 11 | height: 100%; 12 | background-color: #492148; 13 | 14 | margin-top: 10px; 15 | ${flex('column', 'flex-start', 'flex-start')} 16 | `; 17 | 18 | const CamListBody = styled.div` 19 | width: 100%; 20 | ${flex('column', 'flex-start', 'flex-start')} 21 | color: #a69c96; 22 | font-size: 15px; 23 | `; 24 | 25 | function CamList(): JSX.Element { 26 | const [isListOpen, setIsListOpen] = useState(true); 27 | const { serverCamList } = useContext(MainStoreContext); 28 | 29 | const listElements = serverCamList.map((cam: { id: number; name: string; url: string }) => ( 30 | 31 | )); 32 | 33 | return ( 34 | 35 | 36 | {isListOpen && {listElements}} 37 | 38 | ); 39 | } 40 | 41 | export default CamList; 42 | -------------------------------------------------------------------------------- /frontend/src/components/Main/Cam/List/CamListHeader.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import styled from 'styled-components'; 3 | import { flex } from '../../../../utils/styledComponentFunc'; 4 | 5 | import { BoostCamMainIcons } from '../../../../utils/svgIcons'; 6 | import Dropdown from '../../../core/Dropdown'; 7 | import DropdownMenu from '../../../core/DropdownMenu'; 8 | import CreateCamModal from '../Modal/CreateCamModal'; 9 | 10 | const { Plus, ListArrow } = BoostCamMainIcons; 11 | 12 | const Container = styled.div` 13 | width: 90%; 14 | height: 30px; 15 | 16 | margin-left: 15px; 17 | color: #a69c96; 18 | font-size: 17px; 19 | ${flex('row', 'flex-start', 'center')} 20 | &:hover { 21 | cursor: pointer; 22 | } 23 | `; 24 | 25 | const CamListHeaderSpan = styled.span` 26 | margin-left: 5px; 27 | `; 28 | 29 | const CamListHeaderButton = styled.div<{ isButtonVisible: boolean }>` 30 | margin-left: 70px; 31 | margin-top: 3px; 32 | visibility: ${(props) => (props.isButtonVisible ? 'visible' : 'hidden')}; 33 | ${flex('column', 'center', 'center')} 34 | `; 35 | 36 | const ListArrowIcon = styled(ListArrow)<{ $isListOpen: boolean }>` 37 | width: 20px; 38 | height: 20px; 39 | fill: #a69c96; 40 | transition: all ease-out 0.3s; 41 | ${(props) => (props.$isListOpen ? 'transform: rotate(90deg);' : 'transform: rotate(0deg);')} 42 | `; 43 | 44 | const PlusIcon = styled(Plus)` 45 | width: 20px; 46 | height: 20px; 47 | fill: #a69c96; 48 | `; 49 | 50 | type CamListHeaderProps = { 51 | isListOpen: boolean; 52 | setIsListOpen: React.Dispatch>; 53 | }; 54 | 55 | function CamListHeader(props: CamListHeaderProps): JSX.Element { 56 | const [isButtonVisible, setIsButtonVisible] = useState(false); 57 | const [isDropdownActivated, setIsDropdownActivated] = useState(false); 58 | const { isListOpen, setIsListOpen } = props; 59 | 60 | const onClickCamAddButton = (e: React.MouseEvent) => { 61 | e.stopPropagation(); 62 | setIsDropdownActivated(!isDropdownActivated); 63 | }; 64 | 65 | return ( 66 | setIsButtonVisible(true)} 68 | onMouseLeave={() => setIsButtonVisible(false)} 69 | onClick={() => setIsListOpen(!isListOpen)} 70 | > 71 | 72 | Cam 73 | 74 | 75 | 76 | , 81 | title: 'Cam 생성', 82 | description: '생성할 Cam의 이름을 작성해주세요', 83 | height: '40%', 84 | minHeight: '350px', 85 | }} 86 | /> 87 | 88 | 89 | 90 | ); 91 | } 92 | 93 | export default CamListHeader; 94 | -------------------------------------------------------------------------------- /frontend/src/components/Main/Cam/List/CamListItem.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import styled from 'styled-components'; 4 | import { flex } from '../../../../utils/styledComponentFunc'; 5 | 6 | import { BoostCamMainIcons } from '../../../../utils/svgIcons'; 7 | import { ToggleStoreContext } from '../../ToggleStore'; 8 | import CamDeleteModal from '../Modal/CamDeleteModal'; 9 | 10 | const { Hash } = BoostCamMainIcons; 11 | 12 | type CamListItemProps = { 13 | id: number; 14 | name: string; 15 | url: string; 16 | }; 17 | 18 | const Container = styled.div` 19 | width: 100%; 20 | height: 25px; 21 | ${flex('row', 'flex-start', 'center')} 22 | 23 | box-sizing: border-box; 24 | padding: 15px 0px 15px 25px; 25 | 26 | white-space: nowrap; 27 | overflow: hidden; 28 | text-overflow: ellipsis; 29 | color: inherit; 30 | 31 | &:hover { 32 | cursor: pointer; 33 | } 34 | `; 35 | 36 | const HashIcon = styled(Hash)` 37 | width: 15px; 38 | min-width: 15px; 39 | height: 15px; 40 | min-height: 15px; 41 | fill: #a69c96; 42 | `; 43 | 44 | const CamNameSpan = styled.span` 45 | padding: 5px 0px 5px 5px; 46 | `; 47 | 48 | function CamListItem(props: CamListItemProps): JSX.Element { 49 | const { id, name, url } = props; 50 | const navigate = useNavigate(); 51 | const { setIsDropdownOpen, setDropdownInfo } = useContext(ToggleStoreContext); 52 | 53 | const onClickCam = () => { 54 | navigate(`/cam?roomid=${url}`); 55 | }; 56 | 57 | const onContextCam = (e: React.MouseEvent) => { 58 | e.preventDefault(); 59 | 60 | const dropdownInfo = { 61 | position: [e.pageX, e.pageY], 62 | components: [ 63 | { 64 | name: '삭제', 65 | component: { 66 | contents: , 67 | title: 'cam 삭제', 68 | description: `${name} cam을 삭제하시겠습니까?`, 69 | height: '30%', 70 | minHeight: '250px', 71 | }, 72 | }, 73 | ], 74 | }; 75 | 76 | setDropdownInfo(dropdownInfo); 77 | setIsDropdownOpen(true); 78 | }; 79 | 80 | return ( 81 | 82 | 83 | {name} 84 | 85 | ); 86 | } 87 | 88 | export default CamListItem; 89 | -------------------------------------------------------------------------------- /frontend/src/components/Main/Cam/Modal/CamDeleteModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { fetchData } from '../../../../utils/fetchMethods'; 3 | import OkCancelModal from '../../../core/OkCancelModal'; 4 | import { MainStoreContext } from '../../MainStore'; 5 | import { ToggleStoreContext } from '../../ToggleStore'; 6 | 7 | type CamDeleteModalProps = { 8 | camId: number; 9 | }; 10 | 11 | function CamDeleteModal(props: CamDeleteModalProps): JSX.Element { 12 | const { camId } = props; 13 | const { getServerCamList } = useContext(MainStoreContext); 14 | const { setIsModalOpen } = useContext(ToggleStoreContext); 15 | 16 | const handleClickOk = async () => { 17 | // 추후 status code에 따른 error landing 방법을 다른 모달까지 한꺼번에 구현해야합니다. 18 | await fetchData('DELETE', `/api/cam/${camId}`); 19 | getServerCamList(); 20 | setIsModalOpen(false); 21 | }; 22 | 23 | const handleClickCancel = () => { 24 | setIsModalOpen(false); 25 | }; 26 | 27 | return ; 28 | } 29 | 30 | export default CamDeleteModal; 31 | -------------------------------------------------------------------------------- /frontend/src/components/Main/Channel/List/ChannelList.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useState } from 'react'; 2 | import { useNavigate, createSearchParams } from 'react-router-dom'; 3 | import styled from 'styled-components'; 4 | 5 | import { ChannelListData } from '../../../../types/main'; 6 | import { flex } from '../../../../utils/styledComponentFunc'; 7 | import { MainStoreContext } from '../../MainStore'; 8 | import ChannelListHeader from './ChannelListHeader'; 9 | import ChannelListItem from './ChannelListItem'; 10 | 11 | const Container = styled.div` 12 | width: 100%; 13 | background-color: #492148; 14 | 15 | margin-top: 10px; 16 | ${flex('column', 'flex-start', 'flex-start')} 17 | `; 18 | 19 | const ChannelListBody = styled.div` 20 | width: 100%; 21 | ${flex('column', 'flex-start', 'flex-start')} 22 | 23 | color: #a69c96; 24 | font-size: 15px; 25 | `; 26 | function ChannelList(): JSX.Element { 27 | const [isListOpen, setIsListOpen] = useState(true); 28 | const { selectedServer, selectedChannel, serverChannelList } = useContext(MainStoreContext); 29 | const navigate = useNavigate(); 30 | useEffect(() => { 31 | const serverId = selectedServer?.server?.id || 'none'; 32 | 33 | navigate({ 34 | search: `?${createSearchParams({ 35 | serverId, 36 | channelId: selectedChannel, 37 | })}`, 38 | }); 39 | }, [selectedChannel]); 40 | 41 | const listElements = serverChannelList.map((val: ChannelListData): JSX.Element => { 42 | const selected = val.id === selectedChannel; 43 | return ; 44 | }); 45 | 46 | return ( 47 | 48 | 49 | {isListOpen && {listElements}} 50 | 51 | ); 52 | } 53 | 54 | export default ChannelList; 55 | -------------------------------------------------------------------------------- /frontend/src/components/Main/Channel/Modal/NoAuthModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import noAuthImg from '../../../../assets/hmm.gif'; 4 | import { flex } from '../../../../utils/styledComponentFunc'; 5 | 6 | const ModalDescriptionDiv = styled.div` 7 | flex: 3 1 0; 8 | width: 90%; 9 | margin: 50px 0px 0px 25px; 10 | ${flex('column', 'center', 'center')}; 11 | `; 12 | 13 | const ModalDescription = styled.span` 14 | padding: 10px 5px; 15 | margin-left: 25px; 16 | color: #cbc4b9; 17 | font-size: 15px; 18 | `; 19 | 20 | const NoAuthImg = styled.img` 21 | width: 150px; 22 | height: 150px; 23 | margin-bottom: 25px; 24 | `; 25 | 26 | function NoAuthModal(): JSX.Element { 27 | return ( 28 | 29 | 30 | 이 채널에 대한 수정 권한이 없습니다! 31 | 32 | ); 33 | } 34 | 35 | export default NoAuthModal; 36 | -------------------------------------------------------------------------------- /frontend/src/components/Main/Channel/Modal/QuitChannelModal .tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | 3 | import { MainStoreContext } from '../../MainStore'; 4 | import { fetchData } from '../../../../utils/fetchMethods'; 5 | import OkCancelModal from '../../../core/OkCancelModal'; 6 | import { ToggleStoreContext } from '../../ToggleStore'; 7 | 8 | function QuitChannelModal(): JSX.Element { 9 | const { selectedServer, rightClickedChannelId, getServerChannelList } = useContext(MainStoreContext); 10 | const { setIsModalOpen } = useContext(ToggleStoreContext); 11 | 12 | const handleClickOk = async () => { 13 | await fetchData( 14 | 'DELETE', 15 | `/api/user/servers/${selectedServer.server.id}/channels/${rightClickedChannelId}`, 16 | ); 17 | getServerChannelList(); 18 | setIsModalOpen(false); 19 | }; 20 | 21 | const handleClickCancel = () => { 22 | setIsModalOpen(false); 23 | }; 24 | return ; 25 | } 26 | 27 | export default QuitChannelModal; 28 | -------------------------------------------------------------------------------- /frontend/src/components/Main/ContentsSection/NotFoundChannel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import { MainStoreContext } from '../MainStore'; 5 | import noInfoImg from '../../../assets/hmm.gif'; 6 | import { flex } from '../../../utils/styledComponentFunc'; 7 | 8 | const Container = styled.div` 9 | width: 100%; 10 | height: 100%; 11 | ${flex('column', 'center', 'center')} 12 | `; 13 | 14 | const NoChannelInfoDescription = styled.span` 15 | margin-top: 20px; 16 | font-size: 25px; 17 | `; 18 | 19 | const NoInfoImg = styled.img` 20 | width: 150px; 21 | height: 150px; 22 | margin-bottom: 25px; 23 | `; 24 | 25 | const ResetButton = styled.button` 26 | width: 250px; 27 | height: 50px; 28 | background: none; 29 | padding: 15px 10px; 30 | margin: 15px 0px 0px 0px; 31 | border: 0; 32 | outline: 0; 33 | text-align: center; 34 | vertical-align: middle; 35 | border-radius: 10px; 36 | background-color: #26a9ca; 37 | cursor: pointer; 38 | transition: all 0.3s; 39 | &:hover { 40 | background-color: #2dc2e6; 41 | transition: all 0.3s; 42 | } 43 | `; 44 | 45 | function NotFoundChannel(): JSX.Element { 46 | const { getServerChannelList } = useContext(MainStoreContext); 47 | 48 | const onClickChannelListResetButton = () => { 49 | getServerChannelList(); 50 | }; 51 | 52 | return ( 53 | 54 | 55 | 채널이 존재하지 않습니다... 56 | 새고로침 57 | 58 | ); 59 | } 60 | 61 | export default NotFoundChannel; 62 | -------------------------------------------------------------------------------- /frontend/src/components/Main/ContentsSection/ServerJoinSection.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState } from 'react'; 2 | import styled from 'styled-components'; 3 | import { fetchData } from '../../../utils/fetchMethods'; 4 | import { flex } from '../../../utils/styledComponentFunc'; 5 | import { MainStoreContext } from '../MainStore'; 6 | 7 | const Container = styled.div` 8 | width: 100%; 9 | height: 100%; 10 | color: #dcd6d0; 11 | ${flex('column', 'center', 'center')} 12 | `; 13 | 14 | const Title = styled.span` 15 | font-size: 36px; 16 | margin-bottom: 20px; 17 | `; 18 | const SubTitle = styled.span` 19 | margin-bottom: 10px; 20 | `; 21 | const CodeBox = styled.div` 22 | ${flex('row', 'center', 'center')} 23 | 24 | margin-bottom: 10px; 25 | `; 26 | const ServerCode = styled.input` 27 | width: 200px; 28 | height: 30px; 29 | border-radius: 10px; 30 | font-weight: bold; 31 | outline: none; 32 | border: none; 33 | `; 34 | const PostCode = styled.button` 35 | width: 60px; 36 | height: 32px; 37 | background-color: #26a9ca; 38 | border: none; 39 | border-radius: 5px; 40 | font-weight: bold; 41 | margin-left: 10px; 42 | &:hover { 43 | background-color: #2dc2e6; 44 | cursor: pointer; 45 | } 46 | `; 47 | 48 | const ErrorMessage = styled.div` 49 | color: red; 50 | height: 20px; 51 | font-size: 16px; 52 | font-family: Malgun Gothic; 53 | `; 54 | 55 | function ServerJoinSection(): JSX.Element { 56 | const { getUserServerList } = useContext(MainStoreContext); 57 | const [serverCode, setServerCode] = useState(''); 58 | const [errorMessage, setErrorMessage] = useState(''); 59 | 60 | const onclickParticipateToServer = async () => { 61 | const url = `/api/user/servers`; 62 | const body = { code: serverCode }; 63 | 64 | if (!serverCode) { 65 | setErrorMessage('참가 코드를 입력하세요.'); 66 | return; 67 | } 68 | 69 | const { statusCode, message } = await fetchData('POST', url, body); 70 | if (statusCode === 201) { 71 | getUserServerList(); 72 | } else { 73 | setErrorMessage(`${message}`); 74 | } 75 | }; 76 | 77 | return ( 78 | 79 | 참가중인 서버가 없습니다. 80 | 서버 참가 코드를 입력하세요. 81 | 82 | setServerCode(e.target.value)} /> 83 | 84 | 참가 85 | 86 | 87 | {errorMessage} 88 | 89 | ); 90 | } 91 | 92 | export default ServerJoinSection; 93 | -------------------------------------------------------------------------------- /frontend/src/components/Main/ContentsSection/UserListModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import { User } from '../../../types/user'; 5 | import { customScroll, flex } from '../../../utils/styledComponentFunc'; 6 | 7 | const Container = styled.div` 8 | width: 90%; 9 | height: 70%; 10 | margin-left: 25px; 11 | margin-bottom: 25px; 12 | 13 | ${flex('column', 'flex-start', 'center')}; 14 | 15 | color: #e5e0d8; 16 | 17 | flex: 4; 18 | 19 | overflow-y: auto; 20 | 21 | ${customScroll()}; 22 | `; 23 | 24 | const ModalUserListItem = styled.div` 25 | width: 90%; 26 | padding: 15px 10px; 27 | margin: 3px 0px 0px 0px; 28 | ${flex('row', 'flex-start', 'center')}; 29 | 30 | font-size: 18px; 31 | border-top: 1px solid #e5e0d8; 32 | &:last-child { 33 | border-bottom: 1px solid #e5e0d8; 34 | } 35 | 36 | &:hover { 37 | button { 38 | visibility: visible; 39 | } 40 | background-color: #282929; 41 | } 42 | 43 | button { 44 | visibility: hidden; 45 | } 46 | `; 47 | 48 | const ItemIcon = styled.div<{ imgUrl: string }>` 49 | width: 36px; 50 | height: 36px; 51 | margin: 10px; 52 | background-image: url(${(props) => props.imgUrl}); 53 | background-size: cover; 54 | background-repeat: no-repeat; 55 | border-radius: 8px; 56 | `; 57 | 58 | const ItemName = styled.span` 59 | margin-left: 15px; 60 | font-size: 20px; 61 | font-weight: 600; 62 | `; 63 | 64 | type UserListModalProps = { 65 | userList: User[]; 66 | }; 67 | 68 | function UserListModal(props: UserListModalProps): JSX.Element { 69 | const { userList } = props; 70 | 71 | const userListElements = () => { 72 | return userList.map((data) => { 73 | return ( 74 | 75 | 76 | {data.nickname} 77 | 78 | ); 79 | }); 80 | }; 81 | 82 | return {userListElements()}; 83 | } 84 | 85 | export default UserListModal; 86 | -------------------------------------------------------------------------------- /frontend/src/components/Main/Main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import MainPage from './MainPage'; 3 | import MainStore from './MainStore'; 4 | import ToggleStore from './ToggleStore'; 5 | 6 | function Main(): JSX.Element { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | ); 14 | } 15 | 16 | export default Main; 17 | -------------------------------------------------------------------------------- /frontend/src/components/Main/MainPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useState } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import ServerListTab from './Server/List/ServerListTab'; 5 | import MainSection from './MainSection'; 6 | import MainModal from '../core/MainModal'; 7 | 8 | import MainDropdown from '../core/MainDropdown'; 9 | import AlertModal from './AlertModal'; 10 | import Loading from '../core/Loading'; 11 | import { MainStoreContext } from './MainStore'; 12 | import { flex } from '../../utils/styledComponentFunc'; 13 | import { ToggleStoreContext } from './ToggleStore'; 14 | 15 | const Container = styled.div` 16 | width: 100vw; 17 | height: 100vh; 18 | 19 | ${flex('row', 'flex-start', 'center')} 20 | `; 21 | 22 | function MainPage(): JSX.Element { 23 | const { getUserServerList } = useContext(MainStoreContext); 24 | const { isModalOpen, isAlertModalOpen } = useContext(ToggleStoreContext); 25 | const [isLoading, setIsLoading] = useState(true); 26 | 27 | const setUserServerList = async () => { 28 | await getUserServerList(); 29 | setIsLoading(false); 30 | }; 31 | 32 | useEffect(() => { 33 | setUserServerList(); 34 | }, []); 35 | 36 | if (isLoading) { 37 | return ; 38 | } 39 | 40 | return ( 41 | 42 | 43 | {isModalOpen && } 44 | {isAlertModalOpen && } 45 | 46 | 47 | 48 | ); 49 | } 50 | 51 | export default MainPage; 52 | -------------------------------------------------------------------------------- /frontend/src/components/Main/RoomListSection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import ChannelList from './Channel/List/ChannelList'; 5 | import CamList from './Cam/List/CamList'; 6 | 7 | const Container = styled.div` 8 | width: 180px; 9 | height: 100%; 10 | background-color: #492148; 11 | `; 12 | 13 | function RoomListSection(): JSX.Element { 14 | return ( 15 | 16 | 17 | 18 | 19 | ); 20 | } 21 | 22 | export default RoomListSection; 23 | -------------------------------------------------------------------------------- /frontend/src/components/Main/Server/Modal/QuitServerModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import { MainStoreContext } from '../../MainStore'; 5 | import { deleteApi } from '../../../../utils/fetchMethods'; 6 | import { flex } from '../../../../utils/styledComponentFunc'; 7 | import { ToggleStoreContext } from '../../ToggleStore'; 8 | 9 | const Container = styled.form` 10 | width: 90%; 11 | height: 40%; 12 | border-radius: 20px; 13 | margin: 30px 0px 0px 25px; 14 | ${flex('column', 'flex-start', 'flex-start')} 15 | `; 16 | 17 | const SubmitButton = styled.button<{ isButtonActive: boolean }>` 18 | width: 100px; 19 | height: 50px; 20 | background: none; 21 | 22 | padding: 15px 10px; 23 | 24 | border: 0; 25 | outline: 0; 26 | 27 | text-align: center; 28 | vertical-align: middle; 29 | 30 | border-radius: 10px; 31 | background-color: ${(props) => (props.isButtonActive ? '#26a9ca' : 'gray')}; 32 | cursor: pointer; 33 | transition: all 0.3s; 34 | 35 | &:hover { 36 | background-color: ${(props) => (props.isButtonActive ? '#2dc2e6' : 'gray')}; 37 | transition: all 0.3s; 38 | } 39 | `; 40 | 41 | const MessageFailToPost = styled.span` 42 | color: red; 43 | font-size: 16px; 44 | font-family: Malgun Gothic; 45 | `; 46 | 47 | function QuitServerModal(): JSX.Element { 48 | const { selectedServer, getUserServerList } = useContext(MainStoreContext); 49 | const { setIsModalOpen } = useContext(ToggleStoreContext); 50 | const isButtonActive = true; 51 | const [messageFailToPost, setMessageFailToPost] = useState(''); 52 | 53 | const onClickQuitServer = async () => { 54 | const userServerId = selectedServer.id; 55 | const { statusCode, message } = await deleteApi(`/api/user/servers/${userServerId}`); 56 | if (statusCode === 204) { 57 | const calledStatus = 'deleted'; 58 | getUserServerList(calledStatus); 59 | setIsModalOpen(false); 60 | } else { 61 | setMessageFailToPost(`${message}`); 62 | } 63 | }; 64 | 65 | return ( 66 | 67 | {messageFailToPost} 68 | 69 | 예 70 | 71 | 72 | ); 73 | } 74 | 75 | export default QuitServerModal; 76 | -------------------------------------------------------------------------------- /frontend/src/components/Main/Server/Modal/ServerDeleteCheckModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState } from 'react'; 2 | import styled from 'styled-components'; 3 | import { deleteApi } from '../../../../utils/fetchMethods'; 4 | import { flex } from '../../../../utils/styledComponentFunc'; 5 | import { MainStoreContext } from '../../MainStore'; 6 | import { ToggleStoreContext } from '../../ToggleStore'; 7 | 8 | const MessageFailToPost = styled.span` 9 | color: red; 10 | font-size: 16px; 11 | font-family: Malgun Gothic; 12 | `; 13 | 14 | const Container = styled.div` 15 | position: relative; 16 | padding: 20px; 17 | width: 300px; 18 | height: 100px; 19 | background-color: #222322; 20 | border-radius: 10px; 21 | 22 | ${flex('column', 'space-between')} 23 | `; 24 | 25 | const Title = styled.span` 26 | color: #cbc4b9; 27 | font-size: 26px; 28 | font-weight: 600; 29 | `; 30 | 31 | const ButtonBox = styled.div` 32 | display: flex; 33 | `; 34 | 35 | const DeleteButton = styled.button` 36 | width: 80px; 37 | height: 30px; 38 | font-weight: bold; 39 | border: 0; 40 | outline: 0; 41 | text-align: center; 42 | vertical-align: middle; 43 | 44 | border-radius: 10px; 45 | background-color: red; 46 | cursor: pointer; 47 | &:hover { 48 | background-color: #ff3b1c; 49 | } 50 | `; 51 | 52 | const CancelButton = styled.button` 53 | width: 80px; 54 | height: 30px; 55 | font-weight: bold; 56 | border: 0; 57 | outline: 0; 58 | text-align: center; 59 | vertical-align: middle; 60 | 61 | border-radius: 10px; 62 | background-color: gray; 63 | cursor: pointer; 64 | margin-left: 10px; 65 | 66 | &:hover { 67 | background-color: #888888; 68 | } 69 | `; 70 | 71 | type ServerDeleteCheckModalProps = { 72 | serverId: number; 73 | }; 74 | function ServerDeleteCheckModal(props: ServerDeleteCheckModalProps): JSX.Element { 75 | const { serverId } = props; 76 | const { getUserServerList } = useContext(MainStoreContext); 77 | const { setIsModalOpen, setIsAlertModalOpen } = useContext(ToggleStoreContext); 78 | const [messageFailToDelete, setMessageFailToDelete] = useState(''); 79 | 80 | const onClickDeleteServer = async () => { 81 | if (serverId) { 82 | const { statusCode, message } = await deleteApi(`/api/servers/${serverId}`); 83 | 84 | if (statusCode === 204) { 85 | getUserServerList(); 86 | setIsModalOpen(false); 87 | setIsAlertModalOpen(false); 88 | } else { 89 | setMessageFailToDelete(`${message}`); 90 | } 91 | } 92 | }; 93 | 94 | return ( 95 | 96 | 정말 삭제하시겠습니까? 97 | {messageFailToDelete} 98 | 99 | 삭제 100 | setIsAlertModalOpen(false)}>취소 101 | 102 | 103 | ); 104 | } 105 | 106 | export default ServerDeleteCheckModal; 107 | -------------------------------------------------------------------------------- /frontend/src/components/Main/ToggleStore.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useState } from 'react'; 2 | import { DropdownInfo } from '../../types/dropdown'; 3 | import ModalContents from '../../types/modal'; 4 | 5 | export const ToggleStoreContext = createContext(null); 6 | 7 | type ToggleStoreProps = { 8 | children: React.ReactChild[] | React.ReactChild; 9 | }; 10 | 11 | function ToggleStore(props: ToggleStoreProps): JSX.Element { 12 | const { children } = props; 13 | 14 | const [isModalOpen, setIsModalOpen] = useState(false); 15 | const [modalContents, setModalContents] = useState({ 16 | contents: <>, 17 | title: '', 18 | description: '', 19 | height: '70%', 20 | minHeight: '450px', 21 | }); 22 | const [isAlertModalOpen, setIsAlertModalOpen] = useState(false); 23 | const [alertModalContents, setAlertModalContents] = useState(<>); 24 | 25 | const [isDropdownOpen, setIsDropdownOpen] = useState(false); 26 | const [dropdownInfo, setDropdownInfo] = useState({ position: [0, 0], components: [] }); 27 | 28 | return ( 29 | 45 | {children} 46 | 47 | ); 48 | } 49 | 50 | export default ToggleStore; 51 | -------------------------------------------------------------------------------- /frontend/src/components/core/Draggable.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | type DraggableProps = { 5 | children: React.ReactChild | React.ReactChild[]; 6 | defaultPosition: { 7 | x: string; 8 | y: string; 9 | }; 10 | isActive: boolean; 11 | }; 12 | 13 | const Container = styled.div<{ x: string; y: string; isActive: boolean }>` 14 | display: ${(props) => (props.isActive ? 'flex' : 'none')}; 15 | left: ${(props) => props.x}; 16 | top: ${(props) => props.y}; 17 | position: absolute; 18 | flex-direction: row; 19 | z-index: 1; 20 | &:hover { 21 | cursor: pointer; 22 | } 23 | `; 24 | 25 | function Draggable(props: DraggableProps): JSX.Element { 26 | const { children, defaultPosition, isActive } = props; 27 | const { x, y } = defaultPosition; 28 | let offsetX = 0; 29 | let offsetY = 0; 30 | 31 | const onDragStartInDraggable = (e: React.DragEvent & { target: HTMLDivElement }) => { 32 | setTimeout(() => { 33 | offsetX = e.nativeEvent.offsetX; 34 | offsetY = e.nativeEvent.offsetY; 35 | e.target.style.opacity = '0.5'; 36 | }, 10); 37 | }; 38 | const onDragEndInDraggable = (e: React.DragEvent & { target: HTMLDivElement }) => { 39 | const targetStyle = e.target.style; 40 | targetStyle.left = `${e.pageX - offsetX}px`; 41 | targetStyle.top = `${e.pageY - offsetY}px`; 42 | targetStyle.opacity = '1'; 43 | }; 44 | 45 | return ( 46 | 54 | {children} 55 | 56 | ); 57 | } 58 | 59 | export default Draggable; 60 | -------------------------------------------------------------------------------- /frontend/src/components/core/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | const Container = styled.div<{ activated: boolean }>` 5 | position: absolute; 6 | opacity: ${(props) => (props.activated ? 1 : 0)}; 7 | visibility: ${(props) => (props.activated ? 'visible' : 'hidden')}; 8 | transform: translateY(${(props) => (props.activated ? '0' : '-20')}px); 9 | transition: opacity 0.4s ease, transform 0.4s ease, visibility 0.4s; 10 | z-index: 10; 11 | `; 12 | 13 | const DropdownBackground = styled.div` 14 | position: fixed; 15 | left: 0; 16 | top: 0; 17 | width: 200vw; 18 | height: 200vh; 19 | margin-left: -50vw; 20 | margin-top: -100vh; 21 | background-color: rgb(0, 0, 0, 0.1); 22 | z-index: 3; 23 | `; 24 | 25 | const InnerContainer = styled.div` 26 | background-color: white; 27 | border-radius: 8px; 28 | position: relative; 29 | width: 100px; 30 | text-align: center; 31 | box-shadow: 0 1px 8px rgba(0, 0, 0, 0.3); 32 | z-index: 99; 33 | `; 34 | 35 | const MenuList = styled.ul` 36 | list-style: none; 37 | padding: 0; 38 | margin: 0; 39 | `; 40 | 41 | type DropdownProps = { 42 | isDropdownActivated: boolean; 43 | setIsDropdownActivated: React.Dispatch>; 44 | children: Array | React.ReactChild; 45 | }; 46 | function Dropdown(props: DropdownProps): JSX.Element { 47 | const { isDropdownActivated, setIsDropdownActivated, children } = props; 48 | 49 | const onClickDropdownBackground = (e: React.MouseEvent) => { 50 | e.stopPropagation(); 51 | setIsDropdownActivated(false); 52 | }; 53 | 54 | return ( 55 | 56 | 57 | {children} 58 | 59 | 60 | 61 | ); 62 | } 63 | 64 | export default Dropdown; 65 | -------------------------------------------------------------------------------- /frontend/src/components/core/DropdownMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import styled from 'styled-components'; 3 | import ModalContents from '../../types/modal'; 4 | import { ToggleStoreContext } from '../Main/ToggleStore'; 5 | 6 | const Container = styled.li` 7 | border-bottom: 1px solid #dddddd; 8 | 9 | padding: 2px 5px; 10 | 11 | &:last-child { 12 | border: none; 13 | } 14 | &:hover { 15 | cursor: pointer; 16 | font-weight: bold; 17 | } 18 | `; 19 | 20 | type DropdownMenuProps = { 21 | name: string; 22 | setIsDropdownActivated: React.Dispatch>; 23 | modalContents: ModalContents; 24 | }; 25 | 26 | function DropdownMenu(props: DropdownMenuProps): JSX.Element { 27 | const { setModalContents, setIsModalOpen } = useContext(ToggleStoreContext); 28 | const { name, setIsDropdownActivated, modalContents } = props; 29 | 30 | const onClickMenu = (e: React.MouseEvent) => { 31 | e.stopPropagation(); 32 | setIsModalOpen(true); 33 | setIsDropdownActivated(false); 34 | setModalContents(modalContents); 35 | }; 36 | 37 | return {name}; 38 | } 39 | 40 | export default DropdownMenu; 41 | -------------------------------------------------------------------------------- /frontend/src/components/core/Loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled, { keyframes } from 'styled-components'; 3 | import { flex } from '../../utils/styledComponentFunc'; 4 | import { BoostCamMainIcons } from '../../utils/svgIcons'; 5 | 6 | const { loading } = BoostCamMainIcons; 7 | 8 | const Container = styled.div` 9 | width: 100%; 10 | height: 100%; 11 | ${flex('column', 'center', 'center')} 12 | `; 13 | 14 | const spin = keyframes` 15 | 100%{ transform: rotate(360deg); } 16 | `; 17 | 18 | const LoadingIcon = styled(loading)` 19 | width: 120px; 20 | height: 120px; 21 | fill: #a69c96; 22 | margin-right: 15px; 23 | animation: ${spin} 1s infinite cubic-bezier(0.45, 0, 0.55, 1); 24 | `; 25 | 26 | function Loading(): JSX.Element { 27 | return ( 28 | 29 | 30 | 31 | ); 32 | } 33 | 34 | export default Loading; 35 | -------------------------------------------------------------------------------- /frontend/src/components/core/MainDropdown.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import DropdownMenu from './DropdownMenu'; 5 | import RightClickDropdown from './RightClickDropdown'; 6 | import { ComponentInfo } from '../../types/dropdown'; 7 | import { ToggleStoreContext } from '../Main/ToggleStore'; 8 | 9 | const Container = styled.div``; 10 | 11 | function MainDropdown(): JSX.Element { 12 | const { isDropdownOpen, setIsDropdownOpen, dropdownInfo } = useContext(ToggleStoreContext); 13 | const { position, components } = dropdownInfo; 14 | 15 | const dropdownMenuList = components.map((val: ComponentInfo) => { 16 | const { name, component } = val; 17 | return ; 18 | }); 19 | 20 | return ( 21 | 22 | 27 | {dropdownMenuList} 28 | 29 | 30 | ); 31 | } 32 | 33 | export default MainDropdown; 34 | -------------------------------------------------------------------------------- /frontend/src/components/core/MainModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import { BoostCamMainIcons } from '../../utils/svgIcons'; 5 | import { flex } from '../../utils/styledComponentFunc'; 6 | import { ToggleStoreContext } from '../Main/ToggleStore'; 7 | 8 | const { Close } = BoostCamMainIcons; 9 | 10 | const Container = styled.div` 11 | position: fixed; 12 | width: 100vw; 13 | height: 100vh; 14 | left: 0px; 15 | right: 0px; 16 | ${flex('column', 'space-around', 'center')} 17 | z-index:3; 18 | `; 19 | 20 | const ModalBackground = styled.div` 21 | position: fixed; 22 | left: 0px; 23 | right: 0px; 24 | width: 100%; 25 | height: 100%; 26 | background-color: rgb(0, 0, 0, 0.5); 27 | `; 28 | 29 | const Modal = styled.div<{ height: string; minHeight: string }>` 30 | width: 35%; 31 | min-width: 400px; 32 | height: ${(props) => `${props.height}`}; 33 | min-height: ${(props) => `${props.minHeight}`}; 34 | 35 | background-color: #222322; 36 | ${flex('column', 'center', 'center')}; 37 | 38 | border-radius: 20px; 39 | 40 | z-index: 5; 41 | `; 42 | 43 | const ModalInnerBox = styled.div` 44 | width: 100%; 45 | height: 100%; 46 | padding: 30px 10px; 47 | ${flex('column', 'flex-start', 'flex-start')}; 48 | `; 49 | 50 | const ModalHeader = styled.div` 51 | width: 100%; 52 | ${flex('row', 'space-between', 'center')}; 53 | flex: 1; 54 | `; 55 | 56 | const ModalTitle = styled.span` 57 | margin-left: 25px; 58 | padding: 10px 5px; 59 | 60 | color: #cbc4b9; 61 | font-size: 32px; 62 | font-weight: 600; 63 | `; 64 | 65 | const ModalDescription = styled.span` 66 | flex: 0.3; 67 | margin-left: 25px; 68 | padding: 10px 5px; 69 | 70 | color: #cbc4b9; 71 | font-size: 15px; 72 | `; 73 | 74 | const ModalCloseButton = styled.div` 75 | width: 32px; 76 | height: 32px; 77 | ${flex('row', 'center', 'center')}; 78 | cursor: pointer; 79 | margin-right: 25px; 80 | `; 81 | 82 | const CloseIcon = styled(Close)` 83 | width: 20px; 84 | height: 20px; 85 | fill: #a69c96; 86 | `; 87 | 88 | function MainModal(): JSX.Element { 89 | const { setIsModalOpen, modalContents } = useContext(ToggleStoreContext); 90 | const { contents, title, description, height, minHeight } = modalContents; 91 | return ( 92 | 93 | setIsModalOpen(false)} /> 94 | 95 | 96 | 97 | {title} 98 | setIsModalOpen(false)}> 99 | 100 | 101 | 102 | {description} 103 | {contents} 104 | 105 | 106 | 107 | ); 108 | } 109 | 110 | export default MainModal; 111 | -------------------------------------------------------------------------------- /frontend/src/components/core/OkCancelModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { flex } from '../../utils/styledComponentFunc'; 4 | 5 | const ModalButtonContainer = styled.div` 6 | width: 100%; 7 | margin-top: 15px; 8 | ${flex('row', 'flex-end', 'center')} 9 | `; 10 | 11 | const Button = styled.button` 12 | width: 100px; 13 | height: 40px; 14 | background: none; 15 | 16 | padding: 10px; 17 | margin-right: 20px; 18 | 19 | border: 0; 20 | outline: 0; 21 | 22 | text-align: center; 23 | vertical-align: middle; 24 | 25 | border-radius: 10px; 26 | background-color: #26a9ca; 27 | cursor: pointer; 28 | transition: all 0.3s; 29 | `; 30 | 31 | const OkButton = styled(Button)` 32 | background-color: #26a9ca; 33 | &:hover { 34 | background-color: #54c8e6; 35 | } 36 | `; 37 | 38 | const CancelButton = styled(Button)` 39 | background-color: gray; 40 | &:hover { 41 | background-color: #d4d0d0; 42 | } 43 | `; 44 | 45 | type OkCancelModalProps = { 46 | handleClickOk: () => void; 47 | handleClickCancel: () => void; 48 | }; 49 | 50 | function OkCancelModal(props: OkCancelModalProps): JSX.Element { 51 | const { handleClickOk, handleClickCancel } = props; 52 | 53 | return ( 54 | 55 | 확인 56 | 취소 57 | 58 | ); 59 | } 60 | 61 | export default OkCancelModal; 62 | -------------------------------------------------------------------------------- /frontend/src/components/core/RightClickDropdown.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | const Container = styled.div<{ activated: boolean }>` 5 | position: absolute; 6 | margin-top: -50vh; 7 | opacity: ${(props) => (props.activated ? 1 : 0)}; 8 | visibility: ${(props) => (props.activated ? 'visible' : 'hidden')}; 9 | transform: translateY(${(props) => (props.activated ? '0' : '-20')}px); 10 | transition: opacity 0.4s ease, transform 0.4s ease, visibility 0.4s; 11 | z-index: 10; 12 | background-color: red; 13 | `; 14 | 15 | const DropdownBackground = styled.div` 16 | position: fixed; 17 | left: 0; 18 | top: 0; 19 | width: 200vw; 20 | height: 200vh; 21 | margin-left: -50vw; 22 | margin-top: -50vh; 23 | background-color: rgb(0, 0, 0, 0.1); 24 | z-index: 3; 25 | `; 26 | 27 | const InnerContainer = styled.div<{ pos: [number, number] }>` 28 | left: ${({ pos }) => `${pos[0]}px`}; 29 | top: ${({ pos }) => `${pos[1]}px`}; 30 | background-color: white; 31 | border-radius: 8px; 32 | position: fixed; 33 | width: 100px; 34 | text-align: center; 35 | box-shadow: 0 1px 8px rgba(0, 0, 0, 0.3); 36 | z-index: 99; 37 | `; 38 | 39 | const MenuList = styled.ul` 40 | list-style: none; 41 | padding: 0; 42 | margin: 0; 43 | `; 44 | 45 | type DropdownProps = { 46 | pos: [number, number]; 47 | isDropdownActivated: boolean; 48 | setIsDropdownActivated: React.Dispatch>; 49 | children: React.ReactChild[] | React.ReactChild; 50 | }; 51 | function RightClickDropdown(props: DropdownProps): JSX.Element { 52 | const { isDropdownActivated, setIsDropdownActivated, children, pos } = props; 53 | 54 | const onClickDropdownBackground = (e: React.MouseEvent) => { 55 | e.stopPropagation(); 56 | setIsDropdownActivated(false); 57 | }; 58 | 59 | return ( 60 | 61 | 62 | {children} 63 | 64 | 65 | 66 | ); 67 | } 68 | 69 | export default RightClickDropdown; 70 | -------------------------------------------------------------------------------- /frontend/src/hooks/useSTT.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { useState, useEffect, useRef } from 'react'; 3 | 4 | function useSTT(): { 5 | lastResult: { 6 | text: string; 7 | isFinal: boolean; 8 | }; 9 | isSTTActive: boolean; 10 | isSpeaking: boolean; 11 | toggleSTTActive: () => void; 12 | } { 13 | const [lastResult, setLastResult] = useState({ text: '', isFinal: false }); 14 | const [isSTTActive, setSTTActive] = useState(false); 15 | const [isSpeaking, setIsSpeaking] = useState(false); 16 | const recognitionRef = useRef(); 17 | 18 | // @ts-expect-error: it only works on Chrome 19 | const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; 20 | 21 | const toggleSTTActive = () => { 22 | setSTTActive((prev) => !prev); 23 | }; 24 | 25 | useEffect(() => { 26 | const handleResult = ({ results }: { results: any }) => { 27 | const last: any = Array.from(results[results.length - 1]); 28 | if (isSTTActive) { 29 | setLastResult({ 30 | text: last.reduce((prev: string, curr: any) => prev + curr.transcript, '').trim(), 31 | isFinal: results[results.length - 1].isFinal, 32 | }); 33 | } 34 | if (results[results.length - 1].isFinal) { 35 | setIsSpeaking(false); 36 | } else { 37 | setIsSpeaking(true); 38 | } 39 | }; 40 | 41 | const makeNewRecognition = () => { 42 | const recognition = new SpeechRecognition(); 43 | recognition.continuous = true; 44 | recognition.interimResults = true; 45 | 46 | recognition.addEventListener('result', handleResult); 47 | recognition.onaudioend = () => { 48 | makeNewRecognition(); 49 | }; 50 | 51 | recognition.start(); 52 | recognitionRef.current = recognition; 53 | }; 54 | 55 | if (recognitionRef.current) { 56 | recognitionRef.current.onaudioend = null; 57 | } 58 | makeNewRecognition(); 59 | }, [isSTTActive]); 60 | 61 | useEffect(() => { 62 | return () => { 63 | if (recognitionRef.current) { 64 | recognitionRef.current.onaudioend = null; 65 | recognitionRef.current.onresult = null; 66 | recognitionRef.current.abort(); 67 | recognitionRef.current = null; 68 | } 69 | }; 70 | }, []); 71 | 72 | return { lastResult, isSTTActive, isSpeaking, toggleSTTActive }; 73 | } 74 | 75 | export default useSTT; 76 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { createGlobalStyle } from 'styled-components'; 4 | 5 | import App from './App'; 6 | 7 | const GlobalStyle = createGlobalStyle` 8 | body { 9 | margin: 0; 10 | padding: 0; 11 | overflow: hidden; 12 | } 13 | `; 14 | 15 | ReactDOM.render( 16 | 17 | 18 | 19 | , 20 | document.getElementById('root'), 21 | ); 22 | 23 | // If you want to start measuring performance in your app, pass a function 24 | // to log results (for example: reportWebVitals(console.log)) 25 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 26 | -------------------------------------------------------------------------------- /frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /frontend/src/types/cam.ts: -------------------------------------------------------------------------------- 1 | import { MediaConnection } from 'peerjs'; 2 | import CurrentDate from './date'; 3 | 4 | type Status = { video: boolean; audio: boolean; stream: boolean; speaking: boolean }; 5 | type Screen = { userId: string; stream: MediaStream; call: MediaConnection }; 6 | type UserInfo = { 7 | roomId: string | null; 8 | nickname: string | null; 9 | }; 10 | type Control = { 11 | video: boolean; 12 | audio: boolean; 13 | }; 14 | 15 | type CamMessageInfo = { 16 | msg: string; 17 | room: string | null; 18 | user: string; 19 | date: CurrentDate; 20 | }; 21 | 22 | type CamRoomInfo = { 23 | socketId: string; 24 | userNickname: string; 25 | }; 26 | 27 | export type { Status, Screen, UserInfo, Control, CamMessageInfo, CamRoomInfo }; 28 | -------------------------------------------------------------------------------- /frontend/src/types/channel.ts: -------------------------------------------------------------------------------- 1 | import { ServerEntity } from './server'; 2 | import { UserEntity } from './user'; 3 | 4 | type ChannelEntity = { 5 | id: number; 6 | name: string; 7 | description: string; 8 | owner: UserEntity; 9 | server: ServerEntity; 10 | ownerId: number; 11 | serverId: number; 12 | }; 13 | 14 | export default ChannelEntity; 15 | -------------------------------------------------------------------------------- /frontend/src/types/comment.ts: -------------------------------------------------------------------------------- 1 | import { MessageSender } from './message'; 2 | 3 | type CommentRequestBody = { 4 | channelId: number; 5 | messageId: number; 6 | contents: string; 7 | }; 8 | 9 | type CommentData = { 10 | id: number; 11 | messageId: string; 12 | channelId: number; 13 | contents: string; 14 | createdAt: string; 15 | sender: MessageSender; 16 | }; 17 | 18 | type CommentListInfo = { 19 | commentData: CommentData[]; 20 | isLoading: boolean; 21 | }; 22 | 23 | export type { CommentRequestBody, CommentData, CommentListInfo }; 24 | -------------------------------------------------------------------------------- /frontend/src/types/date.ts: -------------------------------------------------------------------------------- 1 | type CurrentDate = { 2 | year: number; 3 | month: number; 4 | date: number; 5 | hour: number; 6 | minutes: number; 7 | }; 8 | 9 | export default CurrentDate; 10 | -------------------------------------------------------------------------------- /frontend/src/types/dropdown.ts: -------------------------------------------------------------------------------- 1 | import ModalContents from './modal'; 2 | 3 | type ComponentInfo = { 4 | name: string; 5 | component: ModalContents; 6 | }; 7 | 8 | type DropdownInfo = { 9 | position: [number, number]; 10 | components: ComponentInfo[]; 11 | }; 12 | 13 | export type { ComponentInfo, DropdownInfo }; 14 | -------------------------------------------------------------------------------- /frontend/src/types/fetch.ts: -------------------------------------------------------------------------------- 1 | type FetchResponseObject = { 2 | statusCode: number; 3 | message: string | null; 4 | data: T; 5 | }; 6 | 7 | type NoContentsResponse = { 8 | statusCode: number; 9 | message: string | null; 10 | }; 11 | 12 | type CreatedResponse = { 13 | statusCode: number; 14 | message: string | null; 15 | data: number | null; 16 | }; 17 | 18 | export type { FetchResponseObject, NoContentsResponse, CreatedResponse }; 19 | -------------------------------------------------------------------------------- /frontend/src/types/join-channel-request.ts: -------------------------------------------------------------------------------- 1 | export type JoinChannelRequest = { 2 | channelId: number; 3 | }; 4 | -------------------------------------------------------------------------------- /frontend/src/types/main.ts: -------------------------------------------------------------------------------- 1 | type UserData = { 2 | githubId: number; 3 | id: number; 4 | nickname: string; 5 | profile: string; 6 | }; 7 | 8 | type ServerData = { 9 | description: string; 10 | id: number; 11 | name: string; 12 | imgUrl: string; 13 | owner: UserData; 14 | }; 15 | 16 | type MyServerData = { 17 | id: number; 18 | server: ServerData; 19 | }; 20 | 21 | type ChannelListData = { 22 | description: string; 23 | id: number; 24 | name: string; 25 | ownerId: number; 26 | serverId: number; 27 | }; 28 | 29 | type CamData = { 30 | name: string; 31 | url: string; 32 | }; 33 | 34 | export type { UserData, ServerData, MyServerData, ChannelListData, CamData }; 35 | -------------------------------------------------------------------------------- /frontend/src/types/message.ts: -------------------------------------------------------------------------------- 1 | type MessageRequestBody = { 2 | channelId: number; 3 | contents: string; 4 | }; 5 | 6 | type MessageSender = { 7 | id: number; 8 | nickname: string; 9 | profile: string; 10 | }; 11 | 12 | type MessageData = { 13 | id: number; 14 | channelId: number; 15 | contents: string; 16 | createdAt: string; 17 | sender: MessageSender; 18 | }; 19 | 20 | type MessageListInfo = { 21 | messageData: MessageData[]; 22 | isLoading: boolean; 23 | }; 24 | 25 | export type { MessageRequestBody, MessageSender, MessageData, MessageListInfo }; 26 | -------------------------------------------------------------------------------- /frontend/src/types/modal.ts: -------------------------------------------------------------------------------- 1 | type ModalContents = { 2 | contents: JSX.Element; 3 | title: string; 4 | description: string; 5 | height: string; 6 | minHeight: string; 7 | }; 8 | 9 | export default ModalContents; 10 | -------------------------------------------------------------------------------- /frontend/src/types/server.ts: -------------------------------------------------------------------------------- 1 | type Server = { id: number; description: string; name: string }; 2 | type ServerInfo = { 3 | id: number; 4 | server: Server; 5 | }; 6 | 7 | type ServerEntity = { 8 | code: string; 9 | description: string; 10 | id: number; 11 | imgUrl: string; 12 | name: string; 13 | }; 14 | 15 | export type { ServerInfo, Server, ServerEntity }; 16 | -------------------------------------------------------------------------------- /frontend/src/types/user.ts: -------------------------------------------------------------------------------- 1 | type User = { 2 | id: number; 3 | githubId: number; 4 | nickname: string; 5 | profile: string; 6 | }; 7 | 8 | type UserEntity = { 9 | githubId: number; 10 | id: number; 11 | nickname: string; 12 | profile: string; 13 | }; 14 | 15 | export type { User, UserEntity }; 16 | -------------------------------------------------------------------------------- /frontend/src/utils/fetchMethods.ts: -------------------------------------------------------------------------------- 1 | import { FetchResponseObject, NoContentsResponse, CreatedResponse } from '../types/fetch'; 2 | 3 | const fetchData = async (method: string, url: string, requestBody?: T): Promise> => { 4 | const response = await fetch(url, { 5 | method, 6 | credentials: 'include', 7 | headers: { 8 | 'Content-Type': 'application/json', 9 | }, 10 | body: JSON.stringify(requestBody), 11 | }); 12 | const responseObject = await response.json(); 13 | const { statusCode, message, data } = responseObject; 14 | return { statusCode, message, data }; 15 | }; 16 | 17 | const deleteApi = async (url: string): Promise => { 18 | const response = await fetch(url, { 19 | method: 'DELETE', 20 | }); 21 | const headerStatusCode = response.status; 22 | if (headerStatusCode === 204) { 23 | return { statusCode: headerStatusCode, message: null }; 24 | } 25 | const responseObject = await response.json(); 26 | const { statusCode, message } = responseObject; 27 | return { statusCode, message }; 28 | }; 29 | 30 | const sendFormData = async ( 31 | method: string, 32 | url: string, 33 | requestBody: FormData, 34 | ): Promise => { 35 | const response = await fetch(url, { 36 | method, 37 | body: requestBody, 38 | }); 39 | const headerStatusCode = response.status; 40 | if (headerStatusCode === 204) { 41 | return { statusCode: headerStatusCode, message: null }; 42 | } 43 | const responseObject = await response.json(); 44 | const { statusCode, message, data } = responseObject; 45 | return { statusCode, message, data }; 46 | }; 47 | 48 | export { fetchData, deleteApi, sendFormData }; 49 | -------------------------------------------------------------------------------- /frontend/src/utils/getCurrentDate.ts: -------------------------------------------------------------------------------- 1 | import CurrentDate from '../types/date'; 2 | 3 | const getCurrentDate = (today: Date): CurrentDate => { 4 | return { 5 | year: today.getFullYear(), 6 | month: today.getMonth() + 1, 7 | date: today.getDate(), 8 | hour: today.getHours(), 9 | minutes: today.getMinutes(), 10 | }; 11 | }; 12 | 13 | export default getCurrentDate; 14 | -------------------------------------------------------------------------------- /frontend/src/utils/sharedScreenReceiver.ts: -------------------------------------------------------------------------------- 1 | import Peer from 'peerjs'; 2 | import React from 'react'; 3 | import { Socket } from 'socket.io-client'; 4 | 5 | class SharedScreenReceiver { 6 | private readonly peer: Peer; 7 | 8 | private call?: Peer.MediaConnection; 9 | 10 | constructor( 11 | private readonly socket: Socket, 12 | private readonly roomId: string, 13 | private setSharedScreen: React.Dispatch>, 14 | ) { 15 | this.peer = new Peer(undefined, { 16 | host: '/', 17 | path: '/peerjs', 18 | port: parseInt(process.env.REACT_APP_PEERJS_PORT as string, 10), 19 | }); 20 | 21 | this.peer.on('open', (peerId) => { 22 | this.socket.on('screenShareStarted', ({ screenSharingUserId }: { screenSharingUserId: string }) => { 23 | this.socket.emit('requestScreenShare', { peerId, screenSharingUserId }); 24 | }); 25 | 26 | this.socket.on('getScreenSharingUser', ({ screenSharingUserId }: { screenSharingUserId: string }) => { 27 | if (!screenSharingUserId) { 28 | return; 29 | } 30 | this.socket.emit('requestScreenShare', { peerId, screenSharingUserId }); 31 | }); 32 | this.socket.emit('getScreenSharingUser', { roomId: this.roomId }); 33 | }); 34 | 35 | this.peer.on('call', (call) => { 36 | this.call = call; 37 | call.answer(undefined); 38 | call.on('stream', (screenStream) => { 39 | this.setSharedScreen(screenStream); 40 | }); 41 | call.on('close', () => { 42 | this.peer.connections[call.peer] = null; 43 | this.call = undefined; 44 | this.setSharedScreen(null); 45 | }); 46 | }); 47 | 48 | this.peer.on('disconnected', () => { 49 | this.call?.close(); 50 | this.call = undefined; 51 | this.setSharedScreen(null); 52 | }); 53 | 54 | this.socket.on('endSharingScreen', () => { 55 | this.call?.close(); 56 | this.call = undefined; 57 | this.setSharedScreen(null); 58 | }); 59 | } 60 | 61 | setSetScreen(setScreen: React.Dispatch>): void { 62 | this.setSharedScreen = setScreen; 63 | } 64 | 65 | close(): void { 66 | this.call?.close(); 67 | this.peer.destroy(); 68 | this.socket.removeAllListeners('screenShareStarted'); 69 | this.socket.removeAllListeners('endSharingScreen'); 70 | this.socket.removeAllListeners('getScreenSharingUser'); 71 | } 72 | } 73 | 74 | export default SharedScreenReceiver; 75 | -------------------------------------------------------------------------------- /frontend/src/utils/sharedScreenSender.ts: -------------------------------------------------------------------------------- 1 | import Peer from 'peerjs'; 2 | import { Socket } from 'socket.io-client'; 3 | 4 | class SharedScreenSender { 5 | private peer: Peer | null = null; 6 | 7 | private connections: Peer.MediaConnection[] = []; 8 | 9 | private handleRequestScreenShare: (({ peerId }: { peerId: string }) => void) | undefined; 10 | 11 | constructor(private readonly socket: Socket, private readonly roomId: string) {} 12 | 13 | prepareScreenShare(screenStream: MediaStream): void { 14 | if (!screenStream) { 15 | return; 16 | } 17 | 18 | this.peer = new Peer(undefined, { 19 | host: '/', 20 | path: '/peerjs', 21 | port: parseInt(process.env.REACT_APP_PEERJS_PORT as string, 10), 22 | }); 23 | 24 | this.connections = []; 25 | 26 | this.peer.on('open', () => { 27 | this.socket.emit('startScreenShare', { roomId: this.roomId }); 28 | }); 29 | 30 | this.handleRequestScreenShare = ({ peerId }: { peerId: string }) => { 31 | const call = this.peer?.call(peerId, screenStream); 32 | if (call) { 33 | this.connections.push(call); 34 | } 35 | }; 36 | 37 | this.socket.on('requestScreenShare', this.handleRequestScreenShare); 38 | } 39 | 40 | stopSharingScreen(): void { 41 | this.socket.removeAllListeners('requestScreenShare'); 42 | this.closeConnections(); 43 | this.peer?.destroy(); 44 | this.socket.emit('endSharingScreen', { roomId: this.roomId }); 45 | } 46 | 47 | private closeConnections(): void { 48 | this.connections.forEach((connection) => { 49 | connection.close(); 50 | }); 51 | this.connections = []; 52 | } 53 | } 54 | 55 | export default SharedScreenSender; 56 | -------------------------------------------------------------------------------- /frontend/src/utils/styledComponentFunc.ts: -------------------------------------------------------------------------------- 1 | type FlexDirection = 'row' | 'column' | 'row-reverse' | 'column-reverse'; 2 | type JustifyContents = 3 | | 'left' 4 | | 'right' 5 | | 'center' 6 | | 'end' 7 | | 'space-around' 8 | | 'space-between' 9 | | 'space-evenly' 10 | | 'flex-start' 11 | | 'flex-end' 12 | | 'initial'; 13 | type AlignItems = 'start' | 'end' | 'center' | 'flex-start' | 'flex-end'; 14 | 15 | const flex = (direction: FlexDirection, justify?: JustifyContents, align?: AlignItems): string => { 16 | return `display:flex; flex-direction: ${direction}; 17 | ${justify && `justify-content: ${justify}`}; 18 | ${align && `align-items: ${align}`};`; 19 | }; 20 | 21 | const customScroll = (): string => { 22 | return `&::-webkit-scrollbar { 23 | width: 10px; 24 | } 25 | &::-webkit-scrollbar-thumb { 26 | background-color: #999999; 27 | border-radius: 10px; 28 | } 29 | &::-webkit-scrollbar-track { 30 | background-color: #cccccc; 31 | border-radius: 10px; 32 | }`; 33 | }; 34 | 35 | export { flex, customScroll }; 36 | -------------------------------------------------------------------------------- /frontend/src/utils/svgIcons.ts: -------------------------------------------------------------------------------- 1 | import { ReactComponent as MicIcon } from '../assets/icons/mic.svg'; 2 | import { ReactComponent as MicDisabledIcon } from '../assets/icons/mic-disabled.svg'; 3 | import { ReactComponent as VideoIcon } from '../assets/icons/video.svg'; 4 | import { ReactComponent as VideoDisabledIcon } from '../assets/icons/video-disabled.svg'; 5 | import { ReactComponent as IdentificationIcon } from '../assets/icons/identification.svg'; 6 | import { ReactComponent as ChatIcon } from '../assets/icons/chat.svg'; 7 | import { ReactComponent as PresenstationIcon } from '../assets/icons/presentation.svg'; 8 | import { ReactComponent as UsersIcon } from '../assets/icons/users.svg'; 9 | import { ReactComponent as ExitIcon } from '../assets/icons/exit.svg'; 10 | import { ReactComponent as STTIcon } from '../assets/icons/speech.svg'; 11 | import { ReactComponent as STTDisabledIcon } from '../assets/icons/speech-disabled.svg'; 12 | import { ReactComponent as CopyIcon } from '../assets/icons/copy.svg'; 13 | 14 | import { ReactComponent as Hash } from '../assets/icons/hash.svg'; 15 | import { ReactComponent as Plus } from '../assets/icons/plus.svg'; 16 | import { ReactComponent as ListArrow } from '../assets/icons/listarrow.svg'; 17 | import { ReactComponent as Close } from '../assets/icons/close.svg'; 18 | import { ReactComponent as Github } from '../assets/icons/github.svg'; 19 | 20 | import { ReactComponent as loading } from '../assets/loading.svg'; 21 | 22 | export const ButtonBarIcons = { 23 | MicIcon, 24 | MicDisabledIcon, 25 | VideoIcon, 26 | VideoDisabledIcon, 27 | IdentificationIcon, 28 | ChatIcon, 29 | PresenstationIcon, 30 | UsersIcon, 31 | ExitIcon, 32 | STTIcon, 33 | STTDisabledIcon, 34 | CopyIcon, 35 | }; 36 | 37 | export const BoostCamMainIcons = { 38 | Hash, 39 | UsersIcon, 40 | Plus, 41 | ListArrow, 42 | Close, 43 | Github, 44 | loading, 45 | }; 46 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 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 | "noImplicitAny": true, 19 | "noImplicitReturns": true, 20 | "noImplicitThis": true, 21 | "strictNullChecks": true, 22 | "strictFunctionTypes": true, 23 | "strictBindCallApply": true 24 | }, 25 | "include": ["src"] 26 | } 27 | --------------------------------------------------------------------------------