├── client ├── src │ ├── vite-env.d.ts │ ├── constants │ │ ├── API.ts │ │ ├── VIDEO.ts │ │ ├── EMOTICONS.ts │ │ └── CATEGORY.ts │ ├── index.css │ ├── main.tsx │ ├── utils │ │ ├── shuffle.ts │ │ ├── time.ts │ │ └── media.ts │ ├── components │ │ ├── common │ │ │ ├── ProtectedRoute.tsx │ │ │ ├── Icon.tsx │ │ │ ├── Header.tsx │ │ │ └── StopWatch.tsx │ │ ├── StudyRoom │ │ │ ├── ChatMessage.tsx │ │ │ ├── Video.tsx │ │ │ ├── VideoOverlay.tsx │ │ │ ├── VideoGrid.tsx │ │ │ ├── Header.tsx │ │ │ ├── MediaSelectModal.tsx │ │ │ ├── index.tsx │ │ │ ├── Chat.tsx │ │ │ └── ControlBar.tsx │ │ ├── StudyRoomList │ │ │ ├── AddItemCard.tsx │ │ │ ├── Header.tsx │ │ │ ├── ItemCard.tsx │ │ │ ├── Pagination.tsx │ │ │ ├── JoinRoomModal.tsx │ │ │ ├── index.tsx │ │ │ └── AddItemModal.tsx │ │ ├── Home │ │ │ └── Home.tsx │ │ └── Permission │ │ │ └── index.tsx │ ├── APIs │ │ └── StudyRoomAPI.ts │ ├── types │ │ ├── Common.ts │ │ ├── StudyRoomList.ts │ │ ├── StudyRoom.ts │ │ └── WebRTC.ts │ ├── hooks │ │ └── useStopWatch.ts │ ├── App.tsx │ ├── socket │ │ └── signalingClient.ts │ └── stores │ │ └── useWebRTCStore.ts ├── public │ └── images │ │ ├── gomz-logo.png │ │ └── icons.svg ├── tsconfig.json ├── postcss.config.js ├── eslint.config.js ├── tsconfig.node.json ├── index.html ├── vite.config.ts ├── package.json ├── tsconfig.app.json └── tailwind.config.js ├── .husky └── pre-commit ├── server ├── tsconfig.build.json ├── src │ ├── app.service.ts │ ├── chatting │ │ ├── chatting.dto.ts │ │ ├── chatting.module.ts │ │ ├── chatting.constant.ts │ │ ├── chatting.service.ts │ │ └── chatting.service.spec.ts │ ├── user │ │ ├── user.module.ts │ │ ├── user.controller.ts │ │ ├── user.entity.ts │ │ └── user.controller.spec.ts │ ├── app.controller.ts │ ├── study-room │ │ ├── entity │ │ │ ├── study-room-participant.entity.ts │ │ │ ├── category.entity.ts │ │ │ └── study-room.entity.ts │ │ ├── dto │ │ │ ├── read-room.dto.ts │ │ │ └── create-room.dto.ts │ │ ├── study-room.module.ts │ │ ├── repository │ │ │ ├── study-room.repository.ts │ │ │ ├── study-room-participant.repository.ts │ │ │ ├── study-room.repository.spec.ts │ │ │ └── study-room-participant.repository.spec.ts │ │ ├── study-room.controller.ts │ │ ├── study-room.controller.spec.ts │ │ ├── study-room.service.spec.ts │ │ └── study-room.service.ts │ ├── main.ts │ ├── signaling-server │ │ ├── signaling-server.module.ts │ │ ├── signaling-server.dto.ts │ │ ├── signaling-server.gateway.ts │ │ └── signaling-server.gateway.spec.ts │ ├── sfu-server │ │ ├── sfu-server.module.ts │ │ ├── sfu-server.gateway.spec.ts │ │ ├── sfu-server.gateway.ts │ │ └── sfu-server.service.ts │ ├── app.controller.spec.ts │ ├── app.module.ts │ └── util │ │ └── nicknameGenerator.ts ├── nest-cli.json ├── test │ ├── jest-e2e.json │ └── app.e2e-spec.ts ├── tsconfig.json ├── winston.config.ts └── package.json ├── .prettierrc ├── .github ├── ISSUE_TEMPLATE │ ├── gomz_hot_fix_template.md │ └── -fe-be--task-요약-10---15자-이내.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── cd-be.yml │ └── lint-and-prettier.yml ├── eslint.config.js ├── .gitignore ├── package.json └── README.md /client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged -------------------------------------------------------------------------------- /client/public/images/gomz-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web24-GOMZ/HEAD/client/public/images/gomz-logo.png -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] 4 | } 5 | -------------------------------------------------------------------------------- /server/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /client/src/constants/API.ts: -------------------------------------------------------------------------------- 1 | const API_BASE_URL = import.meta.env.DEV ? '/api' : import.meta.env.VITE_SIGNALING_SERVER_URL; 2 | 3 | export { API_BASE_URL }; 4 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | body { 7 | @apply bg-gomz-white; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /client/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 3 | plugins: { 4 | tailwindcss: {}, 5 | autoprefixer: {}, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /client/src/constants/VIDEO.ts: -------------------------------------------------------------------------------- 1 | const RATIO = 4 / 3; 2 | const MAX_HEIGHT = 600; 3 | const MAX_WIDTH = MAX_HEIGHT * RATIO; 4 | const GAP = 8; 5 | 6 | export { MAX_HEIGHT, MAX_WIDTH, GAP }; 7 | -------------------------------------------------------------------------------- /server/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 | -------------------------------------------------------------------------------- /server/src/chatting/chatting.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsNotEmpty } from 'class-validator'; 2 | 3 | export class SendMessageDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | message: string; 7 | } 8 | -------------------------------------------------------------------------------- /server/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /server/src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UserController } from './user.controller'; 3 | 4 | @Module({ 5 | controllers: [UserController], 6 | }) 7 | export class UserModule {} 8 | -------------------------------------------------------------------------------- /client/src/constants/EMOTICONS.ts: -------------------------------------------------------------------------------- 1 | const SAD_EMOTICONS = [ 2 | '。° ૮₍°´ᯅ`°₎ა °', 3 | '。° (ꢳࡇꢳ) °。', 4 | '༼ ༎ຶ ෴ ༎ຶ༽', 5 | '꒰ 𖦹ˊᯅˋ𖦹 ꒱', 6 | '✘ᴗ✘', 7 | 'ヽ(●´Д`●)ノ゚', 8 | ]; 9 | 10 | export { SAD_EMOTICONS }; 11 | -------------------------------------------------------------------------------- /server/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /client/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import App from './App.tsx'; 4 | import './index.css'; 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /client/src/utils/shuffle.ts: -------------------------------------------------------------------------------- 1 | function shuffle(array: T[]): T[] { 2 | for (let i = array.length - 1; i > 0; i--) { 3 | const j = Math.floor(Math.random() * (i + 1)); 4 | [array[i], array[j]] = [array[j], array[i]]; 5 | } 6 | return array; 7 | } 8 | 9 | export default shuffle; 10 | -------------------------------------------------------------------------------- /client/src/components/common/ProtectedRoute.tsx: -------------------------------------------------------------------------------- 1 | import { Navigate } from 'react-router-dom'; 2 | 3 | const ProtectedRoute = ({ children }: { children: JSX.Element }) => { 4 | return localStorage.getItem('nickName') ? children : ; 5 | }; 6 | 7 | export default ProtectedRoute; 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "tabWidth": 2, 4 | "endOfLine": "lf", 5 | "singleQuote": true, 6 | "semi": true, 7 | "trailingComma": "all", 8 | "bracketSpacing": true, 9 | "arrowParens": "always", 10 | "printWidth": 100, 11 | "plugins": ["prettier-plugin-tailwindcss"] 12 | } 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/gomz_hot_fix_template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: GOMZ_HOT_FIX_TEMPLATE 3 | about: hotfix 4 | title: ' 핫픽스 내용 요약 10 ~ 15자 이내' 5 | labels: hotfix 6 | assignees: '' 7 | --- 8 | 9 | ## 문제 상황 10 | 11 | 문제가 무엇인지에 대한 명확하고 간결한 설명을 적어주세요. 12 | 13 | ## 스크린샷(생략 가능) 14 | 15 | 문제를 설명하는 데 도움이 될 만한 스크린샷을 추가해주세요. 16 | -------------------------------------------------------------------------------- /client/src/APIs/StudyRoomAPI.ts: -------------------------------------------------------------------------------- 1 | import { API_BASE_URL } from '@constants/API'; 2 | 3 | const getConfiguration = async () => { 4 | const response = await fetch(`${API_BASE_URL}/study-room/credentials`); 5 | const configuration = await response.json(); 6 | return configuration; 7 | }; 8 | 9 | export { getConfiguration }; 10 | -------------------------------------------------------------------------------- /client/src/types/Common.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | interface Header { 4 | className?: string; 5 | title: ReactNode; 6 | stopWatch: ReactNode; 7 | userInfo: ReactNode; 8 | } 9 | 10 | interface StopWatch { 11 | elapsedSeconds: number; 12 | isAnimationOn: boolean; 13 | } 14 | 15 | export type { Header, StopWatch }; 16 | -------------------------------------------------------------------------------- /server/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | 4 | @Controller() 5 | export class AppController { 6 | constructor(private readonly appService: AppService) {} 7 | 8 | @Get() 9 | getHello(): string { 10 | return this.appService.getHello(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /server/src/chatting/chatting.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ChattingService } from './chatting.service'; 3 | import { StudyRoomsService } from 'src/study-room/study-room.service'; 4 | 5 | @Module({ 6 | imports: [], 7 | providers: [ChattingService, StudyRoomsService], 8 | }) 9 | export class ChattingModule {} 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/-fe-be--task-요약-10---15자-이내.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: GOMZ_ISSUE_TEMPLATE 3 | about: task 4 | title: ' task 요약 10 ~ 15자 이내' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | ## story 10 | 11 | ## task 12 | 13 | ## 예상 소요 시간 14 | 15 | ## 개발할 사항 16 | 17 | - 개발할 사항1 18 | - 개발할 사항2 19 | - ... 20 | 21 | ## 완료 조건 22 | 23 | ## 기타 24 | -------------------------------------------------------------------------------- /server/src/chatting/chatting.constant.ts: -------------------------------------------------------------------------------- 1 | export const MESSAGE_SENT = (clientId: string, userList: string[], message: string) => 2 | `Message from ${clientId} in room ${userList}: ${message}`; 3 | export const ROOM_NOT_FOUND = (clientId: string) => `사용자 ${clientId}가 속한 방이 없습니다.`; 4 | export const ROOM_ID_NOT_FOUND_ERROR = 'Room ID does not exist for the user.'; 5 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js'; 2 | import tseslint from 'typescript-eslint'; 3 | 4 | export default tseslint.config( 5 | { 6 | ignores: ['**/dist'], 7 | }, 8 | eslint.configs.recommended, 9 | ...tseslint.configs.recommended, 10 | { 11 | ...(await import('./client/eslint.config.js').then((m) => m.default)), 12 | }, 13 | ); 14 | -------------------------------------------------------------------------------- /server/src/study-room/entity/study-room-participant.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryColumn } from 'typeorm'; 2 | 3 | @Entity('study_room_participant') 4 | export class StudyRoomParticipant { 5 | @PrimaryColumn({ type: 'varchar', length: 45 }) 6 | socket_id: string; 7 | 8 | @Column({ type: 'int', nullable: false }) 9 | room_id: number; 10 | } 11 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # 제목: 작업 내용 요약 (제목 기입 후 삭제해주세요) 2 | 3 | ## 이슈(수동으로 한 경우 따로 기입) 4 | 5 | resolve # 6 | 7 | ## 소요 시간 8 | 9 | ## 개발한 사항 10 | 11 | - 개발할 사항1 12 | - 개발할 사항2 13 | - 구현한 개발 화면이나 영상을 첨부해 주세요 (생략 가능) 14 | 15 | ## 고민했던 사항 16 | 17 | - 개발하며 고민했던 내용 혹은 고민했던 내용을 적은 노션 링크를 적어주세요. 18 | 19 | ## 참조 (생략 가능) 20 | 21 | - 개발 시 참조했던 자료나 링크를 첨부해 주세요. 22 | -------------------------------------------------------------------------------- /server/src/study-room/entity/category.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 2 | 3 | @Entity('category') 4 | export class Category { 5 | @PrimaryGeneratedColumn({ name: 'category_id' }) // default: increment 6 | id: number; 7 | 8 | @Column({ name: 'category_name', length: 45 }) 9 | name: string; 10 | 11 | // study room 연결 (1-1) 12 | } 13 | -------------------------------------------------------------------------------- /server/src/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { generateRandomNickname } from '../util/nicknameGenerator'; 3 | 4 | @Controller('user') 5 | export class UserController { 6 | @Get('/random-name') 7 | getRandomName(): { nickName: string } { 8 | // random 닉네임 호출 9 | const nickName = generateRandomNickname(); 10 | return { nickName }; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /server/src/user/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 2 | 3 | @Entity() 4 | export class User { 5 | @PrimaryGeneratedColumn() 6 | id: number; 7 | 8 | @Column() 9 | nickname: string; 10 | 11 | @Column() 12 | password: string; 13 | 14 | @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', name: 'created_at' }) 15 | createdAt: Date; 16 | } 17 | -------------------------------------------------------------------------------- /client/src/components/common/Icon.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | const Icon = (props: SVGProps) => { 4 | const id = props.id; 5 | const ariaLabel = props['aria-label'] || `${id} 아이콘`; 6 | 7 | return id ? ( 8 | 9 | 10 | 11 | ) : null; 12 | }; 13 | 14 | export default Icon; 15 | -------------------------------------------------------------------------------- /server/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | 4 | async function bootstrap() { 5 | const app = await NestFactory.create(AppModule); 6 | 7 | app.enableCors({ 8 | origin: '*', 9 | methods: 'GET,HEAD,PUT,POST,DELETE,PATCH', 10 | credentials: true, 11 | }); 12 | 13 | await app.listen(process.env.PORT ?? 3000); 14 | } 15 | 16 | bootstrap(); 17 | -------------------------------------------------------------------------------- /client/src/components/StudyRoom/ChatMessage.tsx: -------------------------------------------------------------------------------- 1 | import type { ChatMessage as ChatMessageProps } from '@customTypes/StudyRoom'; 2 | 3 | const ChatMessage = ({ nickName, message }: ChatMessageProps) => { 4 | return ( 5 |
6 | {nickName} 7 | {message} 8 |
9 | ); 10 | }; 11 | 12 | export default ChatMessage; 13 | -------------------------------------------------------------------------------- /server/src/signaling-server/signaling-server.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { SignalingServerGateway } from './signaling-server.gateway'; 3 | import { StudyRoomModule } from '../study-room/study-room.module'; 4 | import { ChattingService } from 'src/chatting/chatting.service'; 5 | 6 | @Module({ 7 | imports: [StudyRoomModule], 8 | providers: [SignalingServerGateway, ChattingService], 9 | }) 10 | export class SignalingServerModule {} 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | 4 | # Build outputs 5 | dist/ 6 | build/ 7 | 8 | # Environment variables 9 | .env 10 | .env.* 11 | 12 | # Logs 13 | logs 14 | *.log 15 | npm-debug.log* 16 | 17 | # Tests 18 | coverage/ 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | 23 | # IDE - IntelliJ 24 | .idea/* 25 | 26 | # OS generated files 27 | .DS_Store 28 | ._* 29 | .Spotlight-V100 30 | .Trashes 31 | ehthumbs.db 32 | Thumbs.db 33 | 34 | # TypeScript 35 | *.tsbuildinfo 36 | next-env.d.ts -------------------------------------------------------------------------------- /client/src/constants/CATEGORY.ts: -------------------------------------------------------------------------------- 1 | const CATEGORY_NAMES: string[] = [ 2 | '부캠', 3 | '개발자', 4 | '중학교', 5 | '고등학교', 6 | '대학교', 7 | '대학원생', 8 | 'PEET', 9 | 'MDEET', 10 | 'LEET', 11 | '로스쿨생', 12 | '의대/의전원', 13 | '간호대', 14 | '공무원', 15 | '경찰', 16 | '소방', 17 | '임용', 18 | '법무사', 19 | '감정평가사', 20 | '변리사', 21 | '노무사', 22 | '공인중개사', 23 | '관세사', 24 | '회계사', 25 | '세무사', 26 | '어학', 27 | '취업', 28 | '자격증', 29 | '고시', 30 | '편입', 31 | ]; 32 | 33 | export { CATEGORY_NAMES }; 34 | -------------------------------------------------------------------------------- /server/src/sfu-server/sfu-server.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { SfuServerGateway } from './sfu-server.gateway'; 3 | import { SfuServerService } from './sfu-server.service'; 4 | import { ChattingService } from 'src/chatting/chatting.service'; 5 | import { StudyRoomModule } from 'src/study-room/study-room.module'; 6 | 7 | @Module({ 8 | imports: [StudyRoomModule], 9 | providers: [SfuServerGateway, SfuServerService, ChattingService], 10 | }) 11 | export class SfuServerModule {} 12 | -------------------------------------------------------------------------------- /client/eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | import reactHooks from 'eslint-plugin-react-hooks'; 3 | import reactRefresh from 'eslint-plugin-react-refresh'; 4 | 5 | export default { 6 | languageOptions: { 7 | globals: globals.browser, 8 | }, 9 | plugins: { 10 | 'react-hooks': reactHooks, 11 | 'react-refresh': reactRefresh, 12 | }, 13 | rules: { 14 | ...reactHooks.configs.recommended.rules, 15 | 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /.github/workflows/cd-be.yml: -------------------------------------------------------------------------------- 1 | name: Deploy BE to Server 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - dev 8 | 9 | jobs: 10 | deploy: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Run SSH 15 | uses: appleboy/ssh-action@v1.1.0 16 | with: 17 | host: ${{ secrets.HOST }} 18 | username: ${{ secrets.USERNAME }} 19 | password: ${{ secrets.PASSWORD }} 20 | port: ${{ secrets.PORT }} 21 | script: sh /root/boostcamp/deploy.sh 22 | -------------------------------------------------------------------------------- /server/src/user/user.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UserController } from './user.controller'; 3 | 4 | describe('UserController', () => { 5 | let controller: UserController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [UserController], 10 | }).compile(); 11 | 12 | controller = module.get(UserController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /server/src/sfu-server/sfu-server.gateway.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { SfuServerGateway } from './sfu-server.gateway'; 3 | 4 | describe('SfuServerGateway', () => { 5 | let gateway: SfuServerGateway; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [SfuServerGateway], 10 | }).compile(); 11 | 12 | gateway = module.get(SfuServerGateway); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(gateway).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /client/src/components/common/Header.tsx: -------------------------------------------------------------------------------- 1 | import type { Header as HeaderProps } from '@customTypes/Common'; 2 | 3 | const Header = ({ className, title, stopWatch, userInfo }: HeaderProps) => { 4 | return ( 5 |
8 |
{title}
9 |
{stopWatch}
10 |
{userInfo}
11 |
12 | ); 13 | }; 14 | 15 | export default Header; 16 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/lint-and-prettier.yml: -------------------------------------------------------------------------------- 1 | name: Lint and Prettier Check 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - dev 7 | 8 | jobs: 9 | lint-and-prettier: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v3 15 | 16 | - name: Set up Node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: '20' 20 | 21 | - name: Install dependencies 22 | run: npm install 23 | 24 | - name: Run ESLint 25 | run: npm run lint 26 | 27 | - name: Run Prettier Check 28 | run: npm run prettier 29 | -------------------------------------------------------------------------------- /client/src/utils/time.ts: -------------------------------------------------------------------------------- 1 | const secondsToHMS = (elapsedSeconds: number) => { 2 | const hours = Math.floor(elapsedSeconds / 3600); 3 | const minutes = Math.floor((elapsedSeconds % 3600) / 60); 4 | const seconds = elapsedSeconds % 60; 5 | 6 | return { hours, minutes, seconds }; 7 | }; 8 | 9 | const formatTime = (hours: number, minutes: number, seconds: number) => { 10 | const paddedHours = hours.toString().padStart(2, '0'); 11 | const paddedMinutes = minutes.toString().padStart(2, '0'); 12 | const paddedSeconds = seconds.toString().padStart(2, '0'); 13 | 14 | return `${paddedHours} : ${paddedMinutes} : ${paddedSeconds}`; 15 | }; 16 | 17 | export { secondsToHMS, formatTime }; 18 | -------------------------------------------------------------------------------- /client/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "Bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /server/src/study-room/entity/study-room.entity.ts: -------------------------------------------------------------------------------- 1 | import { BeforeInsert, Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm'; 2 | 3 | @Entity('study_room') 4 | export class StudyRoom { 5 | @PrimaryGeneratedColumn() 6 | room_id: number; 7 | 8 | @Column({ type: 'varchar', length: 45, nullable: false }) 9 | room_name: string; 10 | 11 | @CreateDateColumn({ type: 'timestamp' }) 12 | created_at: Date; 13 | 14 | @Column({ type: 'varchar', nullable: false }) 15 | category_name: string; 16 | 17 | @BeforeInsert() 18 | setCreatedAt() { 19 | this.created_at = new Date(); 20 | } 21 | 22 | @Column({ type: 'varchar', length: 60, nullable: true }) 23 | password: string; 24 | } 25 | -------------------------------------------------------------------------------- /server/src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | 5 | describe('AppController', () => { 6 | let appController: AppController; 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | providers: [AppService], 12 | }).compile(); 13 | 14 | appController = app.get(AppController); 15 | }); 16 | 17 | describe('root', () => { 18 | it('should return "Hello World!"', () => { 19 | expect(appController.getHello()).toBe('Hello World!'); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web24-gomz", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "scripts": { 6 | "lint": "eslint --debug .", 7 | "prettier": "prettier --check .", 8 | "prepare": "husky" 9 | }, 10 | "lint-staged": { 11 | "*.{js,jsx,ts,tsx}": [ 12 | "eslint --fix", 13 | "prettier --write" 14 | ], 15 | "*.{json,css,scss,md}": [ 16 | "prettier --write" 17 | ] 18 | }, 19 | "workspaces": [ 20 | "client", 21 | "server" 22 | ], 23 | "devDependencies": { 24 | "@eslint/js": "^9.14.0", 25 | "eslint": "^9.14.0", 26 | "husky": "^9.1.6", 27 | "lint-staged": "^15.2.10", 28 | "prettier": "^3.3.3", 29 | "prettier-plugin-tailwindcss": "^0.6.8", 30 | "typescript": "^5.6.3", 31 | "typescript-eslint": "^8.13.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | GOMZ - 실시간 온라인 학습 플랫폼 7 | 8 | 12 | 13 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | -------------------------------------------------------------------------------- /server/src/study-room/dto/read-room.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsBoolean, IsNumber, IsString } from 'class-validator'; 2 | import { StudyRoom } from '../entity/study-room.entity'; 3 | 4 | export class RoomInfoResponseDto { 5 | constructor(room: StudyRoom, roomId: number, curParticipant: number, maxParticipant: number) { 6 | this.roomId = roomId; 7 | this.roomName = room.room_name; 8 | this.categoryName = room.category_name; 9 | this.isPrivate = !!room.password; 10 | this.curParticipant = curParticipant; 11 | this.maxParticipant = maxParticipant; 12 | } 13 | 14 | @IsNumber() 15 | roomId: number; 16 | 17 | @IsString() 18 | roomName: string; 19 | 20 | @IsString() 21 | categoryName: string; 22 | 23 | @IsBoolean() 24 | isPrivate: boolean; 25 | 26 | @IsNumber() 27 | curParticipant: number; 28 | 29 | @IsNumber() 30 | maxParticipant: number; 31 | } 32 | -------------------------------------------------------------------------------- /server/src/study-room/study-room.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { StudyRoomsService } from './study-room.service'; 4 | import { StudyRoomController } from './study-room.controller'; 5 | import { StudyRoom } from './entity/study-room.entity'; 6 | import { StudyRoomParticipant } from './entity/study-room-participant.entity'; 7 | import { StudyRoomRepository } from './repository/study-room.repository'; 8 | import { StudyRoomParticipantRepository } from './repository/study-room-participant.repository'; 9 | 10 | @Module({ 11 | imports: [TypeOrmModule.forFeature([StudyRoom, StudyRoomParticipant])], 12 | controllers: [StudyRoomController], 13 | providers: [StudyRoomsService, StudyRoomRepository, StudyRoomParticipantRepository], 14 | exports: [StudyRoomsService, StudyRoomRepository, StudyRoomParticipantRepository], 15 | }) 16 | export class StudyRoomModule {} 17 | -------------------------------------------------------------------------------- /server/src/signaling-server/signaling-server.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsNotEmpty, IsObject } from 'class-validator'; 2 | 3 | export class JoinRoomDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | roomId: string; 7 | } 8 | 9 | export class SendOfferDto { 10 | @IsObject() 11 | @IsNotEmpty() 12 | offer: RTCSessionDescriptionInit; 13 | 14 | @IsString() 15 | @IsNotEmpty() 16 | oldId: string; 17 | 18 | @IsString() 19 | @IsNotEmpty() 20 | newRandomId: string; 21 | } 22 | 23 | export class SendAnswerDto { 24 | @IsObject() 25 | @IsNotEmpty() 26 | answer: RTCSessionDescriptionInit; 27 | 28 | @IsString() 29 | @IsNotEmpty() 30 | newId: string; 31 | 32 | @IsString() 33 | @IsNotEmpty() 34 | oldRandomId: string; 35 | } 36 | 37 | export class SendIceCandidateDto { 38 | @IsString() 39 | @IsNotEmpty() 40 | targetId: string; 41 | 42 | @IsObject() 43 | @IsNotEmpty() 44 | iceCandidate: RTCIceCandidateInit; 45 | } 46 | -------------------------------------------------------------------------------- /client/src/components/StudyRoomList/AddItemCard.tsx: -------------------------------------------------------------------------------- 1 | import Icon from '@components/common/Icon'; 2 | 3 | const AddItemCard = ({ openModal }: { openModal: () => void }) => { 4 | return ( 5 |
6 |
7 |

새로운 공부방 만들기 🧸

8 |

다른 사람들과 함께 공부해보세요

9 |
10 | 17 |
18 | ); 19 | }; 20 | 21 | export default AddItemCard; 22 | -------------------------------------------------------------------------------- /client/src/types/StudyRoomList.ts: -------------------------------------------------------------------------------- 1 | interface Room { 2 | roomId: string; 3 | roomName: string; 4 | categoryName: string; 5 | isPrivate: boolean; 6 | curParticipant: number; 7 | maxParticipant: number; 8 | } 9 | 10 | interface ErrorResponse { 11 | message: string; 12 | error: string; 13 | statusCode: number; 14 | } 15 | 16 | interface ResponseData { 17 | canAccess: boolean; 18 | error?: ErrorResponse; 19 | } 20 | 21 | interface ItemCard { 22 | roomId: string; 23 | roomName: string; 24 | categoryName: string; 25 | isPrivate: boolean; 26 | curParticipant: number; 27 | maxParticipant: number; 28 | openModal: () => void; 29 | } 30 | 31 | interface JoinRoomModal { 32 | currentRoom: Partial; 33 | closeModal: () => void; 34 | } 35 | 36 | interface Pagination { 37 | currentPage: number; 38 | totalPages: number; 39 | setCurrentPage: React.Dispatch>; 40 | } 41 | 42 | export type { Room, ResponseData, ItemCard, JoinRoomModal, Pagination }; 43 | -------------------------------------------------------------------------------- /client/src/hooks/useStopWatch.ts: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useEffect } from 'react'; 2 | 3 | const useStopWatch = (isRunning: boolean) => { 4 | const startTimeRef = useRef(0); // ms 5 | const [elapsedSeconds, setElapsedSeconds] = useState(0); // s 6 | const [accumulatedTime, setAccumulatedTime] = useState(0); // ms 7 | 8 | useEffect(() => { 9 | let animationFrameId: number; 10 | 11 | const step = () => { 12 | const diffTime = Date.now() - startTimeRef.current; 13 | setElapsedSeconds(Math.floor(diffTime / 1000)); 14 | animationFrameId = requestAnimationFrame(step); 15 | }; 16 | 17 | if (isRunning) { 18 | startTimeRef.current = Date.now() - accumulatedTime; 19 | animationFrameId = requestAnimationFrame(step); 20 | } else { 21 | setAccumulatedTime(startTimeRef.current ? Date.now() - startTimeRef.current : 0); 22 | } 23 | 24 | return () => cancelAnimationFrame(animationFrameId); 25 | }, [isRunning]); 26 | 27 | return elapsedSeconds; 28 | }; 29 | 30 | export default useStopWatch; 31 | -------------------------------------------------------------------------------- /client/src/components/StudyRoomList/Header.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | import Header from '@components/common/Header'; 3 | import StopWatch from '@components/common/StopWatch'; 4 | 5 | const StudyRoomListHeader = ({ className }: { className?: string }) => { 6 | return ( 7 |
11 |
12 |

GOMZ

13 |
14 | 15 | } 16 | stopWatch={ 17 |
18 | 22 |
23 | } 24 | userInfo={ 25 |
26 |
{localStorage.getItem('nickName')}
27 |
28 | } 29 | /> 30 | ); 31 | }; 32 | 33 | export default StudyRoomListHeader; 34 | -------------------------------------------------------------------------------- /client/src/types/StudyRoom.ts: -------------------------------------------------------------------------------- 1 | interface Header { 2 | className?: string; 3 | roomName: string; 4 | maxParticipant: number; 5 | } 6 | 7 | interface ControlBar { 8 | className?: string; 9 | toggleChat: () => void; 10 | isChatOn: boolean; 11 | unreadMessagesCount: number; 12 | } 13 | 14 | interface MediaSelectModal { 15 | className?: string; 16 | mediaDeviceId: string; 17 | setMediaDeviceId: (deviceId: string) => void; 18 | getMediaDevices: () => Promise; 19 | } 20 | 21 | interface Video { 22 | mediaStream: MediaStream; 23 | muted: boolean; 24 | } 25 | 26 | interface VideoOverlay { 27 | nickName: string; 28 | dataChannel?: RTCDataChannel; 29 | cols: number; 30 | } 31 | 32 | interface ChatMessage { 33 | nickName: string; 34 | message: string; 35 | } 36 | 37 | interface Chat { 38 | messages: ChatMessage[]; 39 | setMessages: React.Dispatch>; 40 | setLastReadMessageIndex: React.Dispatch>; 41 | } 42 | 43 | export type { Header, Video, VideoOverlay, ControlBar, Chat, ChatMessage, MediaSelectModal }; 44 | -------------------------------------------------------------------------------- /server/src/study-room/repository/study-room.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | import { StudyRoom } from '../entity/study-room.entity'; 5 | 6 | @Injectable() 7 | export class StudyRoomRepository { 8 | constructor( 9 | @InjectRepository(StudyRoom) 10 | private readonly repository: Repository, 11 | ) {} 12 | 13 | async saveRoom(roomName: string, password: string, categoryName: string): Promise { 14 | const newRoom = this.repository.create({ 15 | room_name: roomName, 16 | password: password, 17 | category_name: categoryName, 18 | }); 19 | 20 | return await this.repository.save(newRoom); 21 | } 22 | 23 | async findAllRooms(): Promise { 24 | return this.repository.find(); 25 | } 26 | 27 | findRoomById(roomId: number): Promise { 28 | return this.repository.findOne({ where: { room_id: roomId } }); 29 | } 30 | 31 | async deleteRoomById(roomId: number) { 32 | this.repository.delete(roomId); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /client/src/components/StudyRoom/Video.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | import type { Video as VideoProps } from '@customTypes/StudyRoom'; 4 | 5 | const Video = ({ mediaStream, muted }: VideoProps) => { 6 | const videoRef = useRef(null); 7 | 8 | useEffect(() => { 9 | if (!mediaStream) return; 10 | const handleTrackEvent = () => { 11 | if (videoRef.current) { 12 | videoRef.current.srcObject = mediaStream; 13 | } 14 | }; 15 | 16 | handleTrackEvent(); 17 | mediaStream.addEventListener('addtrack', handleTrackEvent); 18 | mediaStream.addEventListener('removetrack', handleTrackEvent); 19 | 20 | return () => { 21 | mediaStream.removeEventListener('addtrack', handleTrackEvent); 22 | mediaStream.removeEventListener('removetrack', handleTrackEvent); 23 | }; 24 | }, [mediaStream]); 25 | 26 | return ( 27 |