diff --git a/backend/package.json b/backend/package.json index 11b922ba..a3918ab8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,6 +25,8 @@ "@nestjs/platform-express": "^10.0.0", "@nestjs/platform-socket.io": "^10.4.6", "@nestjs/websockets": "^10.4.6", + "dotenv": "^16.4.5", + "ioredis": "^5.4.1", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "socket.io": "^4.8.1" diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index f6e8a2e5..2e4f16bc 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,10 +1,14 @@ import { Module } from "@nestjs/common"; import { AppController } from "./app.controller"; import { AppService } from "./app.service"; -import { SocketModule } from "./socket/socket.module"; +import { SocketModule } from "./signaling-server/socket.module"; +import { RoomModule } from "./room/room.module"; +import { RedisModule } from "./redis/redis.module"; + +import "dotenv/config"; @Module({ - imports: [SocketModule], + imports: [SocketModule, RoomModule, RedisModule], controllers: [AppController], providers: [AppService], }) diff --git a/backend/src/config/redis.config.ts b/backend/src/config/redis.config.ts new file mode 100644 index 00000000..0b97e561 --- /dev/null +++ b/backend/src/config/redis.config.ts @@ -0,0 +1,7 @@ +import "dotenv/config"; + +export const redisConfig = { + host: process.env.REDIS_HOST, + port: parseInt(process.env.REDIS_PORT), + password: process.env.REDIS_PASSWORD, +}; diff --git a/backend/src/redis/redis.module.ts b/backend/src/redis/redis.module.ts new file mode 100644 index 00000000..77cec8eb --- /dev/null +++ b/backend/src/redis/redis.module.ts @@ -0,0 +1,7 @@ +import { Module } from "@nestjs/common"; +import { RedisService } from "./redis.service"; + +@Module({ + providers: [RedisService], +}) +export class RedisModule {} diff --git a/backend/src/redis/redis.service.spec.ts b/backend/src/redis/redis.service.spec.ts new file mode 100644 index 00000000..19faa0c1 --- /dev/null +++ b/backend/src/redis/redis.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { RedisService } from "./redis.service"; + +describe("RedisService", () => { + let service: RedisService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [RedisService], + }).compile(); + + service = module.get(RedisService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); +}); diff --git a/backend/src/redis/redis.service.ts b/backend/src/redis/redis.service.ts new file mode 100644 index 00000000..fef1b665 --- /dev/null +++ b/backend/src/redis/redis.service.ts @@ -0,0 +1,79 @@ +import { Injectable } from "@nestjs/common"; +import Redis from "ioredis"; +import { redisConfig } from "../config/redis.config"; +import { stringify } from "ts-jest"; + +@Injectable() +export class RedisService { + private readonly client: Redis = new Redis({ + host: redisConfig.host, + port: redisConfig.port, + password: redisConfig.password, + }); + + async set(key: string, value: any, ttl: number = 0) { + if (typeof value === "object") value = JSON.stringify(value); + + await this.client.set(key, value, "KEEPTTL"); + await this.client.expire(key, ttl); + } + + async get(key: string) { + return this.client.get(key); + } + + async getTTL(key: string) { + return this.client.ttl(key); + } + + async getKeys(query: string) { + const keys: string[] = []; + let cursor = "0"; + + do { + const [nextCursor, matchedKeys] = await this.client.scan( + cursor, + "MATCH", + query, + "COUNT", + "100" + ); + cursor = nextCursor; + keys.push(...matchedKeys); + } while (cursor !== "0"); + return keys; + } + + async getHashValueByField(key: string, field: string) { + return this.client.hget(key, field); + } + + async setHashValueByField(key: string, field: string, value: any) { + if (typeof value !== "string") value = stringify(value); + return this.client.hset(key, field, value); + } + + async delete(...keys: string[]) { + return this.client.del(...keys); + } + + async getValues(query: string) { + const keys = await this.getKeys(query); + if (!keys.length) return null; + return this.client.mget(keys); + } + + async getMap(query: string, valueType: "object" | "primitive" = "object") { + const keys = await this.getKeys(query); + const values = await this.getValues(query); + if (!values) return null; + + return keys.reduce((acc, key, index) => { + acc[key] = + valueType === "object" + ? JSON.parse(values[index]) + : values[index]; + return acc; + }, {}); + } +} diff --git a/backend/src/room/dto/create-room.dto.ts b/backend/src/room/dto/create-room.dto.ts new file mode 100644 index 00000000..50f0dc0b --- /dev/null +++ b/backend/src/room/dto/create-room.dto.ts @@ -0,0 +1,7 @@ +export interface CreateRoomDto { + title: string; + status: "PUBLIC" | "PRIVATE"; + nickname: string; + socketId: string; + maxParticipants?: number; +} diff --git a/backend/src/room/room.controller.spec.ts b/backend/src/room/room.controller.spec.ts new file mode 100644 index 00000000..7536f2ec --- /dev/null +++ b/backend/src/room/room.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { RoomController } from "./room.controller"; + +describe("RoomController", () => { + let controller: RoomController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [RoomController], + }).compile(); + + controller = module.get(RoomController); + }); + + it("should be defined", () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/backend/src/room/room.controller.ts b/backend/src/room/room.controller.ts new file mode 100644 index 00000000..5cdefd1d --- /dev/null +++ b/backend/src/room/room.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Get } from "@nestjs/common"; +import { RoomService } from "./room.service"; + +@Controller("rooms") +export class RoomController { + constructor(private readonly roomService: RoomService) {} + + @Get() + getPublicRooms() { + return this.roomService.getPublicRoom(); + } +} diff --git a/backend/src/room/room.gateway.spec.ts b/backend/src/room/room.gateway.spec.ts new file mode 100644 index 00000000..446b302b --- /dev/null +++ b/backend/src/room/room.gateway.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { RoomGateway } from "./room.gateway"; + +describe("RoomGateway", () => { + let gateway: RoomGateway; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [RoomGateway], + }).compile(); + + gateway = module.get(RoomGateway); + }); + + it("should be defined", () => { + expect(gateway).toBeDefined(); + }); +}); diff --git a/backend/src/room/room.gateway.ts b/backend/src/room/room.gateway.ts new file mode 100644 index 00000000..acdbda46 --- /dev/null +++ b/backend/src/room/room.gateway.ts @@ -0,0 +1,135 @@ +import { + WebSocketGateway, + WebSocketServer, + OnGatewayConnection, + OnGatewayDisconnect, + SubscribeMessage, +} from "@nestjs/websockets"; +import { Server, Socket } from "socket.io"; +import { RoomService } from "./room.service"; + +const EVENT_NAME = { + CREATE_ROOM: "create_room", + JOIN_ROOM: "join_room", + LEAVE_ROOM: "leave_room", + MASTER_CHANGED: "master_changed", + FINISH_ROOM: "finish_room", + REACTION: "reaction", + + ROOM_CREATED: "room_created", + USER_EXIT: "user_exit", + ROOM_FULL: "room_full", + ALL_USERS: "all_users", + ROOM_FINISHED: "room_finished", +} as const; + +/** + * 연결과 관련된 에러를 처리 + * + * 보다 연결과 관련된 코드가 존재 + */ +@WebSocketGateway({ + cors: { + origin: "*", // CORS 설정 + }, +}) +@WebSocketGateway() +export class RoomGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() + server: Server; + + constructor(private readonly roomService: RoomService) {} + + handleConnection(client: Socket) { + console.log(`Client connected in room: ${client.id}`); + } + + async handleDisconnect(client: Socket) { + console.log(`Client disconnected in room: ${client.id}`); + await this.handleLeaveRoom(client); + } + + @SubscribeMessage(EVENT_NAME.CREATE_ROOM) + async handleCreateRoom(client: Socket, data: any) { + const { title, nickname, status, maxParticipants } = data; // unknown 으로 받고, Dto와 Pipe로 검증받기 + try { + const roomData = await this.roomService.createRoom({ + title, + status, + socketId: client.id, + nickname, + maxParticipants, + }); + + client.join(roomData.roomId); + + this.server + .to(roomData.roomId) + .emit(EVENT_NAME.ROOM_CREATED, roomData); + } catch (error) { + console.error(error); + } + } + + @SubscribeMessage(EVENT_NAME.JOIN_ROOM) + async handleJoinRoom(client: Socket, data: any) { + const { roomId, nickname } = data; + + if (!(await this.roomService.checkAvailable(client.id, roomId))) { + // client joins full room + client.emit(EVENT_NAME.ROOM_FULL); + return; + } + + const room = await this.roomService.joinRoom( + client.id, + roomId, + nickname + ); + + client.join(roomId); + + console.log(`[${data.roomId}]: ${client.id} enter`); + + const usersInThisRoom = await this.roomService.getRoomMemberConnection( + client.id, + roomId + ); + + client.emit(EVENT_NAME.ALL_USERS, { + roomMetadata: room, + users: usersInThisRoom, + }); + } + + @SubscribeMessage(EVENT_NAME.LEAVE_ROOM) + async handleLeaveRoom(client: Socket) { + const roomId = await this.roomService.getRoomId(client.id); + + const isHost = await this.roomService.checkHost(client.id); + const leftCount = await this.roomService.leaveRoom(client.id); + + if (!leftCount) { + await this.roomService.deleteRoom(roomId); + return; + } + + if (isHost) { + const hostConnection = await this.roomService.delegateHost(roomId); + this.server.to(roomId).emit(EVENT_NAME.MASTER_CHANGED, { + masterSocketId: hostConnection.socketId, + masterNickname: hostConnection.nickname, + }); + } + + this.server + .to(roomId) + .emit(EVENT_NAME.USER_EXIT, { socketId: client.id }); + } + + @SubscribeMessage(EVENT_NAME.FINISH_ROOM) + async handleFinishRoom(client: Socket) { + const roomId = await this.roomService.finishRoom(client.id); + this.server.to(roomId).emit(EVENT_NAME.ROOM_FINISHED); + } +} diff --git a/backend/src/room/room.model.ts b/backend/src/room/room.model.ts new file mode 100644 index 00000000..1db80a32 --- /dev/null +++ b/backend/src/room/room.model.ts @@ -0,0 +1,15 @@ +export type RoomStatus = "PUBLIC" | "PRIVATE"; + +export interface Room { + title: string; + status: RoomStatus; + maxParticipants: number; + createdAt: number; + host: string; +} + +export interface MemberConnection { + joinTime: number; + isHost: boolean; + nickname: string; +} diff --git a/backend/src/room/room.module.ts b/backend/src/room/room.module.ts new file mode 100644 index 00000000..4bf3e9c1 --- /dev/null +++ b/backend/src/room/room.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { RoomService } from "./room.service"; +import { RoomGateway } from "./room.gateway"; +import { RedisService } from "../redis/redis.service"; +import { RoomRepository } from "./room.repository"; +import { RoomController } from "./room.controller"; + +@Module({ + providers: [RoomService, RoomGateway, RedisService, RoomRepository], + controllers: [RoomController], +}) +export class RoomModule {} diff --git a/backend/src/room/room.repository.ts b/backend/src/room/room.repository.ts new file mode 100644 index 00000000..52c0ecfb --- /dev/null +++ b/backend/src/room/room.repository.ts @@ -0,0 +1,166 @@ +import { Injectable } from "@nestjs/common"; +import { RedisService } from "../redis/redis.service"; +import { MemberConnection, Room } from "./room.model"; +import { generateRoomId } from "../utils/generateRoomId"; +import { HOUR } from "../utils/time"; +import { CreateRoomDto } from "./dto/create-room.dto"; + +/** + * `room:` 과 같은 태그를 사용하는 부분 + * 실제 더 복잡한 비즈니스 로직을 처리할 수 있도록 함수를 일반화 하려고 합니다. + */ +@Injectable() +export class RoomRepository { + constructor(private readonly redisService: RedisService) {} + + async getAllRoom(): Promise> { + const redisMap = await this.redisService.getMap("room:*"); + console.log(redisMap); + + return Object.entries(redisMap).reduce( + (acc, [roomId, room]) => { + acc[roomId.split(":")[1]] = room as Room; + return acc; + }, + {} as Record + ); + } + + async getRoomMemberConnection(callerId: string, roomId: string) { + const room = await this.getRoomById(roomId); + const connectionMap = await this.redisService.getMap( + `join:${roomId}:*` + ); + + if (!connectionMap) return {}; + + return Object.entries(connectionMap).reduce( + (acc, [socketId, connection]) => { + socketId = socketId.split(":")[2]; + + acc[socketId] = { + ...(connection as MemberConnection), + isHost: room.host === socketId, + } as MemberConnection; // 현재 as 키워드를 사용했지만, 별도의 검증 로직이 필요합니다. + return acc; + }, + {} as Record + ); + } + + async checkHost(socketId: string) { + const roomId = await this.findMyRoomId(socketId); + + const room = JSON.parse(await this.redisService.get(`room:${roomId}`)); + if (!room) return false; + return socketId === room.host; + } + + async getRoomById(roomId: string) { + const room = JSON.parse(await this.redisService.get(`room:${roomId}`)); + + if (!room) return null; + + return room as Room; + } + + async findMyRoomId(socketId: string) { + const keys = await this.redisService.getKeys(`join:*:${socketId}`); + if (!keys.length) return null; + return keys[0].split(":")[1]; + } + + async getRoomMemberCount(roomId: string) { + const keys = await this.redisService.getKeys(`join:${roomId}:*`); + + return keys.length; + } + + async getNewHost(roomId: string) { + const memberKeys = await this.redisService.getKeys(`join:${roomId}:*`); + const members = []; + for (const key of memberKeys) { + const socketId = key.split(":")[2]; + const value = await this.redisService.get( + `join:${roomId}:${socketId}` + ); + const result = JSON.parse(value); + members.push({ + socketId, + joinTime: result.joinTime, + nickname: result.nickname, + }); + } + + const sortedMembers = members.sort((a, b) => a.joinTime - b.joinTime); + return sortedMembers[0] as { + joinTime: number; + nickname: string; + socketId: string; + }; + } + + async setNewHost(roomId: string, newHostId: string) { + const room = JSON.parse(await this.redisService.get(`room:${roomId}`)); + const roomTTL = await this.redisService.getTTL(`room:${roomId}`); + + await this.redisService.set( + `room:${roomId}`, + { + ...room, + host: newHostId, + }, + roomTTL + ); + } + + async createRoom(dto: CreateRoomDto) { + const { title, socketId, maxParticipants, status } = dto; + const roomId = generateRoomId(); + + await this.redisService.set( + `room:${roomId}`, + { + title, + createdAt: Date.now(), + host: socketId, + maxParticipants, + status, + } as Room, + 6 * HOUR + ); + + return roomId; + } + + async addUser(roomId: string, socketId: string, nickname: string) { + const connections = await this.redisService.getKeys( + `join:*:${socketId}` + ); + + if (connections.length > 0) { + // overlapped connection error + } + + const roomTTL = await this.redisService.getTTL(`room:${roomId}`); + + await this.redisService.set( + `join:${roomId}:${socketId}`, + { + joinTime: Date.now(), + nickname, + } as MemberConnection, + roomTTL + ); + } + + async deleteUser(socketId: string) { + const keys = await this.redisService.getKeys(`join:*:${socketId}`); + + await this.redisService.delete(...keys); + } + + async deleteRoom(roomId: string) { + await this.redisService.delete(`room:${roomId}`); + } +} diff --git a/backend/src/room/room.service.spec.ts b/backend/src/room/room.service.spec.ts new file mode 100644 index 00000000..0ab7dff4 --- /dev/null +++ b/backend/src/room/room.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { RoomService } from "./room.service"; + +describe("RoomService", () => { + let service: RoomService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [RoomService], + }).compile(); + + service = module.get(RoomService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); +}); diff --git a/backend/src/room/room.service.ts b/backend/src/room/room.service.ts new file mode 100644 index 00000000..dc7a413e --- /dev/null +++ b/backend/src/room/room.service.ts @@ -0,0 +1,118 @@ +import { Injectable } from "@nestjs/common"; +import { RoomRepository } from "./room.repository"; +import { CreateRoomDto } from "./dto/create-room.dto"; +import { MemberConnection } from "./room.model"; + +/** + * 비즈니스 로직 처리를 좀 더 하게 하기 위한 클래스로 설정 + * 예외 처리 (로직, 더 밑단 에러 처리 - redis 에러 등) 도 이곳에서 이루어질 예정 + * redis 와 관련된 부분은 모르도록 하려고 합니다. + */ +@Injectable() +export class RoomService { + private static MAX_MEMBERS = 5; + + constructor(private readonly roomRepository: RoomRepository) {} + + async getPublicRoom() { + const rooms = await this.roomRepository.getAllRoom(); + + Object.keys(rooms).forEach((roomId) => { + if (rooms[roomId].status === "PRIVATE") rooms[roomId] = undefined; + }); + return rooms; + } + + async getRoomId(socketId: string) { + return this.roomRepository.findMyRoomId(socketId); + } + + async createRoom(dto: CreateRoomDto) { + const { title, status, maxParticipants, socketId, nickname } = dto; + const roomId = await this.roomRepository.createRoom({ + title, + status: status ?? "PUBLIC", + maxParticipants: maxParticipants ?? RoomService.MAX_MEMBERS, + socketId, + nickname: nickname ?? "Master", + }); + await this.roomRepository.addUser(roomId, dto.socketId, dto.nickname); + return { + roomId, + roomMetadata: { + title, + status: status ?? "PUBLIC", + maxParticipants: maxParticipants ?? RoomService.MAX_MEMBERS, + host: socketId, + nickname: nickname ?? "Master", + }, + }; + } + + async joinRoom(socketId: string, roomId: string, nickname: string) { + const room = this.roomRepository.getRoomById(roomId); + if (!room) return null; // throw join error + await this.roomRepository.addUser(roomId, socketId, nickname); + return room; + } + + async getRoomMemberConnection(callerId: string, roomId: string) { + const memberConnection = + await this.roomRepository.getRoomMemberConnection(callerId, roomId); + if (!memberConnection) return null; + + return Object.entries(memberConnection).reduce( + (acc, [id, memberConnection]) => { + if (callerId !== id) { + acc[id] = memberConnection; + } + return acc; + }, + {} as Record + ); + } + + async checkAvailable(socketId: string, roomId: string) { + const room = await this.roomRepository.getRoomById(roomId); + const members = await this.roomRepository.getRoomMemberConnection( + socketId, + roomId + ); + + return Object.keys(members).length < room.maxParticipants; + } + + async checkHost(socketId: string) { + return await this.roomRepository.checkHost(socketId); + } + + async checkConnected(socketId: string) { + return this.roomRepository.findMyRoomId(socketId); + } + + async leaveRoom(socketId: string) { + const roomId = await this.roomRepository.findMyRoomId(socketId); + + if (!roomId) return null; + + await this.roomRepository.deleteUser(socketId); + + return this.roomRepository.getRoomMemberCount(roomId); + } + + async delegateHost(roomId: string) { + const newHost = await this.roomRepository.getNewHost(roomId); + await this.roomRepository.setNewHost(roomId, newHost.socketId); + return newHost; + } + + async finishRoom(socketId: string) { + const roomId = await this.roomRepository.findMyRoomId(socketId); + await this.roomRepository.deleteRoom(roomId); + return roomId; + } + + async deleteRoom(roomId: string) { + await this.roomRepository.deleteRoom(roomId); + } +} diff --git a/backend/src/socket/socket.gateway.ts b/backend/src/signaling-server/socket.gateway.ts similarity index 50% rename from backend/src/socket/socket.gateway.ts rename to backend/src/signaling-server/socket.gateway.ts index 7246aad3..2927fa5e 100644 --- a/backend/src/socket/socket.gateway.ts +++ b/backend/src/signaling-server/socket.gateway.ts @@ -8,11 +8,6 @@ import { } from "@nestjs/websockets"; import { Server } from "socket.io"; -interface User { - id: string; - nickname: string; -} - @WebSocketGateway({ cors: { origin: "*", // CORS 설정 @@ -22,57 +17,12 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { @WebSocketServer() server: Server; - private users: { [key: string]: User[] } = {}; - private socketToRoom: { [key: string]: string } = {}; - private maximum = 5; - handleConnection(socket: any) { - console.log(`Client connected: ${socket.id}`); + console.log(`Client connected in signaling server: ${socket.id}`); } handleDisconnect(socket: any) { - console.log(`Client disconnected: ${socket.id}`); - const roomID = this.socketToRoom[socket.id]; - if (roomID) { - const room = this.users[roomID]; - if (room) { - this.users[roomID] = room.filter( - (user) => user.id !== socket.id - ); - if (this.users[roomID].length === 0) { - delete this.users[roomID]; - } else { - this.server.to(roomID).emit("user_exit", { id: socket.id }); - } - } - } - } - - @SubscribeMessage("join_room") - handleJoinRoom(socket: any, data: { room: string; nickname: string }) { - if (this.users[data.room]) { - if (this.users[data.room].length === this.maximum) { - socket.emit("room_full"); - return; - } - this.users[data.room].push({ - id: socket.id, - nickname: data.nickname, - }); - } else { - this.users[data.room] = [ - { id: socket.id, nickname: data.nickname }, - ]; - } - - this.socketToRoom[socket.id] = data.room; - socket.join(data.room); - console.log(`[${data.room}]: ${socket.id} enter`); - - const usersInThisRoom = this.users[data.room].filter( - (user) => user.id !== socket.id - ); - socket.emit("all_users", usersInThisRoom); + console.log(`Client disconnected signaling server: ${socket.id}`); } @SubscribeMessage("offer") @@ -121,4 +71,17 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { candidateSendID: data.candidateSendID, }); } + + @SubscribeMessage("reaction") + handleReaction( + socket: any, + data: { + roomId: string; + reaction: string; + } + ) { + this.server + .to(data.roomId) + .emit("reaction", { senderId: socket.id, reaction: data.reaction }); + } } diff --git a/backend/src/socket/socket.module.ts b/backend/src/signaling-server/socket.module.ts similarity index 100% rename from backend/src/socket/socket.module.ts rename to backend/src/signaling-server/socket.module.ts diff --git a/backend/src/utils/generateRoomId.ts b/backend/src/utils/generateRoomId.ts new file mode 100644 index 00000000..c808594a --- /dev/null +++ b/backend/src/utils/generateRoomId.ts @@ -0,0 +1,9 @@ +import { createHash } from "node:crypto"; +import "dotenv/config"; + +let id = parseInt(process.env.SESSION_HASH_START); +export function generateRoomId() { + return createHash("sha256") + .update(id++ + process.env.SESSION_HASH) + .digest("hex"); +} \ No newline at end of file diff --git a/backend/src/utils/time.ts b/backend/src/utils/time.ts new file mode 100644 index 00000000..655f5325 --- /dev/null +++ b/backend/src/utils/time.ts @@ -0,0 +1,3 @@ +export const SEC = 1; +export const MIN = 60 * SEC; +export const HOUR = 60 * MIN; diff --git a/frontend/package.json b/frontend/package.json index 3e2aade2..943e72f1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,10 +17,12 @@ "react-dom": "^18.3.1", "react-icons": "^5.3.0", "react-router-dom": "^6.27.0", - "socket.io-client": "^4.8.1" + "socket.io-client": "^4.8.1", + "zustand": "^5.0.1" }, "devDependencies": { "@eslint/js": "^9.13.0", + "@types/node": "^20.3.1", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", diff --git a/frontend/src/assets/fonts/PretendardVariable.woff2 b/frontend/src/assets/fonts/PretendardVariable.woff2 deleted file mode 100644 index 49c54b51..00000000 Binary files a/frontend/src/assets/fonts/PretendardVariable.woff2 and /dev/null differ diff --git a/frontend/src/assets/mike-off.svg b/frontend/src/assets/mike-off.svg deleted file mode 100644 index c5298003..00000000 --- a/frontend/src/assets/mike-off.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/src/assets/mike-on.svg b/frontend/src/assets/mike-on.svg deleted file mode 100644 index 46b5f6fd..00000000 --- a/frontend/src/assets/mike-on.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9b..00000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/assets/video_profile.svg b/frontend/src/assets/video_profile.svg deleted file mode 100644 index d5a6ab8d..00000000 --- a/frontend/src/assets/video_profile.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/frontend/src/components/common/Modal/Title.tsx b/frontend/src/components/common/Modal/Title.tsx new file mode 100644 index 00000000..c9fe807f --- /dev/null +++ b/frontend/src/components/common/Modal/Title.tsx @@ -0,0 +1,21 @@ +import { ModalProps } from "."; + +const ModalTitle = ({ + title, + subtitle, +}: Pick) => { + return ( +
+ {title.split("\\n").map((text, index) => { + return ( +

+ {text} +

+ ); + })} + {subtitle} +
+ ); +}; + +export default ModalTitle; diff --git a/frontend/src/components/common/Modal/index.tsx b/frontend/src/components/common/Modal/index.tsx new file mode 100644 index 00000000..18f4bf8b --- /dev/null +++ b/frontend/src/components/common/Modal/index.tsx @@ -0,0 +1,62 @@ +import { useRef } from "react"; +import useModalStore from "@/stores/useModalStore"; +import useModal from "@/hooks/useModal"; +import ModalTitle from "./Title"; + +export interface ModalProps { + title: string; + subtitle: string; + leftButton: string; + rightButton: string; + type: "red" | "green"; + onLeftClick: () => void; + onRightClick: () => void; +} + +const Modal = ({ + title, + subtitle, + leftButton, + rightButton, + type, + onLeftClick, + onRightClick, +}: ModalProps) => { + const dialogRef = useRef(null); + const { isModalOpen, closeModal } = useModalStore(); + + useModal({ dialogRef, isModalOpen }); + + const handleButtonClick = (callback: () => void) => () => { + callback(); + closeModal(); + }; + + if (!isModalOpen) return null; + + return ( + + +
+ + +
+
+ ); +}; + +export default Modal; diff --git a/frontend/src/components/common/ProgressBar.tsx b/frontend/src/components/common/ProgressBar.tsx new file mode 100644 index 00000000..5c91ce90 --- /dev/null +++ b/frontend/src/components/common/ProgressBar.tsx @@ -0,0 +1,28 @@ +import { useEffect, useRef } from "react"; + +interface ProgressBarProps { + duration: number; +} + +const ProgressBar = ({ duration }: ProgressBarProps) => { + const progressRef = useRef(null); + + useEffect(() => { + const progressBar = progressRef.current; + if (progressBar) { + progressBar.style.animation = `expand ${duration}ms linear`; + } + }, [duration]); + + return ( +
+
+
+ ); +}; + +export default ProgressBar; diff --git a/frontend/src/components/common/SearchBar.tsx b/frontend/src/components/common/SearchBar.tsx new file mode 100644 index 00000000..ad104517 --- /dev/null +++ b/frontend/src/components/common/SearchBar.tsx @@ -0,0 +1,21 @@ +import { IoIosSearch } from "react-icons/io"; +interface Props { + text: string; +} + +const SearchBar = ({ text }: Props) => { + return ( +
+ + +
+ ); +}; + +export default SearchBar; diff --git a/frontend/src/components/common/Toast.tsx b/frontend/src/components/common/Toast.tsx new file mode 100644 index 00000000..ef98581f --- /dev/null +++ b/frontend/src/components/common/Toast.tsx @@ -0,0 +1,31 @@ +import { IoIosClose } from "react-icons/io"; +import ProgressBar from "./ProgressBar.tsx"; + +interface ToastProps { + message: string; + type: "success" | "error"; + removeToast: () => void; +} + +const Toast = ({ message, type, removeToast }: ToastProps) => { + return ( +
+
+

+ {message} +

+ +
+ +
+ ); +}; + +export default Toast; diff --git a/frontend/src/components/common/ToastProvider.tsx b/frontend/src/components/common/ToastProvider.tsx new file mode 100644 index 00000000..6486404e --- /dev/null +++ b/frontend/src/components/common/ToastProvider.tsx @@ -0,0 +1,29 @@ +import Toast from "./Toast.tsx"; +import useToastStore from "../../stores/useToastStore.ts"; + +interface Toast { + id: number; + message: string; + type: "success" | "error"; +} + +const ToastProvider = () => { + const { toasts, removeToast } = useToastStore(); + + return ( +
+ {toasts.map((toast: Toast) => { + return ( + removeToast(toast.id)} + message={toast.message} + type={toast.type} + /> + ); + })} +
+ ); +}; + +export default ToastProvider; diff --git a/frontend/src/components/session/DisplayMediaStream.tsx b/frontend/src/components/session/DisplayMediaStream.tsx new file mode 100644 index 00000000..3a6564b6 --- /dev/null +++ b/frontend/src/components/session/DisplayMediaStream.tsx @@ -0,0 +1,32 @@ +import { useEffect, useRef } from "react"; + +interface DisplayMediaStreamProps { + mediaStream: MediaStream | null; + isLocal: boolean; +} +const DisplayMediaStream = ({ + mediaStream, + isLocal, +}: DisplayMediaStreamProps) => { + const videoRef = useRef(null); + + useEffect(() => { + if (mediaStream !== null && videoRef.current) { + videoRef.current.srcObject = mediaStream; + } else if (videoRef.current && mediaStream === null) { + videoRef.current.srcObject = null; + } + }, [mediaStream]); + + return ( +