From 5780fd1e26750aabf4811133987e377a756007f0 Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Thu, 18 Nov 2021 18:22:43 +0900 Subject: [PATCH 001/172] =?UTF-8?q?Feat=20:=20UserChannel=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EA=B4=80=EB=A0=A8=20=EC=BD=94=EB=93=9C=EB=93=A4=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/app.module.ts | 2 + .../user-channel/user-channel.controller.ts | 36 +++++++++++++ .../src/user-channel/user-channel.entity.ts | 19 +++++++ .../src/user-channel/user-channel.module.ts | 14 +++++ .../user-channel/user-channel.repository.ts | 26 ++++++++++ .../src/user-channel/user-channel.service.ts | 51 +++++++++++++++++++ 6 files changed, 148 insertions(+) create mode 100644 backend/src/user-channel/user-channel.controller.ts create mode 100644 backend/src/user-channel/user-channel.entity.ts create mode 100644 backend/src/user-channel/user-channel.module.ts create mode 100644 backend/src/user-channel/user-channel.repository.ts create mode 100644 backend/src/user-channel/user-channel.service.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index df829bb..687ce5a 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -15,6 +15,7 @@ import { ServerModule } from './server/server.module'; import { CamsModule } from './cams/cams.module'; import { UserServerModule } from './user-server/user-server.module'; import { LoginModule } from './login/login.module'; +import { UserChannelModule } from './user-channel/user-channel.module'; import githubConfig from './config/github.config'; @Module({ @@ -35,6 +36,7 @@ import githubConfig from './config/github.config'; CamsModule, UserServerModule, LoginModule, + UserChannelModule, ], controllers: [AppController], providers: [AppService], diff --git a/backend/src/user-channel/user-channel.controller.ts b/backend/src/user-channel/user-channel.controller.ts new file mode 100644 index 0000000..3a3b4cd --- /dev/null +++ b/backend/src/user-channel/user-channel.controller.ts @@ -0,0 +1,36 @@ +import { + Controller, + Post, + Body, + Delete, + Param, + Session, + Get, +} from '@nestjs/common'; +import { ExpressSession } from '../types/session'; +import { UserChannel } from './user-channel.entity'; +import { UserChannelService } from './user-channel.service'; + +@Controller('/api/user/channels') +export class UserChannelController { + constructor(private userChannelService: UserChannelService) {} + + @Post() + create(@Body() userChannel: UserChannel) { + return this.userChannelService.create(userChannel); + } + + @Get() + getJoinedChannelList( + @Param('serverId') serverid: number, + @Session() session: ExpressSession, + ) { + console.log(session.user); + //return this.userChannelService.deleteById(serverid); + } + + @Delete('/:id') + delete(@Param('id') id: number) { + return this.userChannelService.deleteById(id); + } +} diff --git a/backend/src/user-channel/user-channel.entity.ts b/backend/src/user-channel/user-channel.entity.ts new file mode 100644 index 0000000..625206b --- /dev/null +++ b/backend/src/user-channel/user-channel.entity.ts @@ -0,0 +1,19 @@ +import { Entity, PrimaryGeneratedColumn, ManyToOne } from 'typeorm'; +import { User } from '../user/user.entity'; +import { Server } from '../server/server.entity'; +import { Channel } from 'src/channel/channel.entity'; + +@Entity() +export class UserChannel { + @PrimaryGeneratedColumn({ type: 'bigint' }) + id: number; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + user: User; + + @ManyToOne(() => Channel, { onDelete: 'CASCADE' }) + channel: Channel; + + @ManyToOne(() => Server, { onDelete: 'CASCADE' }) + server: Server; +} diff --git a/backend/src/user-channel/user-channel.module.ts b/backend/src/user-channel/user-channel.module.ts new file mode 100644 index 0000000..87ecb6f --- /dev/null +++ b/backend/src/user-channel/user-channel.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { UserChannelController } from './user-channel.controller'; +import { UserChannel } from './user-channel.entity'; +import { UserChannelRepository } from './user-channel.repository'; +import { UserChannelService } from './user-channel.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([UserChannel, UserChannelRepository])], + providers: [UserChannelService], + controllers: [UserChannelController], + exports: [UserChannelService, TypeOrmModule], +}) +export class UserChannelModule {} diff --git a/backend/src/user-channel/user-channel.repository.ts b/backend/src/user-channel/user-channel.repository.ts new file mode 100644 index 0000000..d75e4f1 --- /dev/null +++ b/backend/src/user-channel/user-channel.repository.ts @@ -0,0 +1,26 @@ +import { EntityRepository, Repository, getConnection } from 'typeorm'; +import { UserChannel } from './user-channel.entity'; + +@EntityRepository(UserChannel) +export class UserChannelRepository extends Repository { + getJoinedChannelListByUserId(userId: number, serverId: number) { + return this.createQueryBuilder('user_channel') + .where('user_channel.user = :userId', { userId: userId }) + .andWhere('user_channel.server = :serverId', { serverId: serverId }) + .getMany(); + } + + getNotJoinedChannelListByUserId(userId: number, serverId: number) { + return this.createQueryBuilder('user_channel') + .where('user_channel.user != :userId', { userId: userId }) + .andWhere('user_channel.server = :serverId', { serverId: serverId }) + .getMany(); + } + + deleteByUserIdAndChannelId(userId: number, channelId: number) { + return this.createQueryBuilder('user_channel') + .where('user_channel.user = :userId', { userId: userId }) + .andWhere('user_channel.channelId = :channelId', { channelId: channelId }) + .delete(); + } +} diff --git a/backend/src/user-channel/user-channel.service.ts b/backend/src/user-channel/user-channel.service.ts new file mode 100644 index 0000000..8451559 --- /dev/null +++ b/backend/src/user-channel/user-channel.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { UserChannelRepository } from './user-channel.repository'; +import { UserChannel } from './user-channel.entity'; +import { DeleteQueryBuilder, DeleteResult } from 'typeorm'; + +@Injectable() +export class UserChannelService { + constructor( + @InjectRepository(UserChannelRepository) + private userChannelRepository: UserChannelRepository, + ) {} + + create(userChannel: UserChannel): Promise { + return this.userChannelRepository.save(userChannel); + } + + deleteById(id: number): Promise { + return this.userChannelRepository.delete(id); + } + + deleteByUserIdAndChannelId( + userId: number, + serverId: number, + ): DeleteQueryBuilder { + return this.userChannelRepository.deleteByUserIdAndChannelId( + userId, + serverId, + ); + } + + getJoinedChannelListByUserId( + userId: number, + serverId: number, + ): Promise { + return this.userChannelRepository.getJoinedChannelListByUserId( + userId, + serverId, + ); + } + + getNotJoinedChannelListByUserId( + userId: number, + serverId: number, + ): Promise { + return this.userChannelRepository.getNotJoinedChannelListByUserId( + userId, + serverId, + ); + } +} From 8ced6fd2c4171562b2f06ee21c5f8119c0842ae9 Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Thu, 18 Nov 2021 18:29:54 +0900 Subject: [PATCH 002/172] =?UTF-8?q?Feat=20:=20=EC=83=88=EB=A1=9C=EC=9A=B4?= =?UTF-8?q?=20=EC=B1=84=EB=84=90=EC=9D=84=20=EC=83=9D=EC=84=B1=ED=95=A0=20?= =?UTF-8?q?=EB=95=8C=20=EC=82=AC=EC=9A=A9=ED=95=A0=20CreateChannelDto=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/channel/channe.dto.ts | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 backend/src/channel/channe.dto.ts diff --git a/backend/src/channel/channe.dto.ts b/backend/src/channel/channe.dto.ts new file mode 100644 index 0000000..d727c5e --- /dev/null +++ b/backend/src/channel/channe.dto.ts @@ -0,0 +1,5 @@ +export type CreateChannelDto = { + name: string; + description: string; + serverId: number; +}; From 13e748403f0052cad9d071e874415e016eb70560 Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Thu, 18 Nov 2021 18:30:31 +0900 Subject: [PATCH 003/172] =?UTF-8?q?Feat=20:=20Channel=20=EC=9D=84=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=ED=95=A0=20=EB=95=8C=20UserChannel=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=EC=97=90=EB=8F=84=20=EA=B0=92?= =?UTF-8?q?=EC=9D=84=20=EC=A0=80=EC=9E=A5=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/channel/channel.controller.ts | 15 +++++++-- backend/src/channel/channel.module.ts | 7 ++++- backend/src/channel/channel.service.ts | 38 +++++++++++++++++++++-- 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/backend/src/channel/channel.controller.ts b/backend/src/channel/channel.controller.ts index cb77445..79156e7 100644 --- a/backend/src/channel/channel.controller.ts +++ b/backend/src/channel/channel.controller.ts @@ -6,10 +6,13 @@ import { Param, Post, Patch, + Session, } from '@nestjs/common'; +import { ExpressSession } from '../types/session'; import { ChannelService } from './channel.service'; import { Channel } from './channel.entity'; +import { CreateChannelDto } from './channe.dto'; @Controller('api/channel') export class ChannelController { @@ -32,10 +35,16 @@ export class ChannelController { statusMsg: `데이터 조회가 성공적으로 완료되었습니다.`, }); } - @Post() async saveChannel(@Body() channel: Channel): Promise { - await this.channelService.addChannel(channel); + @Post() async saveChannel( + @Body() channel: CreateChannelDto, + @Session() session: ExpressSession, + ): Promise { + const savedChannel = await this.channelService.addChannel( + channel, + session.user.id, + ); return Object.assign({ - data: { ...channel }, + data: { ...savedChannel }, statusCode: 200, statusMsg: `saved successfully`, }); diff --git a/backend/src/channel/channel.module.ts b/backend/src/channel/channel.module.ts index e62f9d3..dcb91eb 100644 --- a/backend/src/channel/channel.module.ts +++ b/backend/src/channel/channel.module.ts @@ -4,9 +4,14 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Channel } from './channel.entity'; import { ChannelController } from './channel.controller'; import { ChannelService } from './channel.service'; +import { UserChannelRepository } from 'src/user-channel/user-channel.repository'; +import { Server } from 'src/server/server.entity'; +import { User } from 'src/user/user.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Channel])], + imports: [ + TypeOrmModule.forFeature([Channel, Server, User, UserChannelRepository]), + ], providers: [ChannelService], controllers: [ChannelController], }) diff --git a/backend/src/channel/channel.service.ts b/backend/src/channel/channel.service.ts index a9da6c4..de48f32 100644 --- a/backend/src/channel/channel.service.ts +++ b/backend/src/channel/channel.service.ts @@ -1,13 +1,22 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { UserChannel } from 'src/user-channel/user-channel.entity'; +import { UserChannelRepository } from 'src/user-channel/user-channel.repository'; import { Repository } from 'typeorm/index'; import { Channel } from './channel.entity'; +import { User } from 'src/user/user.entity'; +import { CreateChannelDto } from './channe.dto'; +import { Server } from 'src/server/server.entity'; @Injectable() export class ChannelService { /** * 생성자 */ constructor( @InjectRepository(Channel) private channelRepository: Repository, + @InjectRepository(User) private userRepository: Repository, + @InjectRepository(Server) private serverRepository: Repository, + @InjectRepository(UserChannelRepository) + private userChannelRepository: UserChannelRepository, ) { this.channelRepository = channelRepository; } @@ -17,8 +26,31 @@ export class ChannelService { findOne(id: number): Promise { return this.channelRepository.findOne({ id: id }); } - async addChannel(channel: Channel): Promise { - await this.channelRepository.save(channel); + async addChannel( + channel: CreateChannelDto, + userId: number, + ): Promise { + const channelEntity = this.channelRepository.create(); + const server = await this.serverRepository.findOne({ + id: channel.serverId, + }); + + if (!server) { + throw new BadRequestException(); + } + + channelEntity.name = channel.name; + channelEntity.description = channel.description; + channelEntity.server = server; + + const savedChannel = await this.channelRepository.save(channelEntity); + const user = await this.userRepository.findOne({ id: userId }); + const userChannel = this.userChannelRepository.create(); + userChannel.channel = savedChannel; + userChannel.server = server; + userChannel.user = user; + await this.userChannelRepository.save(userChannel); + return savedChannel; } async updateChannel(id: number, channel: Channel): Promise { await this.channelRepository.update(id, channel); From e4d3f5a8548cf6d821dccb3738d8418dcaa043c4 Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Thu, 18 Nov 2021 18:31:28 +0900 Subject: [PATCH 004/172] =?UTF-8?q?Fix=20:=20=EC=88=98=EC=A0=95=EB=90=9C?= =?UTF-8?q?=20API=20=EC=A0=95=EB=B3=B4=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Main/Modal/CreateChannelModal.tsx | 2 +- frontend/src/components/Main/ServerListTab.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Main/Modal/CreateChannelModal.tsx b/frontend/src/components/Main/Modal/CreateChannelModal.tsx index a59c5fd..ccc14be 100644 --- a/frontend/src/components/Main/Modal/CreateChannelModal.tsx +++ b/frontend/src/components/Main/Modal/CreateChannelModal.tsx @@ -187,7 +187,7 @@ function CreateChannelModal(): JSX.Element { body: JSON.stringify({ name: name.trim(), description: description.trim(), - server: +selectedServer, + serverId: +selectedServer, }), }); }; diff --git a/frontend/src/components/Main/ServerListTab.tsx b/frontend/src/components/Main/ServerListTab.tsx index e5a588f..20910eb 100644 --- a/frontend/src/components/Main/ServerListTab.tsx +++ b/frontend/src/components/Main/ServerListTab.tsx @@ -90,7 +90,7 @@ function ServerListTab(): JSX.Element { const getServerList = async (): Promise => { const userIdExam = 1; - const response = await fetch(`/api/users/${userIdExam}/servers`); + const response = await fetch(`/api/user/${userIdExam}/servers`); const list = await response.json(); setServerList(list); }; From 7627f7cd888169bbcdeaa6ad51e734bcd66fb4c6 Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Thu, 18 Nov 2021 16:49:07 +0900 Subject: [PATCH 005/172] =?UTF-8?q?Refactor=20:=20session=EC=9D=98=20user?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EB=A1=9C=20server=EC=97=90=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그인된 유저의 세션정보를 사용하여 새로운 서버만을 받아서 참여하도록 구현했습니다. --- .../src/user-server/user-server.controller.ts | 24 +++++++++++++++---- .../src/user-server/user-server.service.ts | 9 +++++-- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/backend/src/user-server/user-server.controller.ts b/backend/src/user-server/user-server.controller.ts index 1a2767a..e964310 100644 --- a/backend/src/user-server/user-server.controller.ts +++ b/backend/src/user-server/user-server.controller.ts @@ -1,14 +1,30 @@ -import { Controller, Post, Body, Delete, Param } from '@nestjs/common'; -import { UserServer } from './user-server.entity'; +import { + Controller, + Post, + Body, + Delete, + Param, + Session, + UseGuards, +} from '@nestjs/common'; +import { Server } from '../server/server.entity'; +import { LoginGuard } from '../login/login.guard'; +import { ExpressSession } from '../types/session'; import { UserServerService } from './user-server.service'; @Controller('/api/users/servers') +@UseGuards(LoginGuard) export class UserServerController { constructor(private userServerService: UserServerService) {} @Post() - create(@Body() userServer: UserServer) { - return this.userServerService.create(userServer); + create( + @Session() + session: ExpressSession, + @Body() server: Server, + ) { + const user = session.user; + return this.userServerService.create(user, server); } @Delete('/:id') diff --git a/backend/src/user-server/user-server.service.ts b/backend/src/user-server/user-server.service.ts index 4e63fd6..78b37de 100644 --- a/backend/src/user-server/user-server.service.ts +++ b/backend/src/user-server/user-server.service.ts @@ -3,6 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { UserServerRepository } from './user-server.repository'; import { UserServer } from './user-server.entity'; import { DeleteQueryBuilder, DeleteResult } from 'typeorm'; +import { User } from '../user/user.entity'; +import { Server } from '../server/server.entity'; @Injectable() export class UserServerService { @@ -11,8 +13,11 @@ export class UserServerService { private userServerRepository: UserServerRepository, ) {} - create(userServer: UserServer): Promise { - return this.userServerRepository.save(userServer); + async create(user: User, server: Server): Promise { + const newUserServer = new UserServer(); + newUserServer.user = user; + newUserServer.server = server; + return this.userServerRepository.save(newUserServer); } deleteById(id: number): Promise { From 1460a4650b8069f9f9d7c13c3c185eed39f05a77 Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Thu, 18 Nov 2021 17:01:38 +0900 Subject: [PATCH 006/172] =?UTF-8?q?Feat=20:=20ResponseEntity=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=84=9C=EB=B2=84=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=ED=98=B8=EC=B6=9C=20api=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api의 리턴타입을 통일하기 위해 ResponseEntity를 추가하였습니다. - 서버목록 호출 api의 리턴타입을 변경하고 세션의 유저정보로 서버목록을 호출하도록 변경했습니다. --- backend/src/common/response-entity.ts | 16 ++++++++++++++++ backend/src/user/user.controller.ts | 15 +++++++++++---- frontend/src/components/Main/ServerListTab.tsx | 5 ++--- 3 files changed, 29 insertions(+), 7 deletions(-) create mode 100644 backend/src/common/response-entity.ts diff --git a/backend/src/common/response-entity.ts b/backend/src/common/response-entity.ts new file mode 100644 index 0000000..ed0aa24 --- /dev/null +++ b/backend/src/common/response-entity.ts @@ -0,0 +1,16 @@ +class ResponseEntity { + statusCode: number; + message: string; + data: T; + constructor(statusCode: number, message: string, data: T) { + this.statusCode = statusCode; + this.message = message; + this.data = data; + } + + static ok(data: T): ResponseEntity { + return new ResponseEntity(200, null, data); + } +} + +export default ResponseEntity; diff --git a/backend/src/user/user.controller.ts b/backend/src/user/user.controller.ts index 9317d60..2bbaf6f 100644 --- a/backend/src/user/user.controller.ts +++ b/backend/src/user/user.controller.ts @@ -1,17 +1,24 @@ -import { Controller, Get, Param, UseGuards, Session } from '@nestjs/common'; +import { Controller, Get, UseGuards, Session } from '@nestjs/common'; import { UserServer } from '../user-server/user-server.entity'; import { UserServerService } from '../user-server/user-server.service'; import { LoginGuard } from '../login/login.guard'; import { ExpressSession } from '../types/session'; +import ResponseEntity from 'src/common/response-entity'; @Controller('/api/user') @UseGuards(LoginGuard) export class UserController { constructor(private userServerService: UserServerService) {} - @Get('/:id/servers') - getServersByUserId(@Param('id') userId: number): Promise { - return this.userServerService.getServerListByUserId(userId); + @Get('/servers') + async getServersByUserId( + @Session() + session: ExpressSession, + ): Promise> { + const userId = session.user.id; + const data = await this.userServerService.getServerListByUserId(userId); + + return ResponseEntity.ok(data); } @Get() diff --git a/frontend/src/components/Main/ServerListTab.tsx b/frontend/src/components/Main/ServerListTab.tsx index 20910eb..4b37fb9 100644 --- a/frontend/src/components/Main/ServerListTab.tsx +++ b/frontend/src/components/Main/ServerListTab.tsx @@ -89,10 +89,9 @@ function ServerListTab(): JSX.Element { }; const getServerList = async (): Promise => { - const userIdExam = 1; - const response = await fetch(`/api/user/${userIdExam}/servers`); + const response = await fetch(`/api/user/servers`); const list = await response.json(); - setServerList(list); + setServerList(list.data); }; const listElements = serverList.map((val: ServerData, idx: number): JSX.Element => { From 291bcc4fb40f5af2f6aebe16f1d3991342535c94 Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Thu, 18 Nov 2021 17:35:56 +0900 Subject: [PATCH 007/172] =?UTF-8?q?Feat=20:=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=AA=A8=EB=8B=AC=20layout=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버를 생성하기 위한 모달을 추가했습니다. - 서버 추가, 참여 모달을 띄우기 위한 드롭다운을 추가했습니다. --- frontend/src/components/Main/MainPage.tsx | 7 +- frontend/src/components/Main/MainStore.tsx | 6 + .../Main/ServerModal/CreateServerModal.tsx | 245 ++++++++++++++++++ 3 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/Main/ServerModal/CreateServerModal.tsx diff --git a/frontend/src/components/Main/MainPage.tsx b/frontend/src/components/Main/MainPage.tsx index 96523b4..029890d 100644 --- a/frontend/src/components/Main/MainPage.tsx +++ b/frontend/src/components/Main/MainPage.tsx @@ -6,6 +6,8 @@ import MainSection from './MainSection'; import { MainStoreContext } from './MainStore'; import CreateChannelModal from './Modal/CreateChannelModal'; import JoinChannelModal from './Modal/JoinChannelModal'; +import CreateServerModal from './ServerModal/CreateServerModal'; +import JoinServerModal from './ServerModal/JoinServerModal'; const Container = styled.div` width: 100vw; @@ -18,13 +20,16 @@ const Container = styled.div` `; function MainPage(): JSX.Element { - const { isCreateModalOpen, isJoinModalOpen } = useContext(MainStoreContext); + const { isCreateModalOpen, isJoinModalOpen, isCreateServerModalOpen, isJoinServerModalOpen } = + useContext(MainStoreContext); useEffect(() => {}, []); return ( {isCreateModalOpen && } {isJoinModalOpen && } + {isCreateServerModalOpen && } + {isJoinServerModalOpen && } diff --git a/frontend/src/components/Main/MainStore.tsx b/frontend/src/components/Main/MainStore.tsx index 15b51db..3f36afc 100644 --- a/frontend/src/components/Main/MainStore.tsx +++ b/frontend/src/components/Main/MainStore.tsx @@ -12,6 +12,8 @@ function MainStore(props: MainStoreProps): JSX.Element { const [selectedChannel, setSelectedChannel] = useState('1'); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isJoinModalOpen, setIsJoinModalOpen] = useState(false); + const [isCreateServerModalOpen, setIsCreateServerModalOpen] = useState(false); + const [isJoinServerModalOpen, setIsJoinServerModalOpen] = useState(false); return ( {children} diff --git a/frontend/src/components/Main/ServerModal/CreateServerModal.tsx b/frontend/src/components/Main/ServerModal/CreateServerModal.tsx new file mode 100644 index 0000000..363145f --- /dev/null +++ b/frontend/src/components/Main/ServerModal/CreateServerModal.tsx @@ -0,0 +1,245 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import styled from 'styled-components'; + +import { MainStoreContext } from '../MainStore'; +import { BoostCamMainIcons } from '../../../utils/SvgIcons'; + +const { Close } = BoostCamMainIcons; + +const Container = styled.div` + position: fixed; + width: 100vw; + height: 100vh; + left: 0px; + right: 0px; + + display: flex; + justify-content: space-around; + align-items: center; +`; + +const ModalBackground = styled.div` + position: fixed; + left: 0px; + right: 0px; + width: 100%; + height: 100%; + background-color: rgb(0, 0, 0, 0.5); +`; + +const ModalBox = styled.div` + width: 35%; + min-width: 400px; + height: 50%; + + background-color: #222322; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + border-radius: 20px; + + z-index: 3; +`; + +const ModalInnerBox = styled.div` + width: 100%; + height: 100%; + padding: 30px 10px; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; +`; + +const ModalHeader = styled.div` + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +`; + +const ModalTitle = styled.span` + margin-left: 25px; + padding: 10px 5px; + + color: #cbc4b9; + font-size: 32px; + font-weight: 600; +`; + +const ModalDescription = styled.span` + margin-left: 25px; + padding: 10px 5px; + + color: #cbc4b9; + font-size: 15px; +`; + +const Form = styled.form` + width: 90%; + height: 70%; + border-radius: 20px; + margin: 30px 0px 0px 25px; + + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; +`; + +const InputDiv = styled.div` + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; +`; + +const InputName = styled.span` + color: #cbc4b9; + font-size: 20px; + font-weight: 500; +`; + +const Input = styled.input` + width: 90%; + border: none; + outline: none; + padding: 15px 10px; + margin-top: 10px; + border-radius: 10px; +`; + +const InputErrorMessage = styled.span` + padding: 5px 0px; + color: red; +`; + +const SubmitButton = styled.button<{ isButtonActive: boolean }>` + width: 100px; + height: 50px; + background: none; + + padding: 15px 10px; + + border: 0; + outline: 0; + + text-align: center; + vertical-align: middle; + + border-radius: 10px; + background-color: ${(props) => (props.isButtonActive ? '#26a9ca' : 'gray')}; + cursor: pointer; + transition: all 0.3s; + + &:hover { + background-color: ${(props) => (props.isButtonActive ? '#2dc2e6' : 'gray')}; + transition: all 0.3s; + } +`; + +const ModalCloseButton = styled.div` + width: 32px; + height: 32px; + display: flex; + flex-direction: center; + align-items: center; + + cursor: pointer; + margin-right: 25px; +`; + +const CloseIcon = styled(Close)` + width: 20px; + height: 20px; + fill: #a69c96; +`; + +type CreateModalForm = { + name: string; + description: string; +}; + +function CreateServerModal(): JSX.Element { + const { + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm(); + const { selectedServer, setIsCreateServerModalOpen } = useContext(MainStoreContext); + const [isButtonActive, setIsButtonActive] = useState(false); + + const onSubmitCreateChannelModal = async (data: { name: string; description: string }) => { + const { name, description } = data; + await fetch('api/channel', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: name.trim(), + description: description.trim(), + server: +selectedServer, + }), + }); + }; + + useEffect(() => { + const { name, description } = watch(); + const isActive = name.trim().length > 2 && description.trim().length > 0; + setIsButtonActive(isActive); + }, [watch()]); + + /* eslint-disable react/jsx-props-no-spreading */ + return ( + + setIsCreateServerModalOpen(false)} /> + + + + 서버 생성 + setIsCreateServerModalOpen(false)}> + + + + 생성할 서버의 이름과 설명을 작성해주세요 +
+ + 이름 + value.trim().length > 2 || '"이름" 칸은 3글자 이상 입력되어야합니다!', + })} + placeholder="서버명을 입력해주세요" + /> + {errors.name && {errors.name.message}} + + + 설명 + value.trim().length > 0 || '"설명" 칸은 꼭 입력되어야합니다!', + })} + placeholder="서버 설명을 입력해주세요" + /> + {errors.description && {errors.description.message}} + + + 생성 + +
+
+
+
+ ); +} + +export default CreateServerModal; From b029ef5f42f1b1471dafa23ac177c7a7c7c6a4c6 Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Thu, 18 Nov 2021 17:36:21 +0900 Subject: [PATCH 008/172] =?UTF-8?q?Feat=20:=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=B0=B8=EC=97=AC=20=EB=AA=A8=EB=8B=AC=20layout=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버 참여를 위한 모달을 추가했습니다. --- .../src/components/Main/ServerListTab.tsx | 34 ++++- .../Main/ServerModal/JoinServerModal.tsx | 121 ++++++++++++++++++ 2 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/Main/ServerModal/JoinServerModal.tsx diff --git a/frontend/src/components/Main/ServerListTab.tsx b/frontend/src/components/Main/ServerListTab.tsx index 4b37fb9..045d80d 100644 --- a/frontend/src/components/Main/ServerListTab.tsx +++ b/frontend/src/components/Main/ServerListTab.tsx @@ -5,6 +5,8 @@ import styled from 'styled-components'; import { BoostCamMainIcons } from '../../utils/SvgIcons'; import { ServerData } from '../../types/main'; import { MainStoreContext } from './MainStore'; +import Dropdown from '../core/Dropdown'; +import DropdownMenu from '../core/DropdownMenu'; const { Plus } = BoostCamMainIcons; @@ -79,7 +81,16 @@ const tmpUrl: string[] = [ function ServerListTab(): JSX.Element { const [serverList, setServerList] = useState([]); - const { selectedServer, setSelectedServer } = useContext(MainStoreContext); + const [isDropdownActivated, setIsDropdownActivated] = useState(false); + const { + selectedServer, + setSelectedServer, + isCreateServerModalOpen, + isJoinServerModalOpen, + setIsCreateServerModalOpen, + setIsJoinServerModalOpen, + } = useContext(MainStoreContext); + const initChannel = '1'; const navigate = useNavigate(); @@ -94,6 +105,11 @@ function ServerListTab(): JSX.Element { setServerList(list.data); }; + const onClickServerAddButton = (e: React.MouseEvent) => { + e.stopPropagation(); + setIsDropdownActivated(!isDropdownActivated); + }; + const listElements = serverList.map((val: ServerData, idx: number): JSX.Element => { const selected = selectedServer === val.id; return ( @@ -120,7 +136,21 @@ function ServerListTab(): JSX.Element { {listElements} - + + + + + ); diff --git a/frontend/src/components/Main/ServerModal/JoinServerModal.tsx b/frontend/src/components/Main/ServerModal/JoinServerModal.tsx new file mode 100644 index 0000000..5520852 --- /dev/null +++ b/frontend/src/components/Main/ServerModal/JoinServerModal.tsx @@ -0,0 +1,121 @@ +import React, { useContext } from 'react'; +import styled from 'styled-components'; + +import { MainStoreContext } from '../MainStore'; +import { BoostCamMainIcons } from '../../../utils/SvgIcons'; + +const { Close } = BoostCamMainIcons; + +const Container = styled.div` + position: fixed; + width: 100vw; + height: 100vh; + left: 0px; + right: 0px; + + display: flex; + justify-content: space-around; + align-items: center; +`; + +const ModalBackground = styled.div` + position: fixed; + left: 0px; + right: 0px; + width: 100%; + height: 100%; + background-color: rgb(0, 0, 0, 0.5); +`; + +const ModalBox = styled.div` + width: 35%; + min-width: 400px; + height: 50%; + + background-color: #222322; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + border-radius: 20px; + + z-index: 3; +`; + +const ModalInnerBox = styled.div` + width: 100%; + height: 100%; + padding: 30px 10px; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; +`; + +const ModalHeader = styled.div` + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +`; + +const ModalTitle = styled.span` + margin-left: 25px; + padding: 10px 5px; + + color: #cbc4b9; + font-size: 32px; + font-weight: 600; +`; + +const ModalDescription = styled.span` + margin-left: 25px; + padding: 10px 5px; + + color: #cbc4b9; + font-size: 15px; +`; + +const ModalCloseButton = styled.div` + width: 32px; + height: 32px; + display: flex; + flex-direction: center; + align-items: center; + + cursor: pointer; + margin-right: 25px; +`; + +const CloseIcon = styled(Close)` + width: 20px; + height: 20px; + fill: #a69c96; +`; + +function JoinServerModal(): JSX.Element { + const { setIsJoinServerModalOpen } = useContext(MainStoreContext); + + /* eslint-disable react/jsx-props-no-spreading */ + return ( + + setIsJoinServerModalOpen(false)} /> + + + + 서버 참가 + setIsJoinServerModalOpen(false)}> + + + + 참가 코드를 입력하세요. + + + + ); +} + +export default JoinServerModal; From 5a78637b2e504bd4610fa72af8edf3b9684cef26 Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Thu, 18 Nov 2021 18:09:54 +0900 Subject: [PATCH 009/172] =?UTF-8?q?Test=20:=20user-service.service=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EB=B3=80=EA=B2=BD=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create() 함수의 매개변수 변경에 따라 테스트코드를 수정하였습니다. --- backend/src/user-server/user-server.service.spec.ts | 5 ++++- backend/src/user/user.controller.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/src/user-server/user-server.service.spec.ts b/backend/src/user-server/user-server.service.spec.ts index eeaf21d..97955d3 100644 --- a/backend/src/user-server/user-server.service.spec.ts +++ b/backend/src/user-server/user-server.service.spec.ts @@ -52,7 +52,10 @@ describe('UserServerService', () => { describe('create()', () => { it('정상적인 값을 저장할 경우', async () => { repository.save.mockResolvedValue(userServer); - const newUserServer = await service.create(userServer); + const newUserServer = await service.create( + existUserServer.user, + existUserServer.server, + ); expect(newUserServer.user).toBe(userServer.user); expect(newUserServer.server).toBe(userServer.server); diff --git a/backend/src/user/user.controller.ts b/backend/src/user/user.controller.ts index 2bbaf6f..32728ce 100644 --- a/backend/src/user/user.controller.ts +++ b/backend/src/user/user.controller.ts @@ -3,7 +3,7 @@ import { UserServer } from '../user-server/user-server.entity'; import { UserServerService } from '../user-server/user-server.service'; import { LoginGuard } from '../login/login.guard'; import { ExpressSession } from '../types/session'; -import ResponseEntity from 'src/common/response-entity'; +import ResponseEntity from '../common/response-entity'; @Controller('/api/user') @UseGuards(LoginGuard) From 1e8fbc47030ddd8155441b5043c5b95eda82ccdc Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Thu, 18 Nov 2021 18:25:58 +0900 Subject: [PATCH 010/172] =?UTF-8?q?Feat=20:=20=EC=84=A0=ED=83=9D=EB=90=9C?= =?UTF-8?q?=20=EC=84=9C=EB=B2=84=20=EC=A0=95=EB=B3=B4=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 선택한 서버 정보 객체를 상태관리하도록 수정했습니다. --- frontend/src/components/Main/MainHeader.tsx | 9 ++++--- frontend/src/components/Main/MainStore.tsx | 3 ++- .../src/components/Main/ServerListTab.tsx | 27 ++++++++++++------- frontend/src/types/main.ts | 8 ++++-- 4 files changed, 31 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/Main/MainHeader.tsx b/frontend/src/components/Main/MainHeader.tsx index 2a75beb..1415f21 100644 --- a/frontend/src/components/Main/MainHeader.tsx +++ b/frontend/src/components/Main/MainHeader.tsx @@ -1,5 +1,6 @@ -import React, { useEffect } from 'react'; +import React, { useContext, useEffect } from 'react'; import styled from 'styled-components'; +import { MainStoreContext } from './MainStore'; const Container = styled.div` width: 100%; @@ -21,12 +22,14 @@ const CurrentServerName = styled.span` `; function MainHeader(): JSX.Element { + const { selectedServer } = useContext(MainStoreContext); useEffect(() => {}, []); - return ( - Server Name + + {selectedServer !== undefined ? selectedServer.server.name : '새로운 서버에 참여하세요.'} + ); diff --git a/frontend/src/components/Main/MainStore.tsx b/frontend/src/components/Main/MainStore.tsx index 3f36afc..8c9e9e4 100644 --- a/frontend/src/components/Main/MainStore.tsx +++ b/frontend/src/components/Main/MainStore.tsx @@ -1,4 +1,5 @@ import { createContext, useState } from 'react'; +import { MyServerData } from '../../types/main'; export const MainStoreContext = createContext(null); @@ -8,7 +9,7 @@ type MainStoreProps = { function MainStore(props: MainStoreProps): JSX.Element { const { children } = props; - const [selectedServer, setSelectedServer] = useState('1'); + const [selectedServer, setSelectedServer] = useState(); const [selectedChannel, setSelectedChannel] = useState('1'); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isJoinModalOpen, setIsJoinModalOpen] = useState(false); diff --git a/frontend/src/components/Main/ServerListTab.tsx b/frontend/src/components/Main/ServerListTab.tsx index 045d80d..8c483f0 100644 --- a/frontend/src/components/Main/ServerListTab.tsx +++ b/frontend/src/components/Main/ServerListTab.tsx @@ -3,7 +3,7 @@ import { useNavigate, createSearchParams } from 'react-router-dom'; import styled from 'styled-components'; import { BoostCamMainIcons } from '../../utils/SvgIcons'; -import { ServerData } from '../../types/main'; +import { MyServerData } from '../../types/main'; import { MainStoreContext } from './MainStore'; import Dropdown from '../core/Dropdown'; import DropdownMenu from '../core/DropdownMenu'; @@ -80,7 +80,7 @@ const tmpUrl: string[] = [ ]; function ServerListTab(): JSX.Element { - const [serverList, setServerList] = useState([]); + const [serverList, setServerList] = useState([]); const [isDropdownActivated, setIsDropdownActivated] = useState(false); const { selectedServer, @@ -94,15 +94,13 @@ function ServerListTab(): JSX.Element { const initChannel = '1'; const navigate = useNavigate(); - const onClickServerIcon = (e: React.MouseEvent) => { - const serverId = e.currentTarget.dataset.id; - if (serverId) setSelectedServer(serverId); - }; - const getServerList = async (): Promise => { const response = await fetch(`/api/user/servers`); const list = await response.json(); setServerList(list.data); + if (list.data.length !== 0) { + setSelectedServer(list.data[0]); + } }; const onClickServerAddButton = (e: React.MouseEvent) => { @@ -110,10 +108,19 @@ function ServerListTab(): JSX.Element { setIsDropdownActivated(!isDropdownActivated); }; - const listElements = serverList.map((val: ServerData, idx: number): JSX.Element => { - const selected = selectedServer === val.id; + const listElements = serverList.map((myServerData: MyServerData, idx: number): JSX.Element => { + const selected = selectedServer !== undefined ? selectedServer.id === myServerData.id : false; + const onClickChangeSelectedServer = () => { + setSelectedServer(myServerData); + }; + return ( - + ); diff --git a/frontend/src/types/main.ts b/frontend/src/types/main.ts index 834efac..90f5c28 100644 --- a/frontend/src/types/main.ts +++ b/frontend/src/types/main.ts @@ -9,7 +9,11 @@ type ServerData = { description: string; id: string; name: string; - owner: UserData; +}; + +type MyServerData = { + id: string; + server: ServerData; }; type ChannelData = { @@ -19,4 +23,4 @@ type ChannelData = { server: ServerData; }; -export type { UserData, ServerData, ChannelData }; +export type { UserData, ServerData, MyServerData, ChannelData }; From b071acfc66a8068684be80711eb2afb2aad69c5d Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Thu, 18 Nov 2021 20:58:30 +0900 Subject: [PATCH 011/172] =?UTF-8?q?Fix=20:=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=EC=9D=84=20=EA=B0=80=EC=A0=B8=EC=98=A4?= =?UTF-8?q?=EC=A7=80=20=EB=AA=BB=ED=96=88=EC=9D=84=20=EA=B2=BD=EC=9A=B0=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 목록을 가져오지 못했을 경우에 serverList를 undefined로 set하는 에러를 제거했습니다. - 선택된 서버가 바뀌면 url의 serverId가 변하도록 변경했습니다. --- frontend/src/components/Main/ChannelList.tsx | 3 ++- frontend/src/components/Main/ServerListTab.tsx | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/Main/ChannelList.tsx b/frontend/src/components/Main/ChannelList.tsx index d2f2d68..4c8238d 100644 --- a/frontend/src/components/Main/ChannelList.tsx +++ b/frontend/src/components/Main/ChannelList.tsx @@ -141,9 +141,10 @@ function ChannelList(): JSX.Element { }, []); useEffect(() => { + const serverId = selectedServer !== undefined ? selectedServer.server.id : 'none'; navigate({ search: `?${createSearchParams({ - serverId: selectedServer, + serverId, channelId: selectedChannel, })}`, }); diff --git a/frontend/src/components/Main/ServerListTab.tsx b/frontend/src/components/Main/ServerListTab.tsx index 8c483f0..f723b1c 100644 --- a/frontend/src/components/Main/ServerListTab.tsx +++ b/frontend/src/components/Main/ServerListTab.tsx @@ -97,8 +97,9 @@ function ServerListTab(): JSX.Element { const getServerList = async (): Promise => { const response = await fetch(`/api/user/servers`); const list = await response.json(); - setServerList(list.data); - if (list.data.length !== 0) { + + if (response.status === 200 && list.data.length !== 0) { + setServerList(list.data); setSelectedServer(list.data[0]); } }; @@ -107,7 +108,6 @@ function ServerListTab(): JSX.Element { e.stopPropagation(); setIsDropdownActivated(!isDropdownActivated); }; - const listElements = serverList.map((myServerData: MyServerData, idx: number): JSX.Element => { const selected = selectedServer !== undefined ? selectedServer.id === myServerData.id : false; const onClickChangeSelectedServer = () => { @@ -131,9 +131,10 @@ function ServerListTab(): JSX.Element { }, []); useEffect(() => { + const serverId = selectedServer !== undefined ? selectedServer.server.id : 'none'; navigate({ search: `?${createSearchParams({ - serverId: selectedServer, + serverId, channelId: initChannel, })}`, }); From 333dc345c3c40a0b19f59a2434eb8ca779bf6dd8 Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Thu, 18 Nov 2021 22:20:28 +0900 Subject: [PATCH 012/172] =?UTF-8?q?Feat=20:=20API=20=EB=B0=98=ED=99=98?= =?UTF-8?q?=EA=B0=92=EC=9D=84=20=EC=9C=84=ED=95=9C=20ResponseEntity=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API 에서 결과를 반환할 때 사용될 수 있는 ResponseEntity 클래스를 작성하고 이를 적용하였습니다. --- backend/src/cam/cam.controller.ts | 1 + backend/src/channel/channel.controller.ts | 49 +++++++------------ backend/src/lib/ResponseEntity.ts | 15 ++++++ frontend/src/App.tsx | 4 +- .../components/LoginPage/LoginCallback.tsx | 2 +- 5 files changed, 36 insertions(+), 35 deletions(-) create mode 100644 backend/src/lib/ResponseEntity.ts diff --git a/backend/src/cam/cam.controller.ts b/backend/src/cam/cam.controller.ts index e6b0c19..74d69ea 100644 --- a/backend/src/cam/cam.controller.ts +++ b/backend/src/cam/cam.controller.ts @@ -1,4 +1,5 @@ import { Controller, Get, Post, Param, Body } from '@nestjs/common'; + import { CamService } from './cam.service'; @Controller('api/cam') diff --git a/backend/src/channel/channel.controller.ts b/backend/src/channel/channel.controller.ts index 79156e7..ba3b6e9 100644 --- a/backend/src/channel/channel.controller.ts +++ b/backend/src/channel/channel.controller.ts @@ -13,60 +13,45 @@ import { ExpressSession } from '../types/session'; import { ChannelService } from './channel.service'; import { Channel } from './channel.entity'; import { CreateChannelDto } from './channe.dto'; +import ResponseEntity from 'src/lib/ResponseEntity'; @Controller('api/channel') export class ChannelController { constructor(private channelService: ChannelService) { this.channelService = channelService; } - @Get('list') async findAll(): Promise { + @Get('list') async findAll(): Promise> { const serverList = await this.channelService.findAll(); - return Object.assign({ - data: serverList, - statusCode: 200, - statusMsg: `데이터 조회가 성공적으로 완료되었습니다.`, - }); + return ResponseEntity.ok(serverList); } - @Get(':id') async findOne(@Param('id') id: string): Promise { - const foundServer = await this.channelService.findOne(+id); - return Object.assign({ - data: foundServer, - statusCode: 200, - statusMsg: `데이터 조회가 성공적으로 완료되었습니다.`, - }); + @Get(':id') async findOne( + @Param('id') id: number, + ): Promise> { + const foundServer = await this.channelService.findOne(id); + return ResponseEntity.ok(foundServer); } @Post() async saveChannel( @Body() channel: CreateChannelDto, @Session() session: ExpressSession, - ): Promise { + ): Promise> { const savedChannel = await this.channelService.addChannel( channel, session.user.id, ); - return Object.assign({ - data: { ...savedChannel }, - statusCode: 200, - statusMsg: `saved successfully`, - }); + return ResponseEntity.ok(savedChannel); } @Patch(':id') async updateUser( @Param('id') id: number, @Body() channel: Channel, - ): Promise { + ): Promise> { await this.channelService.updateChannel(id, channel); - return Object.assign({ - data: { ...channel }, - statusCode: 200, - statusMsg: `updated successfully`, - }); + return ResponseEntity.ok(channel); } - @Delete(':id') async deleteChannel(@Param('id') id: string): Promise { - await this.channelService.deleteChannel(+id); - return Object.assign({ - data: { id }, - statusCode: 200, - statusMsg: `deleted successfully`, - }); + @Delete(':id') async deleteChannel( + @Param('id') id: number, + ): Promise> { + await this.channelService.deleteChannel(id); + return ResponseEntity.ok(id); } } diff --git a/backend/src/lib/ResponseEntity.ts b/backend/src/lib/ResponseEntity.ts new file mode 100644 index 0000000..0dabb6e --- /dev/null +++ b/backend/src/lib/ResponseEntity.ts @@ -0,0 +1,15 @@ +class ResponseEntity { + statusCode: number; + message: string; + data: T; + constructor(statusCode: number, data: T) { + this.statusCode = statusCode; + this.data = data; + } + + static ok(data: T): ResponseEntity { + return new ResponseEntity(200, data); + } +} + +export default ResponseEntity; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 155f5e9..f4cfdb0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,12 +14,12 @@ function App(): JSX.Element { - } /> + } /> } /> } /> } /> } /> - } /> + } /> diff --git a/frontend/src/components/LoginPage/LoginCallback.tsx b/frontend/src/components/LoginPage/LoginCallback.tsx index 63a4357..2dcf038 100644 --- a/frontend/src/components/LoginPage/LoginCallback.tsx +++ b/frontend/src/components/LoginPage/LoginCallback.tsx @@ -62,7 +62,7 @@ function LoginCallback(props: LoginCallbackProps): JSX.Element { ); } - return ; + return ; } export default LoginCallback; From 5f365274192990ca3ef90df47606d632f69e3848 Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Thu, 18 Nov 2021 22:28:43 +0900 Subject: [PATCH 013/172] =?UTF-8?q?Feat=20:=20channel.controller=EC=97=90?= =?UTF-8?q?=20LoginGuard=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - channel.controller 는 session 값을 사용하는 API가 있으므로 LoginGuard를 추가하였습니다. - channel의 전체 목록을 가져오는 GET API 의 요청 주소를 변경하였습니다. --- backend/src/channel/channel.controller.ts | 9 ++++++--- frontend/src/components/Main/ChannelList.tsx | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/backend/src/channel/channel.controller.ts b/backend/src/channel/channel.controller.ts index ba3b6e9..7ff6ee0 100644 --- a/backend/src/channel/channel.controller.ts +++ b/backend/src/channel/channel.controller.ts @@ -7,7 +7,9 @@ import { Post, Patch, Session, + UseGuards, } from '@nestjs/common'; +import { LoginGuard } from '../login/login.guard'; import { ExpressSession } from '../types/session'; import { ChannelService } from './channel.service'; @@ -16,13 +18,14 @@ import { CreateChannelDto } from './channe.dto'; import ResponseEntity from 'src/lib/ResponseEntity'; @Controller('api/channel') +@UseGuards(LoginGuard) export class ChannelController { constructor(private channelService: ChannelService) { this.channelService = channelService; } - @Get('list') async findAll(): Promise> { - const serverList = await this.channelService.findAll(); - return ResponseEntity.ok(serverList); + @Get() async findAll(): Promise> { + const channelList = await this.channelService.findAll(); + return ResponseEntity.ok(channelList); } @Get(':id') async findOne( @Param('id') id: number, diff --git a/frontend/src/components/Main/ChannelList.tsx b/frontend/src/components/Main/ChannelList.tsx index d2f2d68..7a29372 100644 --- a/frontend/src/components/Main/ChannelList.tsx +++ b/frontend/src/components/Main/ChannelList.tsx @@ -130,7 +130,7 @@ function ChannelList(): JSX.Element { }; const getChannelList = async (): Promise => { - const response = await fetch('/api/channel/list'); + const response = await fetch('/api/channel'); const list = await response.json(); setChannelList(list.data); From 8f3a38c60fc321ec596a25e7ee2895dd09aad65b Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Thu, 18 Nov 2021 23:02:04 +0900 Subject: [PATCH 014/172] =?UTF-8?q?Feat=20:=20user.repository=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/user/user.repository.ts | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 backend/src/user/user.repository.ts diff --git a/backend/src/user/user.repository.ts b/backend/src/user/user.repository.ts new file mode 100644 index 0000000..29e574f --- /dev/null +++ b/backend/src/user/user.repository.ts @@ -0,0 +1,5 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { User } from './user.entity'; + +@EntityRepository(User) +export class UserRepository extends Repository {} From 0cb5cf3abcd47de0e6a326377efbb9c011efbf48 Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Thu, 18 Nov 2021 23:04:00 +0900 Subject: [PATCH 015/172] =?UTF-8?q?Refactor=20:=20channel.controller=20?= =?UTF-8?q?=EC=99=80=20channel.service=EC=97=90=20=EC=9E=88=EB=8A=94=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - channel.controller 와 channel.service에서 userchannel 관련 코드를 userchannel.service에 분리하였습니다. - channel.controller에 userchannel.service의 addNewChannel 메서드를 추가하였습니다. --- backend/src/channel/channel.controller.ts | 13 +++++++----- backend/src/channel/channel.module.ts | 14 +++++++++---- backend/src/channel/channel.service.ts | 21 ++++--------------- .../user-channel/user-channel.controller.ts | 13 +++++------- .../src/user-channel/user-channel.module.ts | 14 ++++++++++++- .../user-channel/user-channel.repository.ts | 2 +- .../src/user-channel/user-channel.service.ts | 21 +++++++++++++++---- 7 files changed, 58 insertions(+), 40 deletions(-) diff --git a/backend/src/channel/channel.controller.ts b/backend/src/channel/channel.controller.ts index 7ff6ee0..b5f1434 100644 --- a/backend/src/channel/channel.controller.ts +++ b/backend/src/channel/channel.controller.ts @@ -15,13 +15,18 @@ import { ExpressSession } from '../types/session'; import { ChannelService } from './channel.service'; import { Channel } from './channel.entity'; import { CreateChannelDto } from './channe.dto'; +import { UserChannelService } from 'src/user-channel/user-channel.service'; import ResponseEntity from 'src/lib/ResponseEntity'; @Controller('api/channel') @UseGuards(LoginGuard) export class ChannelController { - constructor(private channelService: ChannelService) { + constructor( + private channelService: ChannelService, + private userChannelService: UserChannelService, + ) { this.channelService = channelService; + this.userChannelService = userChannelService; } @Get() async findAll(): Promise> { const channelList = await this.channelService.findAll(); @@ -37,10 +42,8 @@ export class ChannelController { @Body() channel: CreateChannelDto, @Session() session: ExpressSession, ): Promise> { - const savedChannel = await this.channelService.addChannel( - channel, - session.user.id, - ); + const savedChannel = await this.channelService.addChannel(channel); + await this.userChannelService.addNewChannel(savedChannel, session.user.id); return ResponseEntity.ok(savedChannel); } @Patch(':id') async updateUser( diff --git a/backend/src/channel/channel.module.ts b/backend/src/channel/channel.module.ts index dcb91eb..b531d95 100644 --- a/backend/src/channel/channel.module.ts +++ b/backend/src/channel/channel.module.ts @@ -4,15 +4,21 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Channel } from './channel.entity'; import { ChannelController } from './channel.controller'; import { ChannelService } from './channel.service'; -import { UserChannelRepository } from 'src/user-channel/user-channel.repository'; import { Server } from 'src/server/server.entity'; -import { User } from 'src/user/user.entity'; +import { UserChannelService } from 'src/user-channel/user-channel.service'; +import { UserChannelRepository } from 'src/user-channel/user-channel.repository'; +import { UserRepository } from 'src/user/user.repository'; @Module({ imports: [ - TypeOrmModule.forFeature([Channel, Server, User, UserChannelRepository]), + TypeOrmModule.forFeature([ + Channel, + Server, + UserChannelRepository, + UserRepository, + ]), ], - providers: [ChannelService], + providers: [ChannelService, UserChannelService], controllers: [ChannelController], }) export class ChannelModule {} diff --git a/backend/src/channel/channel.service.ts b/backend/src/channel/channel.service.ts index de48f32..e22b22c 100644 --- a/backend/src/channel/channel.service.ts +++ b/backend/src/channel/channel.service.ts @@ -1,24 +1,19 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { UserChannel } from 'src/user-channel/user-channel.entity'; -import { UserChannelRepository } from 'src/user-channel/user-channel.repository'; import { Repository } from 'typeorm/index'; -import { Channel } from './channel.entity'; -import { User } from 'src/user/user.entity'; import { CreateChannelDto } from './channe.dto'; +import { Channel } from './channel.entity'; import { Server } from 'src/server/server.entity'; @Injectable() export class ChannelService { /** * 생성자 */ constructor( @InjectRepository(Channel) private channelRepository: Repository, - @InjectRepository(User) private userRepository: Repository, @InjectRepository(Server) private serverRepository: Repository, - @InjectRepository(UserChannelRepository) - private userChannelRepository: UserChannelRepository, ) { this.channelRepository = channelRepository; + this.serverRepository = serverRepository; } findAll(): Promise { return this.channelRepository.find({ relations: ['server'] }); @@ -26,10 +21,7 @@ export class ChannelService { findOne(id: number): Promise { return this.channelRepository.findOne({ id: id }); } - async addChannel( - channel: CreateChannelDto, - userId: number, - ): Promise { + async addChannel(channel: CreateChannelDto): Promise { const channelEntity = this.channelRepository.create(); const server = await this.serverRepository.findOne({ id: channel.serverId, @@ -44,12 +36,7 @@ export class ChannelService { channelEntity.server = server; const savedChannel = await this.channelRepository.save(channelEntity); - const user = await this.userRepository.findOne({ id: userId }); - const userChannel = this.userChannelRepository.create(); - userChannel.channel = savedChannel; - userChannel.server = server; - userChannel.user = user; - await this.userChannelRepository.save(userChannel); + return savedChannel; } async updateChannel(id: number, channel: Channel): Promise { diff --git a/backend/src/user-channel/user-channel.controller.ts b/backend/src/user-channel/user-channel.controller.ts index 3a3b4cd..e1a6609 100644 --- a/backend/src/user-channel/user-channel.controller.ts +++ b/backend/src/user-channel/user-channel.controller.ts @@ -1,23 +1,20 @@ import { Controller, - Post, - Body, Delete, Param, Session, Get, + UseGuards, } from '@nestjs/common'; +import { LoginGuard } from 'src/login/login.guard'; import { ExpressSession } from '../types/session'; -import { UserChannel } from './user-channel.entity'; import { UserChannelService } from './user-channel.service'; @Controller('/api/user/channels') +@UseGuards(LoginGuard) export class UserChannelController { - constructor(private userChannelService: UserChannelService) {} - - @Post() - create(@Body() userChannel: UserChannel) { - return this.userChannelService.create(userChannel); + constructor(private userChannelService: UserChannelService) { + this.userChannelService = userChannelService; } @Get() diff --git a/backend/src/user-channel/user-channel.module.ts b/backend/src/user-channel/user-channel.module.ts index 87ecb6f..c8e059e 100644 --- a/backend/src/user-channel/user-channel.module.ts +++ b/backend/src/user-channel/user-channel.module.ts @@ -1,12 +1,24 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; + +import { Channel } from 'src/channel/channel.entity'; +import { User } from 'src/user/user.entity'; +import { UserRepository } from 'src/user/user.repository'; import { UserChannelController } from './user-channel.controller'; import { UserChannel } from './user-channel.entity'; import { UserChannelRepository } from './user-channel.repository'; import { UserChannelService } from './user-channel.service'; @Module({ - imports: [TypeOrmModule.forFeature([UserChannel, UserChannelRepository])], + imports: [ + TypeOrmModule.forFeature([ + UserChannel, + User, + Channel, + UserChannelRepository, + UserRepository, + ]), + ], providers: [UserChannelService], controllers: [UserChannelController], exports: [UserChannelService, TypeOrmModule], diff --git a/backend/src/user-channel/user-channel.repository.ts b/backend/src/user-channel/user-channel.repository.ts index d75e4f1..48c3c74 100644 --- a/backend/src/user-channel/user-channel.repository.ts +++ b/backend/src/user-channel/user-channel.repository.ts @@ -1,4 +1,4 @@ -import { EntityRepository, Repository, getConnection } from 'typeorm'; +import { EntityRepository, Repository } from 'typeorm'; import { UserChannel } from './user-channel.entity'; @EntityRepository(UserChannel) diff --git a/backend/src/user-channel/user-channel.service.ts b/backend/src/user-channel/user-channel.service.ts index 8451559..35f4cc9 100644 --- a/backend/src/user-channel/user-channel.service.ts +++ b/backend/src/user-channel/user-channel.service.ts @@ -1,18 +1,31 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { DeleteQueryBuilder, DeleteResult } from 'typeorm'; + import { UserChannelRepository } from './user-channel.repository'; import { UserChannel } from './user-channel.entity'; -import { DeleteQueryBuilder, DeleteResult } from 'typeorm'; +import { User } from 'src/user/user.entity'; +import { Channel } from 'src/channel/channel.entity'; +import { UserRepository } from 'src/user/user.repository'; @Injectable() export class UserChannelService { constructor( @InjectRepository(UserChannelRepository) private userChannelRepository: UserChannelRepository, - ) {} + @InjectRepository(UserRepository) private userRepository: UserRepository, + ) { + this.userChannelRepository = userChannelRepository; + this.userRepository = userRepository; + } - create(userChannel: UserChannel): Promise { - return this.userChannelRepository.save(userChannel); + async addNewChannel(channel: Channel, userId: number): Promise { + const user = await this.userRepository.findOne({ id: userId }); + const userChannel = this.userChannelRepository.create(); + userChannel.channel = channel; + userChannel.server = channel.server; + userChannel.user = user; + return await this.userChannelRepository.save(userChannel); } deleteById(id: number): Promise { From 5a79fffe332b857ebe1d4a887489033813ad6cb7 Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Fri, 19 Nov 2021 00:08:01 +0900 Subject: [PATCH 016/172] =?UTF-8?q?Feat=20:=20user-channel.controller?= =?UTF-8?q?=EC=97=90=20getJoinedChannelList=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 현재 사용자가 참가한 channel들만 가져오는 getJoinedChannelList 메서드를 '/api/user/channels/joined/:id'에 설정하였습니다. --- backend/src/channel/channel.controller.ts | 1 + backend/src/channel/channel.service.ts | 2 +- .../user-channel/user-channel.controller.ts | 21 +++++++++++++++++-- .../user-channel/user-channel.repository.ts | 1 + .../src/user-channel/user-channel.service.ts | 5 +++-- frontend/src/components/Main/ChannelList.tsx | 2 +- .../Main/Modal/JoinChannelModal.tsx | 14 +++++++++++-- 7 files changed, 38 insertions(+), 8 deletions(-) diff --git a/backend/src/channel/channel.controller.ts b/backend/src/channel/channel.controller.ts index b5f1434..3b1b0a4 100644 --- a/backend/src/channel/channel.controller.ts +++ b/backend/src/channel/channel.controller.ts @@ -30,6 +30,7 @@ export class ChannelController { } @Get() async findAll(): Promise> { const channelList = await this.channelService.findAll(); + console.log(channelList); return ResponseEntity.ok(channelList); } @Get(':id') async findOne( diff --git a/backend/src/channel/channel.service.ts b/backend/src/channel/channel.service.ts index e22b22c..27ed6e4 100644 --- a/backend/src/channel/channel.service.ts +++ b/backend/src/channel/channel.service.ts @@ -16,7 +16,7 @@ export class ChannelService { this.serverRepository = serverRepository; } findAll(): Promise { - return this.channelRepository.find({ relations: ['server'] }); + return this.channelRepository.find(); } findOne(id: number): Promise { return this.channelRepository.findOne({ id: id }); diff --git a/backend/src/user-channel/user-channel.controller.ts b/backend/src/user-channel/user-channel.controller.ts index e1a6609..21f0fe0 100644 --- a/backend/src/user-channel/user-channel.controller.ts +++ b/backend/src/user-channel/user-channel.controller.ts @@ -6,6 +6,8 @@ import { Get, UseGuards, } from '@nestjs/common'; +import { Channel } from 'src/channel/channel.entity'; +import ResponseEntity from 'src/lib/ResponseEntity'; import { LoginGuard } from 'src/login/login.guard'; import { ExpressSession } from '../types/session'; import { UserChannelService } from './user-channel.service'; @@ -17,8 +19,23 @@ export class UserChannelController { this.userChannelService = userChannelService; } - @Get() - getJoinedChannelList( + @Get('/joined/:id') + async getJoinedChannelList( + @Param('id') serverId: number, + @Session() session: ExpressSession, + ) { + const response = await this.userChannelService.getJoinedChannelListByUserId( + serverId, + session.user.id, + ); + const joinedChannelList = response.map( + (userChannel) => userChannel.channel, + ); + return ResponseEntity.ok(joinedChannelList); + } + + @Get('/notjoined') + getNotJoinedChannelList( @Param('serverId') serverid: number, @Session() session: ExpressSession, ) { diff --git a/backend/src/user-channel/user-channel.repository.ts b/backend/src/user-channel/user-channel.repository.ts index 48c3c74..8c3c8de 100644 --- a/backend/src/user-channel/user-channel.repository.ts +++ b/backend/src/user-channel/user-channel.repository.ts @@ -5,6 +5,7 @@ import { UserChannel } from './user-channel.entity'; export class UserChannelRepository extends Repository { getJoinedChannelListByUserId(userId: number, serverId: number) { return this.createQueryBuilder('user_channel') + .leftJoinAndSelect('user_channel.channel', 'channel') .where('user_channel.user = :userId', { userId: userId }) .andWhere('user_channel.server = :serverId', { serverId: serverId }) .getMany(); diff --git a/backend/src/user-channel/user-channel.service.ts b/backend/src/user-channel/user-channel.service.ts index 35f4cc9..f285e6f 100644 --- a/backend/src/user-channel/user-channel.service.ts +++ b/backend/src/user-channel/user-channel.service.ts @@ -43,9 +43,10 @@ export class UserChannelService { } getJoinedChannelListByUserId( - userId: number, serverId: number, + userId: number, ): Promise { + console.log(serverId, userId); return this.userChannelRepository.getJoinedChannelListByUserId( userId, serverId, @@ -53,8 +54,8 @@ export class UserChannelService { } getNotJoinedChannelListByUserId( - userId: number, serverId: number, + userId: number, ): Promise { return this.userChannelRepository.getNotJoinedChannelListByUserId( userId, diff --git a/frontend/src/components/Main/ChannelList.tsx b/frontend/src/components/Main/ChannelList.tsx index 7a29372..a510cb2 100644 --- a/frontend/src/components/Main/ChannelList.tsx +++ b/frontend/src/components/Main/ChannelList.tsx @@ -130,7 +130,7 @@ function ChannelList(): JSX.Element { }; const getChannelList = async (): Promise => { - const response = await fetch('/api/channel'); + const response = await fetch(`/api/user/channels/joined/${selectedServer}`); const list = await response.json(); setChannelList(list.data); diff --git a/frontend/src/components/Main/Modal/JoinChannelModal.tsx b/frontend/src/components/Main/Modal/JoinChannelModal.tsx index 6ae0ed8..2cd9a61 100644 --- a/frontend/src/components/Main/Modal/JoinChannelModal.tsx +++ b/frontend/src/components/Main/Modal/JoinChannelModal.tsx @@ -1,4 +1,4 @@ -import React, { useContext } from 'react'; +import React, { useContext, useEffect } from 'react'; import styled from 'styled-components'; import { MainStoreContext } from '../MainStore'; @@ -97,7 +97,17 @@ const CloseIcon = styled(Close)` `; function JoinChannelModal(): JSX.Element { - const { setIsJoinModalOpen } = useContext(MainStoreContext); + const { selectedServer, setIsJoinModalOpen } = useContext(MainStoreContext); + + const getNotJoinedChannel = async () => { + const response = await fetch(`/api/user/channels/joined/${selectedServer}`); + const list = await response.json(); + console.log(list.data); + }; + + useEffect(() => { + getNotJoinedChannel(); + }, []); /* eslint-disable react/jsx-props-no-spreading */ return ( From 081d3f7db4a2e9d04573c8c9c92f4ba7a82f91c8 Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Sat, 20 Nov 2021 00:45:41 +0900 Subject: [PATCH 017/172] =?UTF-8?q?Refactor=20:=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=B6=94=EA=B0=80=EC=8B=9C=20server=20id?= =?UTF-8?q?=EB=A5=BC=20=EB=B0=9B=EC=95=84=EC=84=9C=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=ED=9B=84=20=EB=A0=88=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=82=BD=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버목록을 추가할 때 기존 server객체를 따로 생성하여서 넣던 방식에서 조회한 server를 삽입하도록 변경했습니다. - created 상태의 responseEntity생성 함수를 추가했습니다. --- backend/src/common/response-entity.ts | 4 ++++ backend/src/user-server/user-server.controller.ts | 6 ++++-- backend/src/user-server/user-server.module.ts | 6 +++++- backend/src/user-server/user-server.service.ts | 6 ++++-- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/backend/src/common/response-entity.ts b/backend/src/common/response-entity.ts index ed0aa24..b1c711a 100644 --- a/backend/src/common/response-entity.ts +++ b/backend/src/common/response-entity.ts @@ -11,6 +11,10 @@ class ResponseEntity { static ok(data: T): ResponseEntity { return new ResponseEntity(200, null, data); } + + static created(id: number): ResponseEntity { + return new ResponseEntity(201, null, id); + } } export default ResponseEntity; diff --git a/backend/src/user-server/user-server.controller.ts b/backend/src/user-server/user-server.controller.ts index e964310..6c81110 100644 --- a/backend/src/user-server/user-server.controller.ts +++ b/backend/src/user-server/user-server.controller.ts @@ -11,6 +11,7 @@ import { Server } from '../server/server.entity'; import { LoginGuard } from '../login/login.guard'; import { ExpressSession } from '../types/session'; import { UserServerService } from './user-server.service'; +import ResponseEntity from 'src/common/response-entity'; @Controller('/api/users/servers') @UseGuards(LoginGuard) @@ -18,13 +19,14 @@ export class UserServerController { constructor(private userServerService: UserServerService) {} @Post() - create( + async create( @Session() session: ExpressSession, @Body() server: Server, ) { const user = session.user; - return this.userServerService.create(user, server); + const newUserServer = await this.userServerService.create(user, server.id); + return ResponseEntity.created(newUserServer.id); } @Delete('/:id') diff --git a/backend/src/user-server/user-server.module.ts b/backend/src/user-server/user-server.module.ts index b5cf37d..c7df8dd 100644 --- a/backend/src/user-server/user-server.module.ts +++ b/backend/src/user-server/user-server.module.ts @@ -4,9 +4,13 @@ import { UserServerController } from './user-server.controller'; import { UserServerRepository } from './user-server.repository'; import { UserServerService } from './user-server.service'; import { UserServer } from './user-server.entity'; +import { ServerModule } from '../server/server.module'; @Module({ - imports: [TypeOrmModule.forFeature([UserServer, UserServerRepository])], + imports: [ + ServerModule, + TypeOrmModule.forFeature([UserServer, UserServerRepository]), + ], providers: [UserServerService], controllers: [UserServerController], exports: [UserServerService, TypeOrmModule], diff --git a/backend/src/user-server/user-server.service.ts b/backend/src/user-server/user-server.service.ts index 78b37de..5bde2fc 100644 --- a/backend/src/user-server/user-server.service.ts +++ b/backend/src/user-server/user-server.service.ts @@ -5,18 +5,20 @@ import { UserServer } from './user-server.entity'; import { DeleteQueryBuilder, DeleteResult } from 'typeorm'; import { User } from '../user/user.entity'; import { Server } from '../server/server.entity'; +import { ServerService } from 'src/server/server.service'; @Injectable() export class UserServerService { constructor( + private serverService: ServerService, @InjectRepository(UserServerRepository) private userServerRepository: UserServerRepository, ) {} - async create(user: User, server: Server): Promise { + async create(user: User, serverId: number): Promise { const newUserServer = new UserServer(); newUserServer.user = user; - newUserServer.server = server; + newUserServer.server = await this.serverService.findOne(serverId); return this.userServerRepository.save(newUserServer); } From 3b0dbac0cdd8bc5bf11d6c7954c14ebf79a773bd Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Sat, 20 Nov 2021 01:30:35 +0900 Subject: [PATCH 018/172] =?UTF-8?q?Feat=20:=20frontend=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=99=80=20=EC=83=88=20Server?= =?UTF-8?q?=EC=B0=B8=EC=97=AC=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - serverId를 입력하여 새 server에 참여하는 api를 연결하였습니다. - 이미 참여한 서버일 경우 error를 던지도록 처리했습니다. - 서버 참여 후 서버 목록을 다시 렌더링할 수 있도록 serverList state를 context로 옮겼습니다. - 서버 참여 후 서버 목록을 새로 호출합니다. --- .../src/user-server/user-server.repository.ts | 7 + .../src/user-server/user-server.service.ts | 11 +- frontend/src/components/Main/MainStore.tsx | 3 + .../src/components/Main/ServerListTab.tsx | 3 +- .../Main/ServerModal/JoinServerModal.tsx | 127 +++++++++++++++++- 5 files changed, 147 insertions(+), 4 deletions(-) diff --git a/backend/src/user-server/user-server.repository.ts b/backend/src/user-server/user-server.repository.ts index 124e5ce..7317340 100644 --- a/backend/src/user-server/user-server.repository.ts +++ b/backend/src/user-server/user-server.repository.ts @@ -16,4 +16,11 @@ export class UserServerRepository extends Repository { .andWhere('user_server.server = :serverId', { serverId: serverId }) .delete(); } + + findByUserIdAndServerId(userId: number, serverId: number) { + return this.createQueryBuilder('user_server') + .where('user_server.user = :userId', { userId: userId }) + .andWhere('user_server.server = :serverId', { serverId: serverId }) + .getOne(); + } } diff --git a/backend/src/user-server/user-server.service.ts b/backend/src/user-server/user-server.service.ts index 5bde2fc..82e5cf0 100644 --- a/backend/src/user-server/user-server.service.ts +++ b/backend/src/user-server/user-server.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { HttpException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { UserServerRepository } from './user-server.repository'; import { UserServer } from './user-server.entity'; @@ -19,6 +19,15 @@ export class UserServerService { const newUserServer = new UserServer(); newUserServer.user = user; newUserServer.server = await this.serverService.findOne(serverId); + + const userServer = await this.userServerRepository.findByUserIdAndServerId( + user.id, + serverId, + ); + if (userServer !== undefined) { + throw new Error(); + } + return this.userServerRepository.save(newUserServer); } diff --git a/frontend/src/components/Main/MainStore.tsx b/frontend/src/components/Main/MainStore.tsx index 8c9e9e4..67cb511 100644 --- a/frontend/src/components/Main/MainStore.tsx +++ b/frontend/src/components/Main/MainStore.tsx @@ -15,6 +15,7 @@ function MainStore(props: MainStoreProps): JSX.Element { const [isJoinModalOpen, setIsJoinModalOpen] = useState(false); const [isCreateServerModalOpen, setIsCreateServerModalOpen] = useState(false); const [isJoinServerModalOpen, setIsJoinServerModalOpen] = useState(false); + const [serverList, setServerList] = useState([]); return ( {children} diff --git a/frontend/src/components/Main/ServerListTab.tsx b/frontend/src/components/Main/ServerListTab.tsx index f723b1c..c93db07 100644 --- a/frontend/src/components/Main/ServerListTab.tsx +++ b/frontend/src/components/Main/ServerListTab.tsx @@ -80,7 +80,6 @@ const tmpUrl: string[] = [ ]; function ServerListTab(): JSX.Element { - const [serverList, setServerList] = useState([]); const [isDropdownActivated, setIsDropdownActivated] = useState(false); const { selectedServer, @@ -89,6 +88,8 @@ function ServerListTab(): JSX.Element { isJoinServerModalOpen, setIsCreateServerModalOpen, setIsJoinServerModalOpen, + serverList, + setServerList, } = useContext(MainStoreContext); const initChannel = '1'; diff --git a/frontend/src/components/Main/ServerModal/JoinServerModal.tsx b/frontend/src/components/Main/ServerModal/JoinServerModal.tsx index 5520852..ff957f3 100644 --- a/frontend/src/components/Main/ServerModal/JoinServerModal.tsx +++ b/frontend/src/components/Main/ServerModal/JoinServerModal.tsx @@ -1,4 +1,5 @@ -import React, { useContext } from 'react'; +import React, { useContext, useState, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; import styled from 'styled-components'; import { MainStoreContext } from '../MainStore'; @@ -79,6 +80,71 @@ const ModalDescription = styled.span` font-size: 15px; `; +const Form = styled.form` + width: 90%; + height: 70%; + border-radius: 20px; + margin: 30px 0px 0px 25px; + + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; +`; + +const InputDiv = styled.div` + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; +`; + +const InputName = styled.span` + color: #cbc4b9; + font-size: 20px; + font-weight: 500; +`; + +const Input = styled.input` + width: 90%; + border: none; + outline: none; + padding: 15px 10px; + margin-top: 10px; + border-radius: 10px; +`; + +const InputErrorMessage = styled.span` + padding: 5px 0px; + color: red; +`; + +const SubmitButton = styled.button<{ isButtonActive: boolean }>` + width: 100px; + height: 50px; + background: none; + + padding: 15px 10px; + + border: 0; + outline: 0; + + text-align: center; + vertical-align: middle; + + border-radius: 10px; + background-color: ${(props) => (props.isButtonActive ? '#26a9ca' : 'gray')}; + cursor: pointer; + transition: all 0.3s; + + &:hover { + background-color: ${(props) => (props.isButtonActive ? '#2dc2e6' : 'gray')}; + transition: all 0.3s; + } +`; + const ModalCloseButton = styled.div` width: 32px; height: 32px; @@ -96,8 +162,50 @@ const CloseIcon = styled(Close)` fill: #a69c96; `; +type JoinServerModalForm = { + serverId: string; +}; + function JoinServerModal(): JSX.Element { - const { setIsJoinServerModalOpen } = useContext(MainStoreContext); + const { + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm(); + const { setIsJoinServerModalOpen, setServerList, setSelectedServer } = useContext(MainStoreContext); + const [isButtonActive, setIsButtonActive] = useState(false); + + const getServerList = async (): Promise => { + const response = await fetch(`/api/user/servers`); + const list = await response.json(); + + if (response.status === 200 && list.data.length !== 0) { + setServerList(list.data); + setSelectedServer(list.data[list.data.length - 1]); + } + }; + + const onSubmitJoinServerModal = async (data: { serverId: string }) => { + const { serverId } = data; + await fetch('api/users/servers', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + id: serverId.trim(), + }), + }); + getServerList(); + setIsJoinServerModalOpen(false); + }; + + useEffect(() => { + const { serverId } = watch(); + const isActive = serverId.trim().length > 0; + setIsButtonActive(isActive); + }, [watch()]); /* eslint-disable react/jsx-props-no-spreading */ return ( @@ -112,6 +220,21 @@ function JoinServerModal(): JSX.Element { 참가 코드를 입력하세요. +
+ + 참가 코드 + value.trim().length > 0 || '"참가코드" 칸을 입력해주세요!', + })} + placeholder="참가코드를 입력해주세요" + /> + {errors.serverId && {errors.serverId.message}} + + + 생성 + +
From 1f10c04909ab415bd79e040cfcc51d4b236a204a Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Sat, 20 Nov 2021 02:19:40 +0900 Subject: [PATCH 019/172] =?UTF-8?q?Feat=20:=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=ED=98=B8=EC=B6=9C=20api=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 에러 상황에 맞는 에러메시지를 반환하고 client에 표시되도록 구현했습니다. --- .../src/user-server/user-server.controller.ts | 14 ++++++++++--- .../src/user-server/user-server.service.ts | 6 ++++-- .../Main/ServerModal/JoinServerModal.tsx | 20 ++++++++++++++++--- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/backend/src/user-server/user-server.controller.ts b/backend/src/user-server/user-server.controller.ts index 6c81110..972a234 100644 --- a/backend/src/user-server/user-server.controller.ts +++ b/backend/src/user-server/user-server.controller.ts @@ -6,6 +6,7 @@ import { Param, Session, UseGuards, + HttpException, } from '@nestjs/common'; import { Server } from '../server/server.entity'; import { LoginGuard } from '../login/login.guard'; @@ -24,9 +25,16 @@ export class UserServerController { session: ExpressSession, @Body() server: Server, ) { - const user = session.user; - const newUserServer = await this.userServerService.create(user, server.id); - return ResponseEntity.created(newUserServer.id); + try { + const user = session.user; + const newUserServer = await this.userServerService.create( + user, + server.id, + ); + return ResponseEntity.created(newUserServer.id); + } catch (error) { + throw new HttpException(error.response, 403); + } } @Delete('/:id') diff --git a/backend/src/user-server/user-server.service.ts b/backend/src/user-server/user-server.service.ts index 82e5cf0..4fca40a 100644 --- a/backend/src/user-server/user-server.service.ts +++ b/backend/src/user-server/user-server.service.ts @@ -4,7 +4,6 @@ import { UserServerRepository } from './user-server.repository'; import { UserServer } from './user-server.entity'; import { DeleteQueryBuilder, DeleteResult } from 'typeorm'; import { User } from '../user/user.entity'; -import { Server } from '../server/server.entity'; import { ServerService } from 'src/server/server.service'; @Injectable() @@ -20,12 +19,15 @@ export class UserServerService { newUserServer.user = user; newUserServer.server = await this.serverService.findOne(serverId); + if (newUserServer.server == undefined) { + throw new HttpException('해당 서버가 존재하지 않습니다.', 403); + } const userServer = await this.userServerRepository.findByUserIdAndServerId( user.id, serverId, ); if (userServer !== undefined) { - throw new Error(); + throw new HttpException('이미 등록된 서버입니다.', 403); } return this.userServerRepository.save(newUserServer); diff --git a/frontend/src/components/Main/ServerModal/JoinServerModal.tsx b/frontend/src/components/Main/ServerModal/JoinServerModal.tsx index ff957f3..88384e1 100644 --- a/frontend/src/components/Main/ServerModal/JoinServerModal.tsx +++ b/frontend/src/components/Main/ServerModal/JoinServerModal.tsx @@ -145,6 +145,12 @@ const SubmitButton = styled.button<{ isButtonActive: boolean }>` } `; +const MessageFailToPost = styled.span` + color: red; + font-size: 16px; + font-family: Malgun Gothic; +`; + const ModalCloseButton = styled.div` width: 32px; height: 32px; @@ -175,6 +181,7 @@ function JoinServerModal(): JSX.Element { } = useForm(); const { setIsJoinServerModalOpen, setServerList, setSelectedServer } = useContext(MainStoreContext); const [isButtonActive, setIsButtonActive] = useState(false); + const [messageFailToPost, setMessageFailToPost] = useState(''); const getServerList = async (): Promise => { const response = await fetch(`/api/user/servers`); @@ -188,7 +195,7 @@ function JoinServerModal(): JSX.Element { const onSubmitJoinServerModal = async (data: { serverId: string }) => { const { serverId } = data; - await fetch('api/users/servers', { + const response = await fetch('api/users/servers', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -197,8 +204,14 @@ function JoinServerModal(): JSX.Element { id: serverId.trim(), }), }); - getServerList(); - setIsJoinServerModalOpen(false); + + if (response.status === 201) { + getServerList(); + setIsJoinServerModalOpen(false); + } else { + const body = await response.json(); + setMessageFailToPost(body.message); + } }; useEffect(() => { @@ -231,6 +244,7 @@ function JoinServerModal(): JSX.Element { /> {errors.serverId && {errors.serverId.message}} + {messageFailToPost} 생성 From 6eeb475f6b248b1f5726b63e7a6688d12e688de0 Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Sat, 20 Nov 2021 03:08:51 +0900 Subject: [PATCH 020/172] =?UTF-8?q?Test=20:=20user-server=20service=20test?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server service의 사용이 추가되어 의존성을 추가하였습니다. - getServerListByUserId()의 테스트코드를 추가하였습니다. - create()의 상황별 에러처리에 맞는 테스트 코드를 추가하였습니다. --- .../user-server/user-server.service.spec.ts | 80 ++++++++++++++++--- .../src/user-server/user-server.service.ts | 2 +- backend/src/user/user.controller.spec.ts | 12 ++- 3 files changed, 82 insertions(+), 12 deletions(-) diff --git a/backend/src/user-server/user-server.service.spec.ts b/backend/src/user-server/user-server.service.spec.ts index 97955d3..af08479 100644 --- a/backend/src/user-server/user-server.service.spec.ts +++ b/backend/src/user-server/user-server.service.spec.ts @@ -6,18 +6,29 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { UserServerRepository } from './user-server.repository'; import { DeleteResult, Repository } from 'typeorm'; +import { ServerService } from '../server/server.service'; -const mockRepository = () => ({ +const mockUserServerRepository = () => ({ save: jest.fn(), delete: jest.fn(), + findByUserIdAndServerId: jest.fn(), deleteByUserIdAndServerId: jest.fn(), + getServerListByUserId: jest.fn(), }); -type MockRepository = Partial, jest.Mock>>; +const mockServerRepository = () => ({ + findOne: jest.fn(), +}); + +type MockUserServerRepository = Partial< + Record +>; +type MockRepository = Partial, jest.Mock>>; describe('UserServerService', () => { let service: UserServerService; - let repository: MockRepository; + let userServerRepository: MockUserServerRepository; + let serverRepository: MockRepository; let userServer: UserServer; let existUserServer: UserServer; @@ -27,15 +38,23 @@ describe('UserServerService', () => { UserServerService, { provide: getRepositoryToken(UserServerRepository), - useValue: mockRepository(), + useValue: mockUserServerRepository(), + }, + ServerService, + { + provide: getRepositoryToken(Server), + useValue: mockServerRepository(), }, ], }).compile(); service = module.get(UserServerService); - repository = module.get>( + userServerRepository = module.get( getRepositoryToken(UserServerRepository), ); + serverRepository = module.get>( + getRepositoryToken(Server), + ); userServer = new UserServer(); userServer.user = new User(); @@ -51,15 +70,43 @@ describe('UserServerService', () => { describe('create()', () => { it('정상적인 값을 저장할 경우', async () => { - repository.save.mockResolvedValue(userServer); + userServerRepository.save.mockResolvedValue(userServer); + serverRepository.findOne.mockResolvedValue(existUserServer.server); + userServerRepository.findByUserIdAndServerId.mockResolvedValue(undefined); + const newUserServer = await service.create( existUserServer.user, - existUserServer.server, + existUserServer.server.id, ); expect(newUserServer.user).toBe(userServer.user); expect(newUserServer.server).toBe(userServer.server); }); + + it('해당 서버가 존재하지 않는 경우', async () => { + userServerRepository.save.mockResolvedValue(userServer); + serverRepository.findOne.mockResolvedValue(undefined); + + try { + await service.create(existUserServer.user, 2); + } catch (error) { + expect(error.response).toBe('해당 서버가 존재하지 않습니다.'); + } + }); + + it('이미 추가된 서버인 경우', async () => { + userServerRepository.save.mockResolvedValue(userServer); + serverRepository.findOne.mockResolvedValue(existUserServer.server); + userServerRepository.findByUserIdAndServerId.mockResolvedValue( + existUserServer, + ); + + try { + await service.create(existUserServer.user, existUserServer.server.id); + } catch (error) { + expect(error.response).toBe('이미 등록된 서버입니다.'); + } + }); }); describe('deleteById()', () => { @@ -67,7 +114,7 @@ describe('UserServerService', () => { const existsId = existUserServer.id; const returnedDeleteResult = new DeleteResult(); returnedDeleteResult.affected = existsId == existUserServer.id ? 1 : 0; - repository.delete.mockResolvedValue(returnedDeleteResult); + userServerRepository.delete.mockResolvedValue(returnedDeleteResult); const deleteResult: DeleteResult = await service.deleteById(existsId); @@ -78,11 +125,26 @@ describe('UserServerService', () => { const nonExistsId = 0; const returnedDeleteResult = new DeleteResult(); returnedDeleteResult.affected = nonExistsId == existUserServer.id ? 1 : 0; - repository.delete.mockResolvedValue(returnedDeleteResult); + userServerRepository.delete.mockResolvedValue(returnedDeleteResult); const deleteResult: DeleteResult = await service.deleteById(nonExistsId); expect(deleteResult.affected).toBe(0); }); }); + + describe('getServerListByUserId()', () => { + it('list를 가져올 경우', async () => { + userServerRepository.getServerListByUserId.mockResolvedValue([ + existUserServer, + existUserServer, + ]); + + const userServerList = await service.getServerListByUserId( + existUserServer.user.id, + ); + + expect(userServerList[0]).toBe(existUserServer); + }); + }); }); diff --git a/backend/src/user-server/user-server.service.ts b/backend/src/user-server/user-server.service.ts index 4fca40a..7140e33 100644 --- a/backend/src/user-server/user-server.service.ts +++ b/backend/src/user-server/user-server.service.ts @@ -4,7 +4,7 @@ import { UserServerRepository } from './user-server.repository'; import { UserServer } from './user-server.entity'; import { DeleteQueryBuilder, DeleteResult } from 'typeorm'; import { User } from '../user/user.entity'; -import { ServerService } from 'src/server/server.service'; +import { ServerService } from '../server/server.service'; @Injectable() export class UserServerService { diff --git a/backend/src/user/user.controller.spec.ts b/backend/src/user/user.controller.spec.ts index 592bed1..38643f7 100644 --- a/backend/src/user/user.controller.spec.ts +++ b/backend/src/user/user.controller.spec.ts @@ -1,14 +1,17 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; +import { Server } from '../server/server.entity'; +import { ServerService } from '../server/server.service'; import { UserServerRepository } from '../user-server/user-server.repository'; import { UserServerService } from '../user-server/user-server.service'; import { UserController } from './user.controller'; -const mockRepository = () => ({ +const mockUserServerRepository = () => ({ save: jest.fn(), delete: jest.fn(), deleteByUserIdAndServerId: jest.fn(), }); +const mockServerRepository = () => ({}); describe('UserController', () => { let controller: UserController; @@ -19,7 +22,12 @@ describe('UserController', () => { UserServerService, { provide: getRepositoryToken(UserServerRepository), - useValue: mockRepository(), + useValue: mockUserServerRepository(), + }, + ServerService, + { + provide: getRepositoryToken(Server), + useValue: mockServerRepository(), }, ], controllers: [UserController], From dc739483478d8b9797b63d96ec0206911bb9a8ec Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Sat, 20 Nov 2021 15:31:37 +0900 Subject: [PATCH 021/172] =?UTF-8?q?Feat=20:=20CreateChannelModal=20?= =?UTF-8?q?=EC=97=90=20=EC=B5=9C=EC=86=8C=20height=20=EC=A7=80=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Main/Modal/CreateChannelModal.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/components/Main/Modal/CreateChannelModal.tsx b/frontend/src/components/Main/Modal/CreateChannelModal.tsx index ccc14be..0c1e590 100644 --- a/frontend/src/components/Main/Modal/CreateChannelModal.tsx +++ b/frontend/src/components/Main/Modal/CreateChannelModal.tsx @@ -32,6 +32,7 @@ const ModalBox = styled.div` width: 35%; min-width: 400px; height: 50%; + min-height: 450px; background-color: #222322; From b2eefbe2fe58c78e71c119b362f90d633eec51e0 Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Sat, 20 Nov 2021 15:54:04 +0900 Subject: [PATCH 022/172] =?UTF-8?q?Feat=20:=20Server=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20api=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ServerRequestDto를 전달받아 Server 레코드를 생성하도록 기능을 구현하였습니다. --- backend/src/server/dto/RequestServerDto.ts | 21 ++++++++++++++ backend/src/server/server.controller.ts | 33 +++++++++++++++++----- backend/src/server/server.service.ts | 17 +++++++++-- 3 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 backend/src/server/dto/RequestServerDto.ts diff --git a/backend/src/server/dto/RequestServerDto.ts b/backend/src/server/dto/RequestServerDto.ts new file mode 100644 index 0000000..8f975ee --- /dev/null +++ b/backend/src/server/dto/RequestServerDto.ts @@ -0,0 +1,21 @@ +import { Server } from '../server.entity'; + +class RequestServerDto { + name: string; + description: string; + + constructor(name: string, description: string) { + this.name = name; + this.description = description; + } + + toServerEntity = () => { + const newServer = new Server(); + newServer.name = this.name; + newServer.description = this.description; + + return newServer; + }; +} + +export default RequestServerDto; diff --git a/backend/src/server/server.controller.ts b/backend/src/server/server.controller.ts index 8362e26..dbe6062 100644 --- a/backend/src/server/server.controller.ts +++ b/backend/src/server/server.controller.ts @@ -6,16 +6,24 @@ import { Param, Post, Patch, + UseGuards, + Session, + HttpException, } from '@nestjs/common'; import { ServerService } from './server.service'; import { Server } from './server.entity'; +import { LoginGuard } from '../login/login.guard'; +import RequestServerDto from './dto/RequestServerDto'; +import { ExpressSession } from '../types/session'; +import ResponseEntity from '../common/response-entity'; @Controller('/api/servers') export class ServerController { constructor(private serverService: ServerService) { this.serverService = serverService; } + @Get('list') async findAll(): Promise { const serverList = await this.serverService.findAll(); return Object.assign({ @@ -24,6 +32,7 @@ export class ServerController { statusMsg: `데이터 조회가 성공적으로 완료되었습니다.`, }); } + @Get('/:id') async findOne(@Param('id') id: number): Promise { const foundServer = await this.serverService.findOne(id); return Object.assign({ @@ -32,14 +41,23 @@ export class ServerController { statusMsg: `데이터 조회가 성공적으로 완료되었습니다.`, }); } - @Post() async saveServer(@Body() server: Server): Promise { - await this.serverService.addServer(server); - return Object.assign({ - data: { ...server }, - statusCode: 200, - statusMsg: `saved successfully`, - }); + + @Post() + @UseGuards(LoginGuard) + async saveServer( + @Session() + session: ExpressSession, + @Body() requestServerDto: RequestServerDto, + ): Promise> { + try { + const user = session.user; + const newServer = await this.serverService.create(user, requestServerDto); + return ResponseEntity.created(newServer.id); + } catch (error) { + throw new HttpException(error.response, 403); + } } + @Patch('/:id') async updateUser( @Param('id') id: number, @Body() server: Server, @@ -51,6 +69,7 @@ export class ServerController { statusMsg: `updated successfully`, }); } + @Delete('/:id') async deleteUser(@Param('id') id: number): Promise { await this.serverService.deleteServer(id); return Object.assign({ diff --git a/backend/src/server/server.service.ts b/backend/src/server/server.service.ts index d9427b8..3bfff78 100644 --- a/backend/src/server/server.service.ts +++ b/backend/src/server/server.service.ts @@ -1,6 +1,8 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { User } from '../user/user.entity'; import { Repository } from 'typeorm/index'; +import RequestServerDto from './dto/RequestServerDto'; import { Server } from './server.entity'; @@ -11,18 +13,29 @@ export class ServerService { ) { this.serverRepository = serverRepository; } + findAll(): Promise { return this.serverRepository.find({ relations: ['owner'] }); } + findOne(id: number): Promise { return this.serverRepository.findOne({ id: id }); } - async addServer(server: Server): Promise { - await this.serverRepository.save(server); + + async create( + user: User, + requestServerDto: RequestServerDto, + ): Promise { + const newServer = requestServerDto.toServerEntity(); + newServer.owner = user; + + return this.serverRepository.save(newServer); } + async updateServer(id: number, server: Server): Promise { await this.serverRepository.update(id, server); } + async deleteServer(id: number): Promise { await this.serverRepository.delete({ id: id }); } From 8bc7eeb995f66e03ddca23beb83ab6d485a574df Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Sat, 20 Nov 2021 16:15:32 +0900 Subject: [PATCH 023/172] =?UTF-8?q?Feat=20:=20Server=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server 생성 api를 frontend와 연결하였습니다. --- backend/src/server/dto/RequestServerDto.ts | 10 ----- backend/src/server/server.service.ts | 4 +- .../Main/ServerModal/CreateServerModal.tsx | 37 ++++++++++++++++--- 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/backend/src/server/dto/RequestServerDto.ts b/backend/src/server/dto/RequestServerDto.ts index 8f975ee..5733a56 100644 --- a/backend/src/server/dto/RequestServerDto.ts +++ b/backend/src/server/dto/RequestServerDto.ts @@ -1,5 +1,3 @@ -import { Server } from '../server.entity'; - class RequestServerDto { name: string; description: string; @@ -8,14 +6,6 @@ class RequestServerDto { this.name = name; this.description = description; } - - toServerEntity = () => { - const newServer = new Server(); - newServer.name = this.name; - newServer.description = this.description; - - return newServer; - }; } export default RequestServerDto; diff --git a/backend/src/server/server.service.ts b/backend/src/server/server.service.ts index 3bfff78..e7c029e 100644 --- a/backend/src/server/server.service.ts +++ b/backend/src/server/server.service.ts @@ -26,7 +26,9 @@ export class ServerService { user: User, requestServerDto: RequestServerDto, ): Promise { - const newServer = requestServerDto.toServerEntity(); + const newServer = new Server(); + newServer.name = requestServerDto.name; + newServer.description = requestServerDto.description; newServer.owner = user; return this.serverRepository.save(newServer); diff --git a/frontend/src/components/Main/ServerModal/CreateServerModal.tsx b/frontend/src/components/Main/ServerModal/CreateServerModal.tsx index 363145f..0ef513c 100644 --- a/frontend/src/components/Main/ServerModal/CreateServerModal.tsx +++ b/frontend/src/components/Main/ServerModal/CreateServerModal.tsx @@ -145,6 +145,12 @@ const SubmitButton = styled.button<{ isButtonActive: boolean }>` } `; +const MessageFailToPost = styled.span` + color: red; + font-size: 16px; + font-family: Malgun Gothic; +`; + const ModalCloseButton = styled.div` width: 32px; height: 32px; @@ -174,12 +180,23 @@ function CreateServerModal(): JSX.Element { watch, formState: { errors }, } = useForm(); - const { selectedServer, setIsCreateServerModalOpen } = useContext(MainStoreContext); + const { selectedServer, setIsCreateServerModalOpen, setServerList, setSelectedServer } = useContext(MainStoreContext); const [isButtonActive, setIsButtonActive] = useState(false); + const [messageFailToPost, setMessageFailToPost] = useState(''); + + const getServerList = async (): Promise => { + const response = await fetch(`/api/user/servers`); + const list = await response.json(); - const onSubmitCreateChannelModal = async (data: { name: string; description: string }) => { + if (response.status === 200 && list.data.length !== 0) { + setServerList(list.data); + setSelectedServer(list.data[list.data.length - 1]); + } + }; + + const onSubmitCreateServerModal = async (data: { name: string; description: string }) => { const { name, description } = data; - await fetch('api/channel', { + const response = await fetch('api/servers', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -187,9 +204,16 @@ function CreateServerModal(): JSX.Element { body: JSON.stringify({ name: name.trim(), description: description.trim(), - server: +selectedServer, }), }); + + if (response.status === 201) { + getServerList(); + setIsCreateServerModalOpen(false); + } else { + const body = await response.json(); + setMessageFailToPost(body.message); + } }; useEffect(() => { @@ -211,12 +235,12 @@ function CreateServerModal(): JSX.Element { 생성할 서버의 이름과 설명을 작성해주세요 -
+ 이름 value.trim().length > 2 || '"이름" 칸은 3글자 이상 입력되어야합니다!', + validate: (value) => value.trim().length > 1 || '"이름" 칸은 2글자 이상 입력되어야합니다!', })} placeholder="서버명을 입력해주세요" /> @@ -232,6 +256,7 @@ function CreateServerModal(): JSX.Element { /> {errors.description && {errors.description.message}} + {messageFailToPost} 생성 From 423cd2edb56d1280193c709f3c944443a11a5979 Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Sat, 20 Nov 2021 16:39:42 +0900 Subject: [PATCH 024/172] =?UTF-8?q?Feat=20:=20Server=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EC=8B=9C=20=EB=82=B4=20Server=20=EB=AA=A9=EB=A1=9D=EC=97=90?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=ED=95=98=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Server 생성 시 내 Server목록에 해당 서버를 추가하도록 기능을 추가하였습니다. - Module, Service간 순환참조 문제를 해결하였습니다. --- backend/src/server/server.module.ts | 8 ++++++-- backend/src/server/server.service.ts | 10 ++++++++-- backend/src/user-server/user-server.module.ts | 6 +++--- backend/src/user-server/user-server.service.ts | 5 +++-- .../components/Main/ServerModal/CreateServerModal.tsx | 2 +- 5 files changed, 21 insertions(+), 10 deletions(-) diff --git a/backend/src/server/server.module.ts b/backend/src/server/server.module.ts index de968eb..9ab24c4 100644 --- a/backend/src/server/server.module.ts +++ b/backend/src/server/server.module.ts @@ -1,13 +1,17 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from 'src/user/user.entity'; import { Server } from './server.entity'; import { ServerService } from './server.service'; import { ServerController } from './server.controller'; +import { UserServerModule } from '../user-server/user-server.module'; @Module({ - imports: [TypeOrmModule.forFeature([User, Server])], + imports: [ + forwardRef(() => UserServerModule), + TypeOrmModule.forFeature([User, Server]), + ], providers: [ServerService], controllers: [ServerController], exports: [ServerService], diff --git a/backend/src/server/server.service.ts b/backend/src/server/server.service.ts index e7c029e..b3f7c7d 100644 --- a/backend/src/server/server.service.ts +++ b/backend/src/server/server.service.ts @@ -1,14 +1,17 @@ -import { Injectable } from '@nestjs/common'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { User } from '../user/user.entity'; import { Repository } from 'typeorm/index'; import RequestServerDto from './dto/RequestServerDto'; import { Server } from './server.entity'; +import { UserServerService } from '../user-server/user-server.service'; @Injectable() export class ServerService { constructor( + @Inject(forwardRef(() => UserServerService)) + private readonly userServerService: UserServerService, @InjectRepository(Server) private serverRepository: Repository, ) { this.serverRepository = serverRepository; @@ -31,7 +34,10 @@ export class ServerService { newServer.description = requestServerDto.description; newServer.owner = user; - return this.serverRepository.save(newServer); + const createdServer = await this.serverRepository.save(newServer); + this.userServerService.create(user, createdServer.id); + + return createdServer; } async updateServer(id: number, server: Server): Promise { diff --git a/backend/src/user-server/user-server.module.ts b/backend/src/user-server/user-server.module.ts index c7df8dd..4290937 100644 --- a/backend/src/user-server/user-server.module.ts +++ b/backend/src/user-server/user-server.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { UserServerController } from './user-server.controller'; import { UserServerRepository } from './user-server.repository'; @@ -8,11 +8,11 @@ import { ServerModule } from '../server/server.module'; @Module({ imports: [ - ServerModule, + forwardRef(() => ServerModule), TypeOrmModule.forFeature([UserServer, UserServerRepository]), ], providers: [UserServerService], controllers: [UserServerController], - exports: [UserServerService, TypeOrmModule], + exports: [UserServerService], }) export class UserServerModule {} diff --git a/backend/src/user-server/user-server.service.ts b/backend/src/user-server/user-server.service.ts index 7140e33..24e171a 100644 --- a/backend/src/user-server/user-server.service.ts +++ b/backend/src/user-server/user-server.service.ts @@ -1,4 +1,4 @@ -import { HttpException, Injectable } from '@nestjs/common'; +import { forwardRef, HttpException, Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { UserServerRepository } from './user-server.repository'; import { UserServer } from './user-server.entity'; @@ -9,7 +9,8 @@ import { ServerService } from '../server/server.service'; @Injectable() export class UserServerService { constructor( - private serverService: ServerService, + @Inject(forwardRef(() => ServerService)) + private readonly serverService: ServerService, @InjectRepository(UserServerRepository) private userServerRepository: UserServerRepository, ) {} diff --git a/frontend/src/components/Main/ServerModal/CreateServerModal.tsx b/frontend/src/components/Main/ServerModal/CreateServerModal.tsx index 0ef513c..9dbfe51 100644 --- a/frontend/src/components/Main/ServerModal/CreateServerModal.tsx +++ b/frontend/src/components/Main/ServerModal/CreateServerModal.tsx @@ -180,7 +180,7 @@ function CreateServerModal(): JSX.Element { watch, formState: { errors }, } = useForm(); - const { selectedServer, setIsCreateServerModalOpen, setServerList, setSelectedServer } = useContext(MainStoreContext); + const { setIsCreateServerModalOpen, setServerList, setSelectedServer } = useContext(MainStoreContext); const [isButtonActive, setIsButtonActive] = useState(false); const [messageFailToPost, setMessageFailToPost] = useState(''); From a2de049931f8fa46848be02b449ce8f0cd8b35f7 Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Sat, 20 Nov 2021 18:40:54 +0900 Subject: [PATCH 025/172] =?UTF-8?q?Feat=20:=20JoinChannelModal=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JoinChannelModal에서는 이제 로그인한 사용자가 참가하지 않은 Channel들의 목록을 보여줍니다. - JoinChannelModal에 css 를 적용하였습니다. 리스트의 item에 마우스를 올리면 '참여' 버튼을 보여줍니다. --- .../user-channel/user-channel.controller.ts | 17 ++- .../user-channel/user-channel.repository.ts | 1 + frontend/src/components/Main/ChannelList.tsx | 1 - .../Main/Modal/JoinChannelModal.tsx | 126 +++++++++++++++++- frontend/src/types/main.ts | 1 - 5 files changed, 132 insertions(+), 14 deletions(-) diff --git a/backend/src/user-channel/user-channel.controller.ts b/backend/src/user-channel/user-channel.controller.ts index 21f0fe0..bbb3dac 100644 --- a/backend/src/user-channel/user-channel.controller.ts +++ b/backend/src/user-channel/user-channel.controller.ts @@ -34,13 +34,20 @@ export class UserChannelController { return ResponseEntity.ok(joinedChannelList); } - @Get('/notjoined') - getNotJoinedChannelList( - @Param('serverId') serverid: number, + @Get('/notjoined/:id') + async getNotJoinedChannelList( + @Param('id') serverId: number, @Session() session: ExpressSession, ) { - console.log(session.user); - //return this.userChannelService.deleteById(serverid); + const response = + await this.userChannelService.getNotJoinedChannelListByUserId( + serverId, + session.user.id, + ); + const notJoinedChannelList = response.map( + (userChannel) => userChannel.channel, + ); + return ResponseEntity.ok(notJoinedChannelList); } @Delete('/:id') diff --git a/backend/src/user-channel/user-channel.repository.ts b/backend/src/user-channel/user-channel.repository.ts index 8c3c8de..93ca20c 100644 --- a/backend/src/user-channel/user-channel.repository.ts +++ b/backend/src/user-channel/user-channel.repository.ts @@ -13,6 +13,7 @@ export class UserChannelRepository extends Repository { getNotJoinedChannelListByUserId(userId: number, serverId: number) { return this.createQueryBuilder('user_channel') + .leftJoinAndSelect('user_channel.channel', 'channel') .where('user_channel.user != :userId', { userId: userId }) .andWhere('user_channel.server = :serverId', { serverId: serverId }) .getMany(); diff --git a/frontend/src/components/Main/ChannelList.tsx b/frontend/src/components/Main/ChannelList.tsx index a510cb2..6ae650d 100644 --- a/frontend/src/components/Main/ChannelList.tsx +++ b/frontend/src/components/Main/ChannelList.tsx @@ -132,7 +132,6 @@ function ChannelList(): JSX.Element { const getChannelList = async (): Promise => { const response = await fetch(`/api/user/channels/joined/${selectedServer}`); const list = await response.json(); - setChannelList(list.data); }; diff --git a/frontend/src/components/Main/Modal/JoinChannelModal.tsx b/frontend/src/components/Main/Modal/JoinChannelModal.tsx index 2cd9a61..cd629fb 100644 --- a/frontend/src/components/Main/Modal/JoinChannelModal.tsx +++ b/frontend/src/components/Main/Modal/JoinChannelModal.tsx @@ -1,8 +1,9 @@ -import React, { useContext, useEffect } from 'react'; +import React, { useState, useContext, useEffect } from 'react'; import styled from 'styled-components'; import { MainStoreContext } from '../MainStore'; import { BoostCamMainIcons } from '../../../utils/SvgIcons'; +import { ChannelData } from '../../../types/main'; const { Close } = BoostCamMainIcons; @@ -28,9 +29,10 @@ const ModalBackground = styled.div` `; const ModalBox = styled.div` - width: 35%; + width: 50%; min-width: 400px; - height: 50%; + height: 70%; + min-height: 500px; background-color: #222322; @@ -60,6 +62,8 @@ const ModalHeader = styled.div` flex-direction: row; justify-content: space-between; align-items: center; + + flex: 1; `; const ModalTitle = styled.span` @@ -72,6 +76,7 @@ const ModalTitle = styled.span` `; const ModalDescription = styled.span` + flex: 0.3; margin-left: 25px; padding: 10px 5px; @@ -90,6 +95,95 @@ const ModalCloseButton = styled.div` margin-right: 25px; `; +const ModalChannelList = styled.div` + width: 90%; + height: 70%; + margin-left: 25px; + margin-bottom: 25px; + + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + + color: #e5e0d8; + + flex: 4; + + overflow-y: auto; + + &::-webkit-scrollbar { + width: 10px; + } + &::-webkit-scrollbar-thumb { + background-color: #999999; + border-radius: 10px; + } + &::-webkit-scrollbar-track { + background-color: #cccccc; + border-radius: 10px; + } +`; + +const ModalChannelListItem = styled.div` + width: 90%; + padding: 15px 10px; + margin: 3px 0px 0px 0px; + + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + + font-size: 18px; + + border-top: 1px solid #e5e0d8; + + &:last-child { + border-bottom: 1px solid #e5e0d8; + } + + &:hover { + button { + visibility: visible; + } + background-color: #282929; + } + + button { + visibility: hidden; + } +`; + +const ItemText = styled.div` + flex: 4; + display: flex; + flex-direction: column; + justify-content: space-around; + align-items: flex-start; +`; + +const ItemTitle = styled.span` + font-size: 20px; + font-weight: 600; +`; + +const ItemDescription = styled.span` + text-align: center; + font-weight: 400; + color: #6e6d69; +`; + +const ItemButton = styled.button` + border: none; + border-radius: 5px; + flex: 0.5; + padding: 4px 12px 4px; + text-align: center; + background-color: #236b56; + cursor: pointer; +`; + const CloseIcon = styled(Close)` width: 20px; height: 20px; @@ -98,17 +192,34 @@ const CloseIcon = styled(Close)` function JoinChannelModal(): JSX.Element { const { selectedServer, setIsJoinModalOpen } = useContext(MainStoreContext); + const [channelList, setChannelList] = useState([]); - const getNotJoinedChannel = async () => { - const response = await fetch(`/api/user/channels/joined/${selectedServer}`); + const getNotJoinedChannelList = async () => { + const response = await fetch(`/api/user/channels/notjoined/${selectedServer}`); const list = await response.json(); - console.log(list.data); + setChannelList(list.data); }; + const onClickChannelListButton = async () => {}; + useEffect(() => { - getNotJoinedChannel(); + getNotJoinedChannelList(); }, []); + useEffect(() => { + console.log(channelList); + }, [channelList]); + + const tmpList = channelList.map((val) => ( + + + {val.name} + {val.description} + + 참여 + + )); + /* eslint-disable react/jsx-props-no-spreading */ return ( @@ -122,6 +233,7 @@ function JoinChannelModal(): JSX.Element { 참가할 채널을 선택해주세요 + {tmpList} diff --git a/frontend/src/types/main.ts b/frontend/src/types/main.ts index 834efac..08c43f5 100644 --- a/frontend/src/types/main.ts +++ b/frontend/src/types/main.ts @@ -16,7 +16,6 @@ type ChannelData = { description: string; id: string; name: string; - server: ServerData; }; export type { UserData, ServerData, ChannelData }; From 2a8e716e1df152a6bc59a1eb2998ee4767d4ee0e Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Sat, 20 Nov 2021 21:19:12 +0900 Subject: [PATCH 026/172] =?UTF-8?q?Chore=20:=20ncloud=20object=20storage?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=EC=97=85=EB=A1=9C=EB=93=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버아이콘 업로드를 위한 aws-sdk, @types/multer 라이브러리를 추가하였습니다. --- backend/package-lock.json | 222 ++++++++++++++++++++++++++++++++++++++ backend/package.json | 2 + 2 files changed, 224 insertions(+) diff --git a/backend/package-lock.json b/backend/package-lock.json index b59fb2f..a6bb274 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -16,6 +16,7 @@ "@nestjs/platform-socket.io": "^8.1.2", "@nestjs/typeorm": "^8.0.2", "@nestjs/websockets": "^8.1.2", + "aws-sdk": "^2.1033.0", "axios": "^0.24.0", "express-session": "^1.17.2", "mysql2": "^2.3.3", @@ -33,6 +34,7 @@ "@types/express": "^4.17.13", "@types/express-session": "^1.17.4", "@types/jest": "^27.0.1", + "@types/multer": "^1.4.7", "@types/node": "^16.0.0", "@types/supertest": "^2.0.11", "@typescript-eslint/eslint-plugin": "^5.0.0", @@ -2012,6 +2014,15 @@ "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" }, + "node_modules/@types/multer": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz", + "integrity": "sha512-/SNsDidUFCvqqcWDwxv2feww/yqhNeTRL5CVoL3jU4Goc4kKEL10T7Eye65ZqPNi4HRx8sAEX59pV1aEH7drNA==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "16.11.6", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.6.tgz", @@ -2669,6 +2680,84 @@ "node": ">= 4.0.0" } }, + "node_modules/aws-sdk": { + "version": "2.1033.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1033.0.tgz", + "integrity": "sha512-cgcjiuR82bhfBWTffqt6e9+Cn/UgeC6QPQTrlJy3GxwPxChthyrt/h5pekj2l4PLFvETsG10Y6CqQysJEMsncw==", + "dependencies": { + "buffer": "4.9.2", + "events": "1.1.1", + "ieee754": "1.1.13", + "jmespath": "0.15.0", + "querystring": "0.2.0", + "sax": "1.2.1", + "url": "0.10.3", + "uuid": "3.3.2", + "xml2js": "0.4.19" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/aws-sdk/node_modules/buffer": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", + "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + } + }, + "node_modules/aws-sdk/node_modules/events": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", + "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=", + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/aws-sdk/node_modules/ieee754": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", + "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" + }, + "node_modules/aws-sdk/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "node_modules/aws-sdk/node_modules/sax": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", + "integrity": "sha1-e45lYZCyKOgaZq6nSEgNgozS03o=" + }, + "node_modules/aws-sdk/node_modules/uuid": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/aws-sdk/node_modules/xml2js": { + "version": "0.4.19", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz", + "integrity": "sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~9.0.1" + } + }, + "node_modules/aws-sdk/node_modules/xmlbuilder": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", + "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=", + "engines": { + "node": ">=4.0" + } + }, "node_modules/axios": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz", @@ -6378,6 +6467,14 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jmespath": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz", + "integrity": "sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc=", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7598,6 +7695,15 @@ "node": ">=0.6" } }, + "node_modules/querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=", + "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -9238,6 +9344,20 @@ "punycode": "^2.1.0" } }, + "node_modules/url": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", + "integrity": "sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ=", + "dependencies": { + "punycode": "1.3.2", + "querystring": "0.2.0" + } + }, + "node_modules/url/node_modules/punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -11282,6 +11402,15 @@ "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" }, + "@types/multer": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz", + "integrity": "sha512-/SNsDidUFCvqqcWDwxv2feww/yqhNeTRL5CVoL3jU4Goc4kKEL10T7Eye65ZqPNi4HRx8sAEX59pV1aEH7drNA==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, "@types/node": { "version": "16.11.6", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.6.tgz", @@ -11796,6 +11925,73 @@ "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", "dev": true }, + "aws-sdk": { + "version": "2.1033.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1033.0.tgz", + "integrity": "sha512-cgcjiuR82bhfBWTffqt6e9+Cn/UgeC6QPQTrlJy3GxwPxChthyrt/h5pekj2l4PLFvETsG10Y6CqQysJEMsncw==", + "requires": { + "buffer": "4.9.2", + "events": "1.1.1", + "ieee754": "1.1.13", + "jmespath": "0.15.0", + "querystring": "0.2.0", + "sax": "1.2.1", + "url": "0.10.3", + "uuid": "3.3.2", + "xml2js": "0.4.19" + }, + "dependencies": { + "buffer": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", + "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", + "requires": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + } + }, + "events": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", + "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=" + }, + "ieee754": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", + "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "sax": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", + "integrity": "sha1-e45lYZCyKOgaZq6nSEgNgozS03o=" + }, + "uuid": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" + }, + "xml2js": { + "version": "0.4.19", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz", + "integrity": "sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==", + "requires": { + "sax": ">=0.6.0", + "xmlbuilder": "~9.0.1" + } + }, + "xmlbuilder": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", + "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=" + } + } + }, "axios": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz", @@ -14598,6 +14794,11 @@ } } }, + "jmespath": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz", + "integrity": "sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc=" + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -15547,6 +15748,11 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" }, + "querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=" + }, "queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -16697,6 +16903,22 @@ "punycode": "^2.1.0" } }, + "url": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", + "integrity": "sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ=", + "requires": { + "punycode": "1.3.2", + "querystring": "0.2.0" + }, + "dependencies": { + "punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" + } + } + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 628a405..bbbe1a9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,6 +28,7 @@ "@nestjs/platform-socket.io": "^8.1.2", "@nestjs/typeorm": "^8.0.2", "@nestjs/websockets": "^8.1.2", + "aws-sdk": "^2.1033.0", "axios": "^0.24.0", "express-session": "^1.17.2", "mysql2": "^2.3.3", @@ -45,6 +46,7 @@ "@types/express": "^4.17.13", "@types/express-session": "^1.17.4", "@types/jest": "^27.0.1", + "@types/multer": "^1.4.7", "@types/node": "^16.0.0", "@types/supertest": "^2.0.11", "@typescript-eslint/eslint-plugin": "^5.0.0", From d4f87eac60edd2ec00f7aa7ef11c1935f3fc0dac Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Sat, 20 Nov 2021 21:21:18 +0900 Subject: [PATCH 027/172] =?UTF-8?q?Feat=20:=20Image=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit object storage image업로드를 위한 image 모듈을 추가하였습니다. --- backend/src/app.module.ts | 2 ++ backend/src/image/image.module.ts | 8 +++++ backend/src/image/image.service.spec.ts | 18 ++++++++++++ backend/src/image/image.service.ts | 39 +++++++++++++++++++++++++ 4 files changed, 67 insertions(+) create mode 100644 backend/src/image/image.module.ts create mode 100644 backend/src/image/image.service.spec.ts create mode 100644 backend/src/image/image.service.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index df829bb..7095aa6 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -15,6 +15,7 @@ import { ServerModule } from './server/server.module'; import { CamsModule } from './cams/cams.module'; import { UserServerModule } from './user-server/user-server.module'; import { LoginModule } from './login/login.module'; +import { ImageModule } from './image/image.module'; import githubConfig from './config/github.config'; @Module({ @@ -35,6 +36,7 @@ import githubConfig from './config/github.config'; CamsModule, UserServerModule, LoginModule, + ImageModule, ], controllers: [AppController], providers: [AppService], diff --git a/backend/src/image/image.module.ts b/backend/src/image/image.module.ts new file mode 100644 index 0000000..71eadae --- /dev/null +++ b/backend/src/image/image.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { ImageService } from './image.service'; + +@Module({ + providers: [ImageService], + exports: [ImageService], +}) +export class ImageModule {} diff --git a/backend/src/image/image.service.spec.ts b/backend/src/image/image.service.spec.ts new file mode 100644 index 0000000..2adc452 --- /dev/null +++ b/backend/src/image/image.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ImageService } from './image.service'; + +describe('ImageService', () => { + let service: ImageService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ImageService], + }).compile(); + + service = module.get(ImageService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/backend/src/image/image.service.ts b/backend/src/image/image.service.ts new file mode 100644 index 0000000..c0f139a --- /dev/null +++ b/backend/src/image/image.service.ts @@ -0,0 +1,39 @@ +import { HttpException, Injectable } from '@nestjs/common'; + +import * as AWS from 'aws-sdk'; + +@Injectable() +export class ImageService { + async uploadFile(file: Express.Multer.File) { + const endpoint = new AWS.Endpoint('https://kr.object.ncloudstorage.com'); + + const S3 = new AWS.S3({ + endpoint: endpoint, + region: process.env.NCP_STORAGE_REGION, + credentials: { + accessKeyId: process.env.NCP_STORAGE_ACCESS_KEY, + secretAccessKey: process.env.NCP_STORAGE_SECRET_KEY, + }, + }); + + const bucket_name = process.env.NCP_STORAGE_BUCKET_NAME; + const object_name = `${Date.now().toString()}-${file.originalname}`; + const options = { + partSize: 5 * 1024 * 1024, + }; + + try { + return await S3.upload( + { + Bucket: bucket_name, + Body: file.buffer, + Key: object_name, + ACL: 'public-read', + }, + options, + ).promise(); + } catch (error) { + throw new HttpException('서버 아이콘 업로드에 실패했습니다.', 403); + } + } +} From dc56dd885d48f64fa8804f9aa025fa1f135a0a24 Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Sat, 20 Nov 2021 22:20:36 +0900 Subject: [PATCH 028/172] =?UTF-8?q?Feat=20:=20Server=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 목록에 서버 이미지 아이콘이 보이게 처리했습니다. - 이미지가 없다면 서버 이름의 첫글자를 아이콘으로 만들도록 처리했습니다. - Server 테이블에 이미지 url 칼럼을 추가하였습니다. - Server에 이미지를 추가할 수 있는 input을 추가하였습니다. --- backend/src/server/server.controller.ts | 25 ++++++++-- backend/src/server/server.entity.ts | 3 ++ backend/src/server/server.module.ts | 4 +- backend/src/server/server.service.ts | 6 ++- .../src/components/Main/ServerListTab.tsx | 33 +++++++----- .../Main/ServerModal/CreateServerModal.tsx | 50 +++++++++++++++---- frontend/src/types/main.ts | 1 + 7 files changed, 91 insertions(+), 31 deletions(-) diff --git a/backend/src/server/server.controller.ts b/backend/src/server/server.controller.ts index dbe6062..41fe1bb 100644 --- a/backend/src/server/server.controller.ts +++ b/backend/src/server/server.controller.ts @@ -9,6 +9,8 @@ import { UseGuards, Session, HttpException, + UseInterceptors, + UploadedFile, } from '@nestjs/common'; import { ServerService } from './server.service'; @@ -17,12 +19,15 @@ import { LoginGuard } from '../login/login.guard'; import RequestServerDto from './dto/RequestServerDto'; import { ExpressSession } from '../types/session'; import ResponseEntity from '../common/response-entity'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ImageService } from '../image/image.service'; @Controller('/api/servers') export class ServerController { - constructor(private serverService: ServerService) { - this.serverService = serverService; - } + constructor( + private serverService: ServerService, + private imageService: ImageService, + ) {} @Get('list') async findAll(): Promise { const serverList = await this.serverService.findAll(); @@ -44,14 +49,26 @@ export class ServerController { @Post() @UseGuards(LoginGuard) + @UseInterceptors(FileInterceptor('icon')) async saveServer( @Session() session: ExpressSession, @Body() requestServerDto: RequestServerDto, + @UploadedFile() icon: Express.Multer.File, ): Promise> { try { + let imgUrl: string; + + if (icon !== undefined && icon.mimetype.substring(0, 5) === 'image') { + const uploadedFile = await this.imageService.uploadFile(icon); + imgUrl = uploadedFile.Location; + } const user = session.user; - const newServer = await this.serverService.create(user, requestServerDto); + const newServer = await this.serverService.create( + user, + requestServerDto, + imgUrl, + ); return ResponseEntity.created(newServer.id); } catch (error) { throw new HttpException(error.response, 403); diff --git a/backend/src/server/server.entity.ts b/backend/src/server/server.entity.ts index 61e4923..45624a6 100644 --- a/backend/src/server/server.entity.ts +++ b/backend/src/server/server.entity.ts @@ -18,6 +18,9 @@ export class Server { @Column() name: string; + @Column() + imgUrl: string; + @ManyToOne(() => User) @JoinColumn({ referencedColumnName: 'id' }) owner: User; diff --git a/backend/src/server/server.module.ts b/backend/src/server/server.module.ts index 9ab24c4..4b48291 100644 --- a/backend/src/server/server.module.ts +++ b/backend/src/server/server.module.ts @@ -1,14 +1,16 @@ import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { User } from 'src/user/user.entity'; +import { User } from '../user/user.entity'; import { Server } from './server.entity'; import { ServerService } from './server.service'; import { ServerController } from './server.controller'; import { UserServerModule } from '../user-server/user-server.module'; +import { ImageModule } from '../image/image.module'; @Module({ imports: [ + ImageModule, forwardRef(() => UserServerModule), TypeOrmModule.forFeature([User, Server]), ], diff --git a/backend/src/server/server.service.ts b/backend/src/server/server.service.ts index b3f7c7d..673522f 100644 --- a/backend/src/server/server.service.ts +++ b/backend/src/server/server.service.ts @@ -1,10 +1,10 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { User } from '../user/user.entity'; import { Repository } from 'typeorm/index'; -import RequestServerDto from './dto/RequestServerDto'; +import { User } from '../user/user.entity'; import { Server } from './server.entity'; +import RequestServerDto from './dto/RequestServerDto'; import { UserServerService } from '../user-server/user-server.service'; @Injectable() @@ -28,11 +28,13 @@ export class ServerService { async create( user: User, requestServerDto: RequestServerDto, + imgUrl: string | undefined, ): Promise { const newServer = new Server(); newServer.name = requestServerDto.name; newServer.description = requestServerDto.description; newServer.owner = user; + newServer.imgUrl = imgUrl !== undefined ? imgUrl : ''; const createdServer = await this.serverRepository.save(newServer); this.userServerService.create(user, createdServer.id); diff --git a/frontend/src/components/Main/ServerListTab.tsx b/frontend/src/components/Main/ServerListTab.tsx index c93db07..0e74764 100644 --- a/frontend/src/components/Main/ServerListTab.tsx +++ b/frontend/src/components/Main/ServerListTab.tsx @@ -28,8 +28,6 @@ const ServerIconBox = styled.div<{ selected: boolean }>` height: 60px; margin-top: 10px; - - background-color: white; box-sizing: border-box; ${(props) => (props.selected ? 'border: 5px solid gray;' : '')} @@ -38,18 +36,31 @@ const ServerIconBox = styled.div<{ selected: boolean }>` display: flex; justify-content: center; align-items: center; - &:hover { cursor: pointer; } + z-index: 1; `; const ServerImg = styled.div<{ imgUrl: string }>` - width: 40px; - height: 40px; + width: 55px; + height: 55px; background-image: url(${(props) => props.imgUrl}); background-size: cover; background-repeat: no-repeat; + border-radius: 20px; + z-index: 0; +`; + +const ServerName = styled.div` + width: 55px; + height: 55px; + font-size: 40px; + font-weight: bold; + background-color: white; + border-radius: 20px; + text-align: center; + vertical-align: middle; `; const AddServerButton = styled.div` @@ -73,12 +84,6 @@ const PlusIcon = styled(Plus)` fill: #a69c96; `; -const tmpUrl: string[] = [ - 'https://miro.medium.com/max/2000/0*wwsAZUu1oClOuat-.png', - 'https://miro.medium.com/max/2000/0*Jx_rwR_dmW4y1g-7.png', - 'https://kgo.googleusercontent.com/profile_vrt_raw_bytes_1587515358_10512.png', -]; - function ServerListTab(): JSX.Element { const [isDropdownActivated, setIsDropdownActivated] = useState(false); const { @@ -109,11 +114,13 @@ function ServerListTab(): JSX.Element { e.stopPropagation(); setIsDropdownActivated(!isDropdownActivated); }; - const listElements = serverList.map((myServerData: MyServerData, idx: number): JSX.Element => { + const listElements = serverList.map((myServerData: MyServerData): JSX.Element => { const selected = selectedServer !== undefined ? selectedServer.id === myServerData.id : false; const onClickChangeSelectedServer = () => { setSelectedServer(myServerData); }; + const { server } = myServerData; + const { imgUrl, name } = server; return ( - + {imgUrl ? : {name[0]}} ); }); diff --git a/frontend/src/components/Main/ServerModal/CreateServerModal.tsx b/frontend/src/components/Main/ServerModal/CreateServerModal.tsx index 9dbfe51..3c52c80 100644 --- a/frontend/src/components/Main/ServerModal/CreateServerModal.tsx +++ b/frontend/src/components/Main/ServerModal/CreateServerModal.tsx @@ -31,7 +31,6 @@ const ModalBackground = styled.div` const ModalBox = styled.div` width: 35%; min-width: 400px; - height: 50%; background-color: #222322; @@ -82,7 +81,7 @@ const ModalDescription = styled.span` const Form = styled.form` width: 90%; - height: 70%; + height: 100%; border-radius: 20px; margin: 30px 0px 0px 25px; @@ -101,6 +100,14 @@ const InputDiv = styled.div` align-items: flex-start; `; +const ImageInputDiv = styled.div` + width: 100%; + height: 100%; + display: flex; + justify-content: flex-start; + align-items: flex-start; +`; + const InputName = styled.span` color: #cbc4b9; font-size: 20px; @@ -145,6 +152,10 @@ const SubmitButton = styled.button<{ isButtonActive: boolean }>` } `; +const ImagePreview = styled.img` + width: 40px; + height: 40px; +`; const MessageFailToPost = styled.span` color: red; font-size: 16px; @@ -171,6 +182,7 @@ const CloseIcon = styled(Close)` type CreateModalForm = { name: string; description: string; + file: FileList; }; function CreateServerModal(): JSX.Element { @@ -183,6 +195,7 @@ function CreateServerModal(): JSX.Element { const { setIsCreateServerModalOpen, setServerList, setSelectedServer } = useContext(MainStoreContext); const [isButtonActive, setIsButtonActive] = useState(false); const [messageFailToPost, setMessageFailToPost] = useState(''); + const [imagePreview, setImagePreview] = useState(); const getServerList = async (): Promise => { const response = await fetch(`/api/user/servers`); @@ -194,17 +207,17 @@ function CreateServerModal(): JSX.Element { } }; - const onSubmitCreateServerModal = async (data: { name: string; description: string }) => { - const { name, description } = data; + const onSubmitCreateServerModal = async (data: { name: string; description: string; file: FileList }) => { + const formData = new FormData(); + const { name, description, file } = data; + + formData.append('name', name); + formData.append('description', description); + formData.append('icon', file[0]); + const response = await fetch('api/servers', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - name: name.trim(), - description: description.trim(), - }), + body: formData, }); if (response.status === 201) { @@ -216,6 +229,14 @@ function CreateServerModal(): JSX.Element { } }; + const onChangePreviewImage = (e: React.ChangeEvent & { target: HTMLInputElement }) => { + const file = e.target.files; + + if (file) { + setImagePreview(URL.createObjectURL(file[0])); + } + }; + useEffect(() => { const { name, description } = watch(); const isActive = name.trim().length > 2 && description.trim().length > 0; @@ -256,6 +277,13 @@ function CreateServerModal(): JSX.Element { /> {errors.description && {errors.description.message}} + + 서버 아이콘 + + + + + {messageFailToPost} 생성 diff --git a/frontend/src/types/main.ts b/frontend/src/types/main.ts index 90f5c28..f269b32 100644 --- a/frontend/src/types/main.ts +++ b/frontend/src/types/main.ts @@ -9,6 +9,7 @@ type ServerData = { description: string; id: string; name: string; + imgUrl: string; }; type MyServerData = { From d2fafe5a2f3d3a8eaabb9024bd4e78e6e22156ee Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Sat, 20 Nov 2021 22:22:15 +0900 Subject: [PATCH 029/172] =?UTF-8?q?Fix=20:=20JoinChannelModal=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=9C=EB=A0=A5=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JoinChannelModal 에서 참여하지 않은 채널 목록을 출력하는 로직에 오류가 있어 이를 수정하였습니다. --- backend/src/channel/channel.module.ts | 2 + backend/src/channel/channel.service.ts | 8 +++- backend/src/channel/user.repository.ts | 9 ++++ backend/src/server/server.module.ts | 3 +- backend/src/server/server.repository.ts | 5 +++ backend/src/server/server.service.ts | 4 +- .../user-channel/user-channel.controller.ts | 27 ++++++++++-- .../src/user-channel/user-channel.module.ts | 9 +++- .../user-channel/user-channel.repository.ts | 5 ++- .../src/user-channel/user-channel.service.ts | 41 +++++++++++++------ .../Main/Modal/JoinChannelModal.tsx | 18 ++++++-- 11 files changed, 103 insertions(+), 28 deletions(-) create mode 100644 backend/src/channel/user.repository.ts create mode 100644 backend/src/server/server.repository.ts diff --git a/backend/src/channel/channel.module.ts b/backend/src/channel/channel.module.ts index b531d95..05bbfa4 100644 --- a/backend/src/channel/channel.module.ts +++ b/backend/src/channel/channel.module.ts @@ -8,6 +8,7 @@ import { Server } from 'src/server/server.entity'; import { UserChannelService } from 'src/user-channel/user-channel.service'; import { UserChannelRepository } from 'src/user-channel/user-channel.repository'; import { UserRepository } from 'src/user/user.repository'; +import { ChannelRepository } from './user.repository'; @Module({ imports: [ @@ -16,6 +17,7 @@ import { UserRepository } from 'src/user/user.repository'; Server, UserChannelRepository, UserRepository, + ChannelRepository, ]), ], providers: [ChannelService, UserChannelService], diff --git a/backend/src/channel/channel.service.ts b/backend/src/channel/channel.service.ts index 27ed6e4..a308d75 100644 --- a/backend/src/channel/channel.service.ts +++ b/backend/src/channel/channel.service.ts @@ -5,11 +5,12 @@ import { Repository } from 'typeorm/index'; import { CreateChannelDto } from './channe.dto'; import { Channel } from './channel.entity'; import { Server } from 'src/server/server.entity'; +import { ChannelRepository } from './user.repository'; @Injectable() export class ChannelService { /** * 생성자 */ constructor( - @InjectRepository(Channel) private channelRepository: Repository, + @InjectRepository(Channel) private channelRepository: ChannelRepository, @InjectRepository(Server) private serverRepository: Repository, ) { this.channelRepository = channelRepository; @@ -19,7 +20,10 @@ export class ChannelService { return this.channelRepository.find(); } findOne(id: number): Promise { - return this.channelRepository.findOne({ id: id }); + return this.channelRepository.findOne( + { id: id }, + { relations: ['server'] }, + ); } async addChannel(channel: CreateChannelDto): Promise { const channelEntity = this.channelRepository.create(); diff --git a/backend/src/channel/user.repository.ts b/backend/src/channel/user.repository.ts new file mode 100644 index 0000000..1745651 --- /dev/null +++ b/backend/src/channel/user.repository.ts @@ -0,0 +1,9 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { Channel } from './channel.entity'; + +@EntityRepository(Channel) +export class ChannelRepository extends Repository { + getAllList() { + return this.createQueryBuilder('channel').getMany(); + } +} diff --git a/backend/src/server/server.module.ts b/backend/src/server/server.module.ts index de968eb..b66968a 100644 --- a/backend/src/server/server.module.ts +++ b/backend/src/server/server.module.ts @@ -5,9 +5,10 @@ import { User } from 'src/user/user.entity'; import { Server } from './server.entity'; import { ServerService } from './server.service'; import { ServerController } from './server.controller'; +import { ServerRepository } from './server.repository'; @Module({ - imports: [TypeOrmModule.forFeature([User, Server])], + imports: [TypeOrmModule.forFeature([User, Server, ServerRepository])], providers: [ServerService], controllers: [ServerController], exports: [ServerService], diff --git a/backend/src/server/server.repository.ts b/backend/src/server/server.repository.ts new file mode 100644 index 0000000..40347f0 --- /dev/null +++ b/backend/src/server/server.repository.ts @@ -0,0 +1,5 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { Server } from './server.entity'; + +@EntityRepository(Server) +export class ServerRepository extends Repository {} diff --git a/backend/src/server/server.service.ts b/backend/src/server/server.service.ts index d9427b8..5c6768c 100644 --- a/backend/src/server/server.service.ts +++ b/backend/src/server/server.service.ts @@ -1,13 +1,13 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm/index'; import { Server } from './server.entity'; +import { ServerRepository } from './server.repository'; @Injectable() export class ServerService { constructor( - @InjectRepository(Server) private serverRepository: Repository, + @InjectRepository(Server) private serverRepository: ServerRepository, ) { this.serverRepository = serverRepository; } diff --git a/backend/src/user-channel/user-channel.controller.ts b/backend/src/user-channel/user-channel.controller.ts index bbb3dac..cb382b1 100644 --- a/backend/src/user-channel/user-channel.controller.ts +++ b/backend/src/user-channel/user-channel.controller.ts @@ -5,18 +5,26 @@ import { Session, Get, UseGuards, + Post, + Body, } from '@nestjs/common'; import { Channel } from 'src/channel/channel.entity'; import ResponseEntity from 'src/lib/ResponseEntity'; import { LoginGuard } from 'src/login/login.guard'; import { ExpressSession } from '../types/session'; import { UserChannelService } from './user-channel.service'; +import { ChannelService } from 'src/channel/channel.service'; +import { UserChannel } from './user-channel.entity'; @Controller('/api/user/channels') @UseGuards(LoginGuard) export class UserChannelController { - constructor(private userChannelService: UserChannelService) { + constructor( + private userChannelService: UserChannelService, + private channelService: ChannelService, + ) { this.userChannelService = userChannelService; + this.channelService = channelService; } @Get('/joined/:id') @@ -44,10 +52,21 @@ export class UserChannelController { serverId, session.user.id, ); - const notJoinedChannelList = response.map( - (userChannel) => userChannel.channel, + return ResponseEntity.ok(response); + } + + @Post() + async joinNewChannel( + @Body('channelId') channelId: number, + @Body('serverId') serverId: number, + @Session() session: ExpressSession, + ) { + const selectedChannel = await this.channelService.findOne(channelId); + const savedChannel = await this.userChannelService.addNewChannel( + selectedChannel, + session.user.id, ); - return ResponseEntity.ok(notJoinedChannelList); + return ResponseEntity.ok(savedChannel); } @Delete('/:id') diff --git a/backend/src/user-channel/user-channel.module.ts b/backend/src/user-channel/user-channel.module.ts index c8e059e..546a158 100644 --- a/backend/src/user-channel/user-channel.module.ts +++ b/backend/src/user-channel/user-channel.module.ts @@ -2,12 +2,16 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Channel } from 'src/channel/channel.entity'; +import { ChannelService } from 'src/channel/channel.service'; import { User } from 'src/user/user.entity'; import { UserRepository } from 'src/user/user.repository'; import { UserChannelController } from './user-channel.controller'; import { UserChannel } from './user-channel.entity'; import { UserChannelRepository } from './user-channel.repository'; import { UserChannelService } from './user-channel.service'; +import { ServerRepository } from 'src/server/server.repository'; +import { Server } from 'src/server/server.entity'; +import { ChannelRepository } from 'src/channel/user.repository'; @Module({ imports: [ @@ -15,11 +19,14 @@ import { UserChannelService } from './user-channel.service'; UserChannel, User, Channel, + Server, UserChannelRepository, UserRepository, + ServerRepository, + ChannelRepository, ]), ], - providers: [UserChannelService], + providers: [UserChannelService, ChannelService], controllers: [UserChannelController], exports: [UserChannelService, TypeOrmModule], }) diff --git a/backend/src/user-channel/user-channel.repository.ts b/backend/src/user-channel/user-channel.repository.ts index 93ca20c..ffb13dd 100644 --- a/backend/src/user-channel/user-channel.repository.ts +++ b/backend/src/user-channel/user-channel.repository.ts @@ -14,8 +14,9 @@ export class UserChannelRepository extends Repository { getNotJoinedChannelListByUserId(userId: number, serverId: number) { return this.createQueryBuilder('user_channel') .leftJoinAndSelect('user_channel.channel', 'channel') - .where('user_channel.user != :userId', { userId: userId }) - .andWhere('user_channel.server = :serverId', { serverId: serverId }) + .where('user_channel.channel IN (:id)', { + id: this.getJoinedChannelListByUserId(userId, serverId), + }) .getMany(); } diff --git a/backend/src/user-channel/user-channel.service.ts b/backend/src/user-channel/user-channel.service.ts index f285e6f..2e255e3 100644 --- a/backend/src/user-channel/user-channel.service.ts +++ b/backend/src/user-channel/user-channel.service.ts @@ -7,6 +7,7 @@ import { UserChannel } from './user-channel.entity'; import { User } from 'src/user/user.entity'; import { Channel } from 'src/channel/channel.entity'; import { UserRepository } from 'src/user/user.repository'; +import { ChannelRepository } from 'src/channel/user.repository'; @Injectable() export class UserChannelService { @@ -14,9 +15,12 @@ export class UserChannelService { @InjectRepository(UserChannelRepository) private userChannelRepository: UserChannelRepository, @InjectRepository(UserRepository) private userRepository: UserRepository, + @InjectRepository(ChannelRepository) + private channelRepository: ChannelRepository, ) { this.userChannelRepository = userChannelRepository; this.userRepository = userRepository; + this.channelRepository = channelRepository; } async addNewChannel(channel: Channel, userId: number): Promise { @@ -32,16 +36,6 @@ export class UserChannelService { return this.userChannelRepository.delete(id); } - deleteByUserIdAndChannelId( - userId: number, - serverId: number, - ): DeleteQueryBuilder { - return this.userChannelRepository.deleteByUserIdAndChannelId( - userId, - serverId, - ); - } - getJoinedChannelListByUserId( serverId: number, userId: number, @@ -53,11 +47,32 @@ export class UserChannelService { ); } - getNotJoinedChannelListByUserId( + async getNotJoinedChannelListByUserId( serverId: number, userId: number, - ): Promise { - return this.userChannelRepository.getNotJoinedChannelListByUserId( + ): Promise { + const allList = await this.channelRepository.getAllList(); + const joinedList = + await this.userChannelRepository.getJoinedChannelListByUserId( + userId, + serverId, + ); + const joinedChannelList = joinedList.map( + (userChannel) => userChannel.channel.id, + ); + + const notJoinedList = allList.filter( + (channel) => !joinedChannelList.includes(channel.id), + ); + + return notJoinedList; + } + + deleteByUserIdAndChannelId( + userId: number, + serverId: number, + ): DeleteQueryBuilder { + return this.userChannelRepository.deleteByUserIdAndChannelId( userId, serverId, ); diff --git a/frontend/src/components/Main/Modal/JoinChannelModal.tsx b/frontend/src/components/Main/Modal/JoinChannelModal.tsx index cd629fb..6635284 100644 --- a/frontend/src/components/Main/Modal/JoinChannelModal.tsx +++ b/frontend/src/components/Main/Modal/JoinChannelModal.tsx @@ -200,7 +200,19 @@ function JoinChannelModal(): JSX.Element { setChannelList(list.data); }; - const onClickChannelListButton = async () => {}; + const onClickChannelListButton = async ({ currentTarget }: React.MouseEvent, id: string) => { + // const targetChannelId = currentTarget.dataset.id; + const response = await fetch('/api/user/channels', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + channelId: id, + serverId: selectedServer, + }), + }); + }; useEffect(() => { getNotJoinedChannelList(); @@ -211,12 +223,12 @@ function JoinChannelModal(): JSX.Element { }, [channelList]); const tmpList = channelList.map((val) => ( - + {val.name} {val.description} - 참여 + onClickChannelListButton(e, val.id)}>참여 )); From 806f83e224f192b9c2fe2eb46dce2b45fb50295a Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Sun, 21 Nov 2021 14:29:34 +0900 Subject: [PATCH 030/172] =?UTF-8?q?Fix=20:=20getNotJoinedChannelListByUser?= =?UTF-8?q?Id=20=EC=9D=98=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전체 Channel 의 List가 아닌 UserChannel의 List를 가져와 필터링 하도록 수정하였습니다. --- backend/src/user-channel/user-channel.controller.ts | 5 ++++- backend/src/user-channel/user-channel.repository.ts | 7 +++++++ backend/src/user-channel/user-channel.service.ts | 11 +++-------- .../src/components/Main/Modal/JoinChannelModal.tsx | 1 - 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/backend/src/user-channel/user-channel.controller.ts b/backend/src/user-channel/user-channel.controller.ts index cb382b1..74d1e56 100644 --- a/backend/src/user-channel/user-channel.controller.ts +++ b/backend/src/user-channel/user-channel.controller.ts @@ -52,7 +52,10 @@ export class UserChannelController { serverId, session.user.id, ); - return ResponseEntity.ok(response); + const notJoinedChannelList = response.map( + (userChannel) => userChannel.channel, + ); + return ResponseEntity.ok(notJoinedChannelList); } @Post() diff --git a/backend/src/user-channel/user-channel.repository.ts b/backend/src/user-channel/user-channel.repository.ts index ffb13dd..740cab5 100644 --- a/backend/src/user-channel/user-channel.repository.ts +++ b/backend/src/user-channel/user-channel.repository.ts @@ -3,6 +3,13 @@ import { UserChannel } from './user-channel.entity'; @EntityRepository(UserChannel) export class UserChannelRepository extends Repository { + getAllList(serverId: number) { + return this.createQueryBuilder('user_channel') + .leftJoinAndSelect('user_channel.channel', 'channel') + .where('user_channel.server = :serverId', { serverId: serverId }) + .getMany(); + } + getJoinedChannelListByUserId(userId: number, serverId: number) { return this.createQueryBuilder('user_channel') .leftJoinAndSelect('user_channel.channel', 'channel') diff --git a/backend/src/user-channel/user-channel.service.ts b/backend/src/user-channel/user-channel.service.ts index 2e255e3..2af6e8e 100644 --- a/backend/src/user-channel/user-channel.service.ts +++ b/backend/src/user-channel/user-channel.service.ts @@ -4,10 +4,8 @@ import { DeleteQueryBuilder, DeleteResult } from 'typeorm'; import { UserChannelRepository } from './user-channel.repository'; import { UserChannel } from './user-channel.entity'; -import { User } from 'src/user/user.entity'; import { Channel } from 'src/channel/channel.entity'; import { UserRepository } from 'src/user/user.repository'; -import { ChannelRepository } from 'src/channel/user.repository'; @Injectable() export class UserChannelService { @@ -15,12 +13,9 @@ export class UserChannelService { @InjectRepository(UserChannelRepository) private userChannelRepository: UserChannelRepository, @InjectRepository(UserRepository) private userRepository: UserRepository, - @InjectRepository(ChannelRepository) - private channelRepository: ChannelRepository, ) { this.userChannelRepository = userChannelRepository; this.userRepository = userRepository; - this.channelRepository = channelRepository; } async addNewChannel(channel: Channel, userId: number): Promise { @@ -50,8 +45,8 @@ export class UserChannelService { async getNotJoinedChannelListByUserId( serverId: number, userId: number, - ): Promise { - const allList = await this.channelRepository.getAllList(); + ): Promise { + const allList = await this.userChannelRepository.getAllList(serverId); const joinedList = await this.userChannelRepository.getJoinedChannelListByUserId( userId, @@ -62,7 +57,7 @@ export class UserChannelService { ); const notJoinedList = allList.filter( - (channel) => !joinedChannelList.includes(channel.id), + (userChannel) => !joinedChannelList.includes(userChannel.channel.id), ); return notJoinedList; diff --git a/frontend/src/components/Main/Modal/JoinChannelModal.tsx b/frontend/src/components/Main/Modal/JoinChannelModal.tsx index 6635284..22f8e69 100644 --- a/frontend/src/components/Main/Modal/JoinChannelModal.tsx +++ b/frontend/src/components/Main/Modal/JoinChannelModal.tsx @@ -201,7 +201,6 @@ function JoinChannelModal(): JSX.Element { }; const onClickChannelListButton = async ({ currentTarget }: React.MouseEvent, id: string) => { - // const targetChannelId = currentTarget.dataset.id; const response = await fetch('/api/user/channels', { method: 'POST', headers: { From cfe85c68d40332222d81a268de73ba764636310a Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Sun, 21 Nov 2021 14:37:07 +0900 Subject: [PATCH 031/172] =?UTF-8?q?Feat=20:=20Modal=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=EC=9D=84=20=EB=88=84=EB=A5=B4=EB=A9=B4=20Mod?= =?UTF-8?q?al=EC=9D=B4=20=EB=8B=AB=ED=9E=88=EB=8F=84=EB=A1=9D=20state=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Main/ChannelList.tsx | 4 ++-- .../src/components/Main/Modal/CreateChannelModal.tsx | 1 + .../src/components/Main/Modal/JoinChannelModal.tsx | 11 ++++------- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/Main/ChannelList.tsx b/frontend/src/components/Main/ChannelList.tsx index 6ae650d..43a6467 100644 --- a/frontend/src/components/Main/ChannelList.tsx +++ b/frontend/src/components/Main/ChannelList.tsx @@ -119,8 +119,8 @@ function ChannelList(): JSX.Element { } = useContext(MainStoreContext); const navigate = useNavigate(); - const onClickChannelBlock = (e: React.MouseEvent) => { - const channelId = e.currentTarget.dataset.id; + const onClickChannelBlock = ({ currentTarget }: React.MouseEvent) => { + const channelId = currentTarget.dataset.id; if (channelId) setSelectedChannel(channelId); }; diff --git a/frontend/src/components/Main/Modal/CreateChannelModal.tsx b/frontend/src/components/Main/Modal/CreateChannelModal.tsx index 0c1e590..d767341 100644 --- a/frontend/src/components/Main/Modal/CreateChannelModal.tsx +++ b/frontend/src/components/Main/Modal/CreateChannelModal.tsx @@ -191,6 +191,7 @@ function CreateChannelModal(): JSX.Element { serverId: +selectedServer, }), }); + setIsCreateModalOpen(false); }; useEffect(() => { diff --git a/frontend/src/components/Main/Modal/JoinChannelModal.tsx b/frontend/src/components/Main/Modal/JoinChannelModal.tsx index 22f8e69..a1f05a4 100644 --- a/frontend/src/components/Main/Modal/JoinChannelModal.tsx +++ b/frontend/src/components/Main/Modal/JoinChannelModal.tsx @@ -200,8 +200,8 @@ function JoinChannelModal(): JSX.Element { setChannelList(list.data); }; - const onClickChannelListButton = async ({ currentTarget }: React.MouseEvent, id: string) => { - const response = await fetch('/api/user/channels', { + const onClickChannelListButton = async (id: string) => { + await fetch('/api/user/channels', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -211,23 +211,20 @@ function JoinChannelModal(): JSX.Element { serverId: selectedServer, }), }); + setIsJoinModalOpen(false); }; useEffect(() => { getNotJoinedChannelList(); }, []); - useEffect(() => { - console.log(channelList); - }, [channelList]); - const tmpList = channelList.map((val) => ( {val.name} {val.description} - onClickChannelListButton(e, val.id)}>참여 + onClickChannelListButton(val.id)}>참여 )); From 2abd02094fee7856751ace0e2ea97803a6415eaf Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Sun, 21 Nov 2021 17:36:02 +0900 Subject: [PATCH 032/172] =?UTF-8?q?Feat=20:=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=AA=A8=EB=8B=AC=20layout=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 정보를 수정하거나 서버를 삭제할 수 있는 서버 설정 모달 layout을 구현했습니다. - ServerListTab 아이콘 layout을 일부 변경했습니다. --- .../src/components/Main/ServerListTab.tsx | 5 +- .../Main/ServerModal/ServerSettingModal.tsx | 237 ++++++++++++++++++ 2 files changed, 239 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/Main/ServerModal/ServerSettingModal.tsx diff --git a/frontend/src/components/Main/ServerListTab.tsx b/frontend/src/components/Main/ServerListTab.tsx index 0e74764..7439e6b 100644 --- a/frontend/src/components/Main/ServerListTab.tsx +++ b/frontend/src/components/Main/ServerListTab.tsx @@ -39,7 +39,6 @@ const ServerIconBox = styled.div<{ selected: boolean }>` &:hover { cursor: pointer; } - z-index: 1; `; const ServerImg = styled.div<{ imgUrl: string }>` @@ -49,7 +48,7 @@ const ServerImg = styled.div<{ imgUrl: string }>` background-size: cover; background-repeat: no-repeat; border-radius: 20px; - z-index: 0; + position: fixed; `; const ServerName = styled.div` @@ -60,7 +59,7 @@ const ServerName = styled.div` background-color: white; border-radius: 20px; text-align: center; - vertical-align: middle; + position: fixed; `; const AddServerButton = styled.div` diff --git a/frontend/src/components/Main/ServerModal/ServerSettingModal.tsx b/frontend/src/components/Main/ServerModal/ServerSettingModal.tsx new file mode 100644 index 0000000..c3e1f9f --- /dev/null +++ b/frontend/src/components/Main/ServerModal/ServerSettingModal.tsx @@ -0,0 +1,237 @@ +import React, { useContext, useState } from 'react'; +import styled from 'styled-components'; + +import { MainStoreContext } from '../MainStore'; +import { BoostCamMainIcons } from '../../../utils/SvgIcons'; + +const { Close } = BoostCamMainIcons; + +const Container = styled.div` + position: fixed; + width: 100vw; + height: 100vh; + left: 0px; + right: 0px; + + display: flex; + justify-content: space-around; + align-items: center; +`; + +const ModalBackground = styled.div` + position: fixed; + left: 0px; + right: 0px; + width: 100%; + height: 100%; + background-color: rgb(0, 0, 0, 0.5); +`; + +const ModalBox = styled.div` + width: 35%; + min-width: 400px; + + background-color: #222322; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + border-radius: 20px; + + z-index: 3; +`; + +const ModalInnerBox = styled.div` + width: 100%; + height: 100%; + padding: 30px 10px; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; +`; + +const ModalHeader = styled.div` + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +`; + +const ModalTitle = styled.span` + margin-left: 25px; + padding: 10px 5px; + + color: #cbc4b9; + font-size: 32px; + font-weight: 600; +`; + +const Form = styled.div` + width: 90%; + height: 100%; + border-radius: 20px; + margin: 0px 0px 0px 25px; + + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; +`; + +const InputDiv = styled.div` + width: 100%; + height: 100%; + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 10px; + margin-bottom: 10px; +`; + +const ImageInputDiv = styled.div` + width: 250px; + height: 100%; + display: flex; + justify-content: flex-start; + align-items: flex-start; +`; + +const InputName = styled.span` + color: #cbc4b9; + font-size: 20px; + font-weight: 500; +`; + +const Input = styled.input` + width: 250px; + border: none; + outline: none; + padding: 15px 10px; + border-radius: 10px; +`; + +const SubmitButton = styled.button<{ isButtonActive: boolean }>` + width: 80px; + height: 30px; + background: none; + + border: 0; + outline: 0; + + text-align: center; + vertical-align: middle; + + border-radius: 10px; + background-color: ${(props) => (props.isButtonActive ? '#26a9ca' : 'gray')}; + cursor: pointer; + transition: all 0.3s; + + &:hover { + background-color: ${(props) => (props.isButtonActive ? '#2dc2e6' : 'gray')}; + transition: all 0.3s; + } +`; + +const ImagePreview = styled.img` + width: 40px; + height: 40px; +`; +const MessageFailToPost = styled.span` + color: red; + font-size: 16px; + font-family: Malgun Gothic; +`; + +const ModalCloseButton = styled.div` + width: 32px; + height: 32px; + display: flex; + flex-direction: center; + align-items: center; + + cursor: pointer; + margin-right: 25px; +`; + +const CloseIcon = styled(Close)` + width: 20px; + height: 20px; + fill: #a69c96; +`; + +function ServerSettingModal(): JSX.Element { + const { setIsServerSettingModalOpen } = useContext(MainStoreContext); + const isButtonActive = true; + const [imagePreview, setImagePreview] = useState(); + + const onChangePreviewImage = (e: React.ChangeEvent & { target: HTMLInputElement }) => { + const file = e.target.files; + + if (file) { + setImagePreview(URL.createObjectURL(file[0])); + } + }; + + /* eslint-disable react/jsx-props-no-spreading */ + return ( + + setIsServerSettingModalOpen(false)} /> + + + + 서버 설정 + setIsServerSettingModalOpen(false)}> + + + + + 서버 이름 변경 + + + + 제출 + + + 서버 설명 변경 + + + + 제출 + + + 서버 아이콘 변경 + + + + + + + 제출 + + + 서버 URL 재생성 + + + + 생성 + + + + 서버 삭제 + + 서버 삭제 + + + 에러메시지 + + + + + ); +} + +export default ServerSettingModal; From 7e30075df63e527ee891f19a16bde1095cfaa71a Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Sun, 21 Nov 2021 17:37:05 +0900 Subject: [PATCH 033/172] =?UTF-8?q?Feat=20:=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EB=82=98=EA=B0=80=EA=B8=B0=20=EB=AA=A8=EB=8B=AC=20layout=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버에서 나갈 수 있는서버 나가기 모달 layout을 구현하였습니다. --- .../Main/ServerModal/QuitServerModal.tsx | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 frontend/src/components/Main/ServerModal/QuitServerModal.tsx diff --git a/frontend/src/components/Main/ServerModal/QuitServerModal.tsx b/frontend/src/components/Main/ServerModal/QuitServerModal.tsx new file mode 100644 index 0000000..61ae941 --- /dev/null +++ b/frontend/src/components/Main/ServerModal/QuitServerModal.tsx @@ -0,0 +1,198 @@ +import React, { useContext, useState } from 'react'; +import styled from 'styled-components'; + +import { MainStoreContext } from '../MainStore'; +import { BoostCamMainIcons } from '../../../utils/SvgIcons'; + +const { Close } = BoostCamMainIcons; + +const Container = styled.div` + position: fixed; + width: 100vw; + height: 100vh; + left: 0px; + right: 0px; + + display: flex; + justify-content: space-around; + align-items: center; +`; + +const ModalBackground = styled.div` + position: fixed; + left: 0px; + right: 0px; + width: 100%; + height: 100%; + background-color: rgb(0, 0, 0, 0.5); +`; + +const ModalBox = styled.div` + width: 35%; + min-width: 400px; + height: 50%; + + background-color: #222322; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + border-radius: 20px; + + z-index: 3; +`; + +const ModalInnerBox = styled.div` + width: 100%; + height: 100%; + padding: 30px 10px; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; +`; + +const ModalHeader = styled.div` + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +`; + +const ModalTitle = styled.span` + margin-left: 25px; + padding: 10px 5px; + + color: #cbc4b9; + font-size: 32px; + font-weight: 600; +`; + +const ModalDescription = styled.span` + margin-left: 25px; + padding: 10px 5px; + + color: #cbc4b9; + font-size: 15px; +`; + +const Form = styled.form` + width: 90%; + height: 70%; + border-radius: 20px; + margin: 30px 0px 0px 25px; + + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; +`; + +const SubmitButton = styled.button<{ isButtonActive: boolean }>` + width: 100px; + height: 50px; + background: none; + + padding: 15px 10px; + + border: 0; + outline: 0; + + text-align: center; + vertical-align: middle; + + border-radius: 10px; + background-color: ${(props) => (props.isButtonActive ? '#26a9ca' : 'gray')}; + cursor: pointer; + transition: all 0.3s; + + &:hover { + background-color: ${(props) => (props.isButtonActive ? '#2dc2e6' : 'gray')}; + transition: all 0.3s; + } +`; + +const MessageFailToPost = styled.span` + color: red; + font-size: 16px; + font-family: Malgun Gothic; +`; + +const ModalCloseButton = styled.div` + width: 32px; + height: 32px; + display: flex; + flex-direction: center; + align-items: center; + + cursor: pointer; + margin-right: 25px; +`; + +const CloseIcon = styled(Close)` + width: 20px; + height: 20px; + fill: #a69c96; +`; + +function QuitServerModal(): JSX.Element { + const { setIsQuitServerModalOpen, setServerList, setSelectedServer, selectedServer } = useContext(MainStoreContext); + const isButtonActive = true; + const [messageFailToPost, setMessageFailToPost] = useState(''); + + const getServerList = async (): Promise => { + const response = await fetch(`/api/user/servers`); + const list = await response.json(); + + if (response.status === 200 && list.data.length !== 0) { + setServerList(list.data); + setSelectedServer(list.data[list.data.length - 1]); + } + }; + + const onClickQuitServer = async () => { + const serverId = selectedServer.id; + const response = await fetch(`api/users/servers/${serverId}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (response.status === 201) { + getServerList(); + setIsQuitServerModalOpen(false); + } else { + const body = await response.json(); + setMessageFailToPost(body.message); + } + }; + + return ( + + setIsQuitServerModalOpen(false)} /> + + + + 서버 나가기 + setIsQuitServerModalOpen(false)}> + + + + 서버에서 나가시겠습니까? +
+ {messageFailToPost} + + 예 + +
+
+
+
+ ); +} + +export default QuitServerModal; From 2abd0129edb4897055689dfc9aadd95a41d3276b Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Sun, 21 Nov 2021 17:38:23 +0900 Subject: [PATCH 034/172] =?UTF-8?q?Feat=20:=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EB=AA=A8=EB=8B=AC=20layout=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 디테일 정보를 확인할 수 있는 서버 정보 모달 layout을 구현하였습니다. - 각 모달의 상태를 store에 저장하였습니다. - 각 모달 렌더링을 위한 dropdown을 MainHeader에 추가하였습니다. --- frontend/src/components/Main/MainHeader.tsx | 50 ++++- frontend/src/components/Main/MainPage.tsx | 17 +- frontend/src/components/Main/MainStore.tsx | 9 + .../Main/ServerModal/ServerInfoModal.tsx | 197 ++++++++++++++++++ frontend/src/components/core/Dropdown.tsx | 3 +- frontend/src/components/core/DropdownMenu.tsx | 1 + 6 files changed, 270 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/Main/ServerModal/ServerInfoModal.tsx diff --git a/frontend/src/components/Main/MainHeader.tsx b/frontend/src/components/Main/MainHeader.tsx index 1415f21..35eaec5 100644 --- a/frontend/src/components/Main/MainHeader.tsx +++ b/frontend/src/components/Main/MainHeader.tsx @@ -1,5 +1,7 @@ -import React, { useContext, useEffect } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import styled from 'styled-components'; +import Dropdown from '../core/Dropdown'; +import DropdownMenu from '../core/DropdownMenu'; import { MainStoreContext } from './MainStore'; const Container = styled.div` @@ -17,19 +19,59 @@ const HeaderBox = styled.div` const CurrentServerName = styled.span` color: #dcd6d0; font-size: 30px; - margin-left: 20px; + + &:hover { + cursor: pointer; + } `; function MainHeader(): JSX.Element { - const { selectedServer } = useContext(MainStoreContext); + const [isDropdownActivated, setIsDropdownActivated] = useState(false); + const { + selectedServer, + isServerInfoModalOpen, + isServerSettingModalOpen, + isQuitServerModalOpen, + setIsServerInfoModalOpen, + setIsServerSettingModalOpen, + setIsQuitServerModalOpen, + } = useContext(MainStoreContext); + + const onClickServerInfoButton = (e: React.MouseEvent) => { + if (selectedServer !== undefined) { + e.stopPropagation(); + setIsDropdownActivated(!isDropdownActivated); + } + }; + useEffect(() => {}, []); return ( - + {selectedServer !== undefined ? selectedServer.server.name : '새로운 서버에 참여하세요.'} + + + + + ); diff --git a/frontend/src/components/Main/MainPage.tsx b/frontend/src/components/Main/MainPage.tsx index 029890d..6df1304 100644 --- a/frontend/src/components/Main/MainPage.tsx +++ b/frontend/src/components/Main/MainPage.tsx @@ -8,6 +8,9 @@ import CreateChannelModal from './Modal/CreateChannelModal'; import JoinChannelModal from './Modal/JoinChannelModal'; import CreateServerModal from './ServerModal/CreateServerModal'; import JoinServerModal from './ServerModal/JoinServerModal'; +import ServerSettingModal from './ServerModal/ServerSettingModal'; +import ServerInfoModal from './ServerModal/ServerInfoModal'; +import QuitServerModal from './ServerModal/QuitServerModal'; const Container = styled.div` width: 100vw; @@ -20,8 +23,15 @@ const Container = styled.div` `; function MainPage(): JSX.Element { - const { isCreateModalOpen, isJoinModalOpen, isCreateServerModalOpen, isJoinServerModalOpen } = - useContext(MainStoreContext); + const { + isCreateModalOpen, + isJoinModalOpen, + isCreateServerModalOpen, + isJoinServerModalOpen, + isServerInfoModalOpen, + isServerSettingModalOpen, + isQuitServerModalOpen, + } = useContext(MainStoreContext); useEffect(() => {}, []); return ( @@ -30,6 +40,9 @@ function MainPage(): JSX.Element { {isJoinModalOpen && } {isCreateServerModalOpen && } {isJoinServerModalOpen && } + {isServerSettingModalOpen && } + {isServerInfoModalOpen && } + {isQuitServerModalOpen && } diff --git a/frontend/src/components/Main/MainStore.tsx b/frontend/src/components/Main/MainStore.tsx index 67cb511..3831667 100644 --- a/frontend/src/components/Main/MainStore.tsx +++ b/frontend/src/components/Main/MainStore.tsx @@ -15,6 +15,9 @@ function MainStore(props: MainStoreProps): JSX.Element { const [isJoinModalOpen, setIsJoinModalOpen] = useState(false); const [isCreateServerModalOpen, setIsCreateServerModalOpen] = useState(false); const [isJoinServerModalOpen, setIsJoinServerModalOpen] = useState(false); + const [isServerInfoModalOpen, setIsServerInfoModalOpen] = useState(false); + const [isServerSettingModalOpen, setIsServerSettingModalOpen] = useState(false); + const [isQuitServerModalOpen, setIsQuitServerModalOpen] = useState(false); const [serverList, setServerList] = useState([]); return ( @@ -26,6 +29,9 @@ function MainStore(props: MainStoreProps): JSX.Element { isJoinModalOpen, isCreateServerModalOpen, isJoinServerModalOpen, + isServerInfoModalOpen, + isServerSettingModalOpen, + isQuitServerModalOpen, serverList, setSelectedServer, setSelectedChannel, @@ -33,6 +39,9 @@ function MainStore(props: MainStoreProps): JSX.Element { setIsJoinModalOpen, setIsCreateServerModalOpen, setIsJoinServerModalOpen, + setIsServerInfoModalOpen, + setIsServerSettingModalOpen, + setIsQuitServerModalOpen, setServerList, }} > diff --git a/frontend/src/components/Main/ServerModal/ServerInfoModal.tsx b/frontend/src/components/Main/ServerModal/ServerInfoModal.tsx new file mode 100644 index 0000000..e0c47d2 --- /dev/null +++ b/frontend/src/components/Main/ServerModal/ServerInfoModal.tsx @@ -0,0 +1,197 @@ +import React, { useContext } from 'react'; +import styled from 'styled-components'; + +import { MainStoreContext } from '../MainStore'; +import { BoostCamMainIcons } from '../../../utils/SvgIcons'; + +const { Close } = BoostCamMainIcons; + +const Container = styled.div` + position: fixed; + width: 100vw; + height: 100vh; + left: 0px; + right: 0px; + + display: flex; + justify-content: space-around; + align-items: center; +`; + +const ModalBackground = styled.div` + position: fixed; + left: 0px; + right: 0px; + width: 100%; + height: 100%; + background-color: rgb(0, 0, 0, 0.5); +`; + +const ModalBox = styled.div` + width: 35%; + min-width: 400px; + + background-color: #222322; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + border-radius: 20px; + + z-index: 3; +`; + +const ModalInnerBox = styled.div` + width: 100%; + height: 100%; + padding: 30px 10px; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; +`; + +const ModalHeader = styled.div` + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +`; + +const ModalTitle = styled.span` + margin-left: 25px; + padding: 10px 5px; + + color: #cbc4b9; + font-size: 32px; + font-weight: 600; +`; + +const ModalCloseButton = styled.div` + width: 32px; + height: 32px; + display: flex; + flex-direction: center; + align-items: center; + + cursor: pointer; + margin-right: 25px; +`; + +const CloseIcon = styled(Close)` + width: 20px; + height: 20px; + fill: #a69c96; +`; + +const ServerTitleBox = styled.div` + margin-left: 25px; + padding: 10px 5px; + width: 350px; + display: flex; +`; + +const InformationBox = styled.div` + margin-left: 25px; + padding: 10px 5px; + width: 350px; + display: flex; + flex-direction: column; + justify-content: end; + item-align: start; + color: #cbc4b9; + font-size: 20px; + font-weight: 600; +`; + +const SubTitle = styled.div` + color: #cbc4b9; + font-size: 20px; + font-weight: 600; + width: 100%; +`; + +const ServerIcon = styled.img` + width: 40px; + height: 40px; + margin-right: 10px; +`; + +const InfoParagraph = styled.pre` + background-color: #cbc4b9; + border-radius: 10px; + color: black; + padding: 0px 10px; + max-height: 80px; + overflow-y: auto; + margin: 0px; + + &::-webkit-scrollbar { + width: 10px; + } + &::-webkit-scrollbar-thumb { + background-color: #999999; + border-radius: 10px; + } + &::-webkit-scrollbar-track { + background-color: #cccccc; + border-radius: 10px; + } +`; + +const InfoSpan = styled.span` + background-color: #cbc4b9; + border-radius: 10px; + padding: 0px 10px; + max-height: 40px; + overflow-y: auto; + color: black; + font-size: 20px; + font-weight: 600; + + &::-webkit-scrollbar { + display: none; + } +`; + +function ServerInfoModal(): JSX.Element { + const { setIsServerInfoModalOpen } = useContext(MainStoreContext); + + const a = `fafeaf\nfeafa\nfeafafeafa\nfeafa\nfeafafeaf\nfeafa\nfeafa\nfeafa\nfeaa\nfeafa`; + /* eslint-disable react/jsx-props-no-spreading */ + return ( + + setIsServerInfoModalOpen(false)} /> + + + + 서버 정보 + setIsServerInfoModalOpen(false)}> + + + + + + {a} + + + {a} + + + 서버 참가 URL + {a} + + + 서버 사용자 리스트 + {a} + + + + + ); +} + +export default ServerInfoModal; diff --git a/frontend/src/components/core/Dropdown.tsx b/frontend/src/components/core/Dropdown.tsx index 30efa37..e393a28 100644 --- a/frontend/src/components/core/Dropdown.tsx +++ b/frontend/src/components/core/Dropdown.tsx @@ -25,7 +25,8 @@ const InnerContainer = styled.div` background-color: white; border-radius: 8px; position: relative; - width: 80px; + width: 90px; + text-align: center; box-shadow: 0 1px 8px rgba(0, 0, 0, 0.3); z-index: 99; `; diff --git a/frontend/src/components/core/DropdownMenu.tsx b/frontend/src/components/core/DropdownMenu.tsx index bf83f4b..39fb518 100644 --- a/frontend/src/components/core/DropdownMenu.tsx +++ b/frontend/src/components/core/DropdownMenu.tsx @@ -9,6 +9,7 @@ const Container = styled.li` } &:hover { cursor: pointer; + font-weight: bold; } `; From f149c7f07b5c5143a6fdf17d6c524d411ecad9d1 Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Sun, 21 Nov 2021 18:34:01 +0900 Subject: [PATCH 035/172] =?UTF-8?q?Feat=20:=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EB=82=98=EA=B0=80=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 참여한 서버를 제거하는 api를 frontend와 연결하고 제거 후 서버목록 처리를 추가하였습니다. --- backend/src/common/response-entity.ts | 3 +++ backend/src/user-server/user-server.controller.ts | 9 ++++++++- .../Main/ServerModal/QuitServerModal.tsx | 15 +++++++++------ 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/backend/src/common/response-entity.ts b/backend/src/common/response-entity.ts index b1c711a..963c646 100644 --- a/backend/src/common/response-entity.ts +++ b/backend/src/common/response-entity.ts @@ -15,6 +15,9 @@ class ResponseEntity { static created(id: number): ResponseEntity { return new ResponseEntity(201, null, id); } + static noContent(): ResponseEntity { + return new ResponseEntity(204, null, null); + } } export default ResponseEntity; diff --git a/backend/src/user-server/user-server.controller.ts b/backend/src/user-server/user-server.controller.ts index 972a234..31e4ac9 100644 --- a/backend/src/user-server/user-server.controller.ts +++ b/backend/src/user-server/user-server.controller.ts @@ -7,6 +7,7 @@ import { Session, UseGuards, HttpException, + HttpCode, } from '@nestjs/common'; import { Server } from '../server/server.entity'; import { LoginGuard } from '../login/login.guard'; @@ -38,7 +39,13 @@ export class UserServerController { } @Delete('/:id') + @HttpCode(204) delete(@Param('id') id: number) { - return this.userServerService.deleteById(id); + try { + this.userServerService.deleteById(id); + return ResponseEntity.noContent(); + } catch (error) { + throw new HttpException(error.response, 403); + } } } diff --git a/frontend/src/components/Main/ServerModal/QuitServerModal.tsx b/frontend/src/components/Main/ServerModal/QuitServerModal.tsx index 61ae941..d92b398 100644 --- a/frontend/src/components/Main/ServerModal/QuitServerModal.tsx +++ b/frontend/src/components/Main/ServerModal/QuitServerModal.tsx @@ -147,22 +147,25 @@ function QuitServerModal(): JSX.Element { const response = await fetch(`/api/user/servers`); const list = await response.json(); - if (response.status === 200 && list.data.length !== 0) { + if (response.status === 200) { setServerList(list.data); - setSelectedServer(list.data[list.data.length - 1]); + if (list.data.length !== 0) { + setSelectedServer(list.data[0]); + } else { + setSelectedServer(undefined); + } } }; const onClickQuitServer = async () => { - const serverId = selectedServer.id; - const response = await fetch(`api/users/servers/${serverId}`, { + const userServerId = selectedServer.id; + const response = await fetch(`api/users/servers/${userServerId}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', }, }); - - if (response.status === 201) { + if (response.status === 204) { getServerList(); setIsQuitServerModalOpen(false); } else { From 9cb6c96928378632f9db663e2ff4cb307732487e Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Sun, 21 Nov 2021 22:06:48 +0900 Subject: [PATCH 036/172] =?UTF-8?q?Refactor=20:=20channelList=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EB=A5=BC=20MainStore=20=EC=BB=A8=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Main/ChannelList.tsx | 28 ++++++++++--------- frontend/src/components/Main/MainStore.tsx | 18 +++++++++++- .../Main/Modal/CreateChannelModal.tsx | 3 +- .../Main/Modal/JoinChannelModal.tsx | 3 +- 4 files changed, 36 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/Main/ChannelList.tsx b/frontend/src/components/Main/ChannelList.tsx index 43a6467..66ab6b1 100644 --- a/frontend/src/components/Main/ChannelList.tsx +++ b/frontend/src/components/Main/ChannelList.tsx @@ -60,7 +60,7 @@ const ChannelListBody = styled.div` font-size: 15px; `; -const ChannelNameBlock = styled.div<{ selected: boolean }>` +const ChannelListItem = styled.div<{ selected: boolean }>` width: 100%; height: 25px; @@ -104,13 +104,13 @@ const HashIcon = styled(Hash)` `; function ChannelList(): JSX.Element { - const [channelList, setChannelList] = useState([]); const [isButtonVisible, setIsButtonVisible] = useState(false); const [isDropdownActivated, setIsDropdownActivated] = useState(false); const [isListOpen, setIsListOpen] = useState(false); const { selectedServer, selectedChannel, + serverChannelList, isCreateModalOpen, isJoinModalOpen, setSelectedChannel, @@ -129,16 +129,12 @@ function ChannelList(): JSX.Element { setIsDropdownActivated(!isDropdownActivated); }; - const getChannelList = async (): Promise => { - const response = await fetch(`/api/user/channels/joined/${selectedServer}`); - const list = await response.json(); - setChannelList(list.data); + const onRightClickChannelItem = (e: React.MouseEvent) => { + e.preventDefault(); + const { currentTarget } = e; + console.log(`rightClick ${currentTarget.dataset.id}`); }; - useEffect(() => { - getChannelList(); - }, []); - useEffect(() => { navigate({ search: `?${createSearchParams({ @@ -148,13 +144,19 @@ function ChannelList(): JSX.Element { }); }, [selectedChannel]); - const listElements = channelList.map((val: ChannelData): JSX.Element => { + const listElements = serverChannelList.map((val: ChannelData): JSX.Element => { const selected = val.id === selectedChannel; return ( - + {val.name} - + ); }); diff --git a/frontend/src/components/Main/MainStore.tsx b/frontend/src/components/Main/MainStore.tsx index 15b51db..c66d1be 100644 --- a/frontend/src/components/Main/MainStore.tsx +++ b/frontend/src/components/Main/MainStore.tsx @@ -1,4 +1,6 @@ -import { createContext, useState } from 'react'; +import { createContext, useEffect, useState } from 'react'; + +import { ChannelData } from '../../types/main'; export const MainStoreContext = createContext(null); @@ -10,9 +12,20 @@ function MainStore(props: MainStoreProps): JSX.Element { const { children } = props; const [selectedServer, setSelectedServer] = useState('1'); const [selectedChannel, setSelectedChannel] = useState('1'); + const [serverChannelList, setServerChannelList] = useState([]); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isJoinModalOpen, setIsJoinModalOpen] = useState(false); + const getServerChannelList = async (): Promise => { + const response = await fetch(`/api/user/channels/joined/${selectedServer}`); + const list = await response.json(); + setServerChannelList(list.data); + }; + + useEffect(() => { + if (selectedServer) getServerChannelList(); + }, [selectedServer]); + return ( {children} diff --git a/frontend/src/components/Main/Modal/CreateChannelModal.tsx b/frontend/src/components/Main/Modal/CreateChannelModal.tsx index d767341..5768886 100644 --- a/frontend/src/components/Main/Modal/CreateChannelModal.tsx +++ b/frontend/src/components/Main/Modal/CreateChannelModal.tsx @@ -175,7 +175,7 @@ function CreateChannelModal(): JSX.Element { watch, formState: { errors }, } = useForm(); - const { selectedServer, setIsCreateModalOpen } = useContext(MainStoreContext); + const { selectedServer, setIsCreateModalOpen, getServerChannelList } = useContext(MainStoreContext); const [isButtonActive, setIsButtonActive] = useState(false); const onSubmitCreateChannelModal = async (data: { name: string; description: string }) => { @@ -191,6 +191,7 @@ function CreateChannelModal(): JSX.Element { serverId: +selectedServer, }), }); + getServerChannelList(); setIsCreateModalOpen(false); }; diff --git a/frontend/src/components/Main/Modal/JoinChannelModal.tsx b/frontend/src/components/Main/Modal/JoinChannelModal.tsx index a1f05a4..da9d8c5 100644 --- a/frontend/src/components/Main/Modal/JoinChannelModal.tsx +++ b/frontend/src/components/Main/Modal/JoinChannelModal.tsx @@ -191,7 +191,7 @@ const CloseIcon = styled(Close)` `; function JoinChannelModal(): JSX.Element { - const { selectedServer, setIsJoinModalOpen } = useContext(MainStoreContext); + const { selectedServer, setIsJoinModalOpen, getServerChannelList } = useContext(MainStoreContext); const [channelList, setChannelList] = useState([]); const getNotJoinedChannelList = async () => { @@ -211,6 +211,7 @@ function JoinChannelModal(): JSX.Element { serverId: selectedServer, }), }); + getServerChannelList(); setIsJoinModalOpen(false); }; From 6758c6da7bd4e96b730c9ac1316cf11524fa5908 Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Sun, 21 Nov 2021 22:59:07 +0900 Subject: [PATCH 037/172] =?UTF-8?q?Feat=20:=20=EC=B0=B8=EC=97=AC=ED=95=9C?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=EC=9E=90=EC=99=80=20=ED=95=A8=EA=BB=98=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EB=B6=88?= =?UTF-8?q?=EB=9F=AC=EC=98=A4=EB=8A=94=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 해당 서버에 참여한 사용자 정보와 함께 서버 정보를 불러오는 api를 구현하였습니다. - query함수의 사용으로 service와 repository역할을 분리하기 위해 repository파일을 생성하였습니다. --- backend/src/server/server.controller.ts | 7 +++++++ backend/src/server/server.entity.ts | 5 +++++ backend/src/server/server.module.ts | 3 ++- backend/src/server/server.repository.ts | 13 +++++++++++++ backend/src/server/server.service.ts | 9 +++++++-- 5 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 backend/src/server/server.repository.ts diff --git a/backend/src/server/server.controller.ts b/backend/src/server/server.controller.ts index 41fe1bb..319b0bf 100644 --- a/backend/src/server/server.controller.ts +++ b/backend/src/server/server.controller.ts @@ -47,6 +47,13 @@ export class ServerController { }); } + @Get('/:id/users') async findOneWithUsers( + @Param('id') id: number, + ): Promise> { + const serverWithUsers = await this.serverService.findOneWithUsers(id); + return ResponseEntity.ok(serverWithUsers); + } + @Post() @UseGuards(LoginGuard) @UseInterceptors(FileInterceptor('icon')) diff --git a/backend/src/server/server.entity.ts b/backend/src/server/server.entity.ts index 45624a6..4dc08dd 100644 --- a/backend/src/server/server.entity.ts +++ b/backend/src/server/server.entity.ts @@ -1,9 +1,11 @@ +import { UserServer } from 'src/user-server/user-server.entity'; import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn, + OneToMany, } from 'typeorm'; import { User } from '../user/user.entity'; @@ -24,4 +26,7 @@ export class Server { @ManyToOne(() => User) @JoinColumn({ referencedColumnName: 'id' }) owner: User; + + @OneToMany(() => UserServer, (userServer) => userServer.server) + userServer: UserServer[]; } diff --git a/backend/src/server/server.module.ts b/backend/src/server/server.module.ts index 4b48291..dca02d3 100644 --- a/backend/src/server/server.module.ts +++ b/backend/src/server/server.module.ts @@ -7,12 +7,13 @@ import { ServerService } from './server.service'; import { ServerController } from './server.controller'; import { UserServerModule } from '../user-server/user-server.module'; import { ImageModule } from '../image/image.module'; +import { ServerRepository } from './server.repository'; @Module({ imports: [ ImageModule, forwardRef(() => UserServerModule), - TypeOrmModule.forFeature([User, Server]), + TypeOrmModule.forFeature([User, Server, ServerRepository]), ], providers: [ServerService], controllers: [ServerController], diff --git a/backend/src/server/server.repository.ts b/backend/src/server/server.repository.ts new file mode 100644 index 0000000..3dad626 --- /dev/null +++ b/backend/src/server/server.repository.ts @@ -0,0 +1,13 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { Server } from './server.entity'; + +@EntityRepository(Server) +export class ServerRepository extends Repository { + findOneWithUsers(serverId: number) { + return this.createQueryBuilder('server') + .leftJoinAndSelect('server.userServer', 'user_server') + .leftJoinAndSelect('user_server.user', 'user') + .where('server.id = :serverId', { serverId: serverId }) + .getOne(); + } +} diff --git a/backend/src/server/server.service.ts b/backend/src/server/server.service.ts index 673522f..3981ed6 100644 --- a/backend/src/server/server.service.ts +++ b/backend/src/server/server.service.ts @@ -1,18 +1,19 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm/index'; import { User } from '../user/user.entity'; import { Server } from './server.entity'; import RequestServerDto from './dto/RequestServerDto'; import { UserServerService } from '../user-server/user-server.service'; +import { ServerRepository } from './server.repository'; @Injectable() export class ServerService { constructor( @Inject(forwardRef(() => UserServerService)) private readonly userServerService: UserServerService, - @InjectRepository(Server) private serverRepository: Repository, + @InjectRepository(ServerRepository) + private serverRepository: ServerRepository, ) { this.serverRepository = serverRepository; } @@ -49,4 +50,8 @@ export class ServerService { async deleteServer(id: number): Promise { await this.serverRepository.delete({ id: id }); } + + findOneWithUsers(serverId: number): Promise { + return this.serverRepository.findOneWithUsers(serverId); + } } From adf3c2cd7d196cf66a07795295ed1c144ff7354f Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Sun, 21 Nov 2021 23:00:04 +0900 Subject: [PATCH 038/172] =?UTF-8?q?Feat=20:=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EA=B0=80=EC=A0=B8=EC=98=A4?= =?UTF-8?q?=EB=8A=94=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버정보를 가져오는 api호출 후 frontend에 나타내도록 ServerInfoModal을 변경하였습니다. --- .../Main/ServerModal/ServerInfoModal.tsx | 77 ++++++++++++++++--- 1 file changed, 65 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/Main/ServerModal/ServerInfoModal.tsx b/frontend/src/components/Main/ServerModal/ServerInfoModal.tsx index e0c47d2..f9cb9cd 100644 --- a/frontend/src/components/Main/ServerModal/ServerInfoModal.tsx +++ b/frontend/src/components/Main/ServerModal/ServerInfoModal.tsx @@ -1,4 +1,4 @@ -import React, { useContext } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import styled from 'styled-components'; import { MainStoreContext } from '../MainStore'; @@ -118,6 +118,19 @@ const ServerIcon = styled.img` width: 40px; height: 40px; margin-right: 10px; + border-radius: 5px; +`; + +const ServerName = styled.div` + width: 40px; + height: 40px; + min-width: 40px; + margin-right: 10px; + font-size: 30px; + font-weight: bold; + background-color: white; + border-radius: 5px; + text-align: center; `; const InfoParagraph = styled.pre` @@ -125,7 +138,7 @@ const InfoParagraph = styled.pre` border-radius: 10px; color: black; padding: 0px 10px; - max-height: 80px; + height: 80px; overflow-y: auto; margin: 0px; @@ -146,21 +159,53 @@ const InfoSpan = styled.span` background-color: #cbc4b9; border-radius: 10px; padding: 0px 10px; - max-height: 40px; + height: 40px; overflow-y: auto; color: black; font-size: 20px; font-weight: 600; + width: 330px; &::-webkit-scrollbar { display: none; } `; - +type User = { + id: number; + githubId: number; + nickname: string; + profile: string; +}; +type UserServer = { + id: number; + user: User; +}; function ServerInfoModal(): JSX.Element { - const { setIsServerInfoModalOpen } = useContext(MainStoreContext); - - const a = `fafeaf\nfeafa\nfeafafeafa\nfeafa\nfeafafeaf\nfeafa\nfeafa\nfeafa\nfeaa\nfeafa`; + const { setIsServerInfoModalOpen, selectedServer } = useContext(MainStoreContext); + const [joinedUserList, setJoinedUserList] = useState(); + const [serverDescription, setServerDescription] = useState(); + const [serverName, setServerName] = useState(''); + const [serverIconUrl, setServerIconUrl] = useState(); + + const getServerInfo = async () => { + const serverId = selectedServer.server.id; + const response = await fetch(`/api/servers/${serverId}/users`); + const serverInfo = await response.json(); + + if (response.status === 200) { + const { name, description, userServer, imgUrl } = serverInfo.data; + + setJoinedUserList(userServer); + setServerDescription(description); + setServerName(name); + if (imgUrl) { + setServerIconUrl(imgUrl); + } + } + }; + useEffect(() => { + getServerInfo(); + }, []); /* eslint-disable react/jsx-props-no-spreading */ return ( @@ -174,19 +219,27 @@ function ServerInfoModal(): JSX.Element { - - {a} + {serverIconUrl ? : {serverName[0]}} + {serverName} - {a} + {serverDescription} 서버 참가 URL - {a} + 서버 참가 url 서버 사용자 리스트 - {a} + + {joinedUserList + ?.map((joinedUser) => { + const { user } = joinedUser; + const { nickname } = user; + return nickname; + }) + .join('\n')} + From 2d818bbb7135946d34325ddbadfb1c63ecb40485 Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Sun, 21 Nov 2021 23:13:31 +0900 Subject: [PATCH 039/172] =?UTF-8?q?Test=20:=20ServerRepository=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=EC=97=90=20=EB=94=B0=EB=A5=B8=20test=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ServerRepository를 분리하였기 때문에 ServerRepository를 mocking 하였습니다. --- backend/src/server/server.entity.ts | 2 +- backend/src/user-server/user-server.service.spec.ts | 13 +++++++------ backend/src/user/user.controller.spec.ts | 8 +++++--- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/backend/src/server/server.entity.ts b/backend/src/server/server.entity.ts index 4dc08dd..20930e2 100644 --- a/backend/src/server/server.entity.ts +++ b/backend/src/server/server.entity.ts @@ -1,4 +1,4 @@ -import { UserServer } from 'src/user-server/user-server.entity'; +import { UserServer } from '../user-server/user-server.entity'; import { Entity, Column, diff --git a/backend/src/user-server/user-server.service.spec.ts b/backend/src/user-server/user-server.service.spec.ts index af08479..89294a1 100644 --- a/backend/src/user-server/user-server.service.spec.ts +++ b/backend/src/user-server/user-server.service.spec.ts @@ -5,8 +5,9 @@ import { Server } from '../server/server.entity'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { UserServerRepository } from './user-server.repository'; -import { DeleteResult, Repository } from 'typeorm'; +import { DeleteResult } from 'typeorm'; import { ServerService } from '../server/server.service'; +import { ServerRepository } from '../server/server.repository'; const mockUserServerRepository = () => ({ save: jest.fn(), @@ -23,12 +24,12 @@ const mockServerRepository = () => ({ type MockUserServerRepository = Partial< Record >; -type MockRepository = Partial, jest.Mock>>; +type MockServerRepository = Partial>; describe('UserServerService', () => { let service: UserServerService; let userServerRepository: MockUserServerRepository; - let serverRepository: MockRepository; + let serverRepository: MockServerRepository; let userServer: UserServer; let existUserServer: UserServer; @@ -42,7 +43,7 @@ describe('UserServerService', () => { }, ServerService, { - provide: getRepositoryToken(Server), + provide: getRepositoryToken(ServerRepository), useValue: mockServerRepository(), }, ], @@ -52,8 +53,8 @@ describe('UserServerService', () => { userServerRepository = module.get( getRepositoryToken(UserServerRepository), ); - serverRepository = module.get>( - getRepositoryToken(Server), + serverRepository = module.get( + getRepositoryToken(ServerRepository), ); userServer = new UserServer(); diff --git a/backend/src/user/user.controller.spec.ts b/backend/src/user/user.controller.spec.ts index 38643f7..8c5ea65 100644 --- a/backend/src/user/user.controller.spec.ts +++ b/backend/src/user/user.controller.spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { Server } from '../server/server.entity'; +import { ServerRepository } from '../server/server.repository'; import { ServerService } from '../server/server.service'; import { UserServerRepository } from '../user-server/user-server.repository'; import { UserServerService } from '../user-server/user-server.service'; @@ -11,7 +11,9 @@ const mockUserServerRepository = () => ({ delete: jest.fn(), deleteByUserIdAndServerId: jest.fn(), }); -const mockServerRepository = () => ({}); +const mockServerRepository = () => ({ + findOne: jest.fn(), +}); describe('UserController', () => { let controller: UserController; @@ -26,7 +28,7 @@ describe('UserController', () => { }, ServerService, { - provide: getRepositoryToken(Server), + provide: getRepositoryToken(ServerRepository), useValue: mockServerRepository(), }, ], From d8758280b6c2a2b86b7cacc53bda0c4a16bb74b4 Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Mon, 22 Nov 2021 00:12:55 +0900 Subject: [PATCH 040/172] =?UTF-8?q?Test=20:=20server=20service=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server service의 테스트코드를 추가하였습니다. - user-server service 테스트코드의 변수초기화 함수를 분리하였습니다. --- backend/src/server/server.service.spec.ts | 117 ++++++++++++++++++ .../user-server/user-server.service.spec.ts | 59 +++++---- 2 files changed, 151 insertions(+), 25 deletions(-) create mode 100644 backend/src/server/server.service.spec.ts diff --git a/backend/src/server/server.service.spec.ts b/backend/src/server/server.service.spec.ts new file mode 100644 index 0000000..110da0a --- /dev/null +++ b/backend/src/server/server.service.spec.ts @@ -0,0 +1,117 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { UserServer } from '../user-server/user-server.entity'; +import { UserServerRepository } from '../user-server/user-server.repository'; +import { UserServerService } from '../user-server/user-server.service'; +import { User } from '../user/user.entity'; +import RequestServerDto from './dto/RequestServerDto'; +import { Server } from './server.entity'; +import { ServerRepository } from './server.repository'; +import { ServerService } from './server.service'; + +const mockUserServerRepository = () => ({ + save: jest.fn(), + delete: jest.fn(), + findByUserIdAndServerId: jest.fn(), + deleteByUserIdAndServerId: jest.fn(), + getServerListByUserId: jest.fn(), +}); + +const mockServerRepository = () => ({ + save: jest.fn(), + findOne: jest.fn(), + findOneWithUsers: jest.fn(), +}); + +type MockUserServerRepository = Partial< + Record +>; +type MockServerRepository = Partial>; + +describe('ServerService', () => { + let serverService: ServerService; + let serverRepository: MockServerRepository; + let userServerRepository: MockUserServerRepository; + let user: User; + let requestServerDto: RequestServerDto; + let newServer: Server; + let newUserServer: UserServer; + let existServer: Server; + + const existsServerId = 1; + const userId = 1; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ServerService, + { + provide: getRepositoryToken(ServerRepository), + useValue: mockServerRepository(), + }, + UserServerService, + { + provide: getRepositoryToken(UserServerRepository), + useValue: mockUserServerRepository(), + }, + ], + }).compile(); + + serverService = module.get(ServerService); + serverRepository = module.get( + getRepositoryToken(ServerRepository), + ); + userServerRepository = module.get( + getRepositoryToken(UserServerRepository), + ); + + refreshVariables(); + }); + + describe('create()', () => { + it('정상적인 값을 저장할 경우', async () => { + serverRepository.save.mockResolvedValue(newServer); + serverRepository.findOne.mockResolvedValue(newServer); + userServerRepository.findByUserIdAndServerId.mockResolvedValue(undefined); + userServerRepository.save.mockResolvedValue(newUserServer); + + const createdServer = await serverService.create( + user, + requestServerDto, + '', + ); + + expect(createdServer.name).toBe(requestServerDto.name); + expect(createdServer.description).toBe(requestServerDto.description); + expect(createdServer.owner.id).toBe(user.id); + }); + }); + + describe('findOneWithUsers()', () => { + it('정상적인 값을 입력할 경우', async () => { + serverRepository.findOneWithUsers.mockResolvedValue(existServer); + + const serverWithUseres = await serverService.findOneWithUsers( + existsServerId, + ); + + expect(serverWithUseres).toBe(existServer); + }); + }); + + const refreshVariables = () => { + const serverName = 'server name'; + const serverDescription = 'server description'; + + user = new User(); + user.id = userId; + requestServerDto = new RequestServerDto(serverName, serverDescription); + newServer = new Server(); + newServer.description = serverDescription; + newServer.name = serverName; + newServer.owner = user; + + existServer = new Server(); + existServer.id = existsServerId; + }; +}); diff --git a/backend/src/user-server/user-server.service.spec.ts b/backend/src/user-server/user-server.service.spec.ts index 89294a1..ddd612a 100644 --- a/backend/src/user-server/user-server.service.spec.ts +++ b/backend/src/user-server/user-server.service.spec.ts @@ -32,6 +32,12 @@ describe('UserServerService', () => { let serverRepository: MockServerRepository; let userServer: UserServer; let existUserServer: UserServer; + let user: User; + let server: Server; + + const userId = 1; + const serverId = 1; + const existUserServerId = 1; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -56,40 +62,28 @@ describe('UserServerService', () => { serverRepository = module.get( getRepositoryToken(ServerRepository), ); - - userServer = new UserServer(); - userServer.user = new User(); - userServer.server = new Server(); - - existUserServer = new UserServer(); - existUserServer.id = 1; - existUserServer.user = new User(); - existUserServer.user.id = 1; - existUserServer.server = new Server(); - existUserServer.server.id = 1; + refreshVariables(); }); describe('create()', () => { it('정상적인 값을 저장할 경우', async () => { userServerRepository.save.mockResolvedValue(userServer); - serverRepository.findOne.mockResolvedValue(existUserServer.server); + serverRepository.findOne.mockResolvedValue(server); userServerRepository.findByUserIdAndServerId.mockResolvedValue(undefined); - const newUserServer = await service.create( - existUserServer.user, - existUserServer.server.id, - ); + const newUserServer = await service.create(user, serverId); - expect(newUserServer.user).toBe(userServer.user); - expect(newUserServer.server).toBe(userServer.server); + expect(newUserServer.user).toBe(user); + expect(newUserServer.server).toBe(server); }); it('해당 서버가 존재하지 않는 경우', async () => { + const nonExistsId = 0; userServerRepository.save.mockResolvedValue(userServer); serverRepository.findOne.mockResolvedValue(undefined); try { - await service.create(existUserServer.user, 2); + await service.create(user, nonExistsId); } catch (error) { expect(error.response).toBe('해당 서버가 존재하지 않습니다.'); } @@ -97,13 +91,13 @@ describe('UserServerService', () => { it('이미 추가된 서버인 경우', async () => { userServerRepository.save.mockResolvedValue(userServer); - serverRepository.findOne.mockResolvedValue(existUserServer.server); + serverRepository.findOne.mockResolvedValue(server); userServerRepository.findByUserIdAndServerId.mockResolvedValue( existUserServer, ); try { - await service.create(existUserServer.user, existUserServer.server.id); + await service.create(user, serverId); } catch (error) { expect(error.response).toBe('이미 등록된 서버입니다.'); } @@ -112,7 +106,7 @@ describe('UserServerService', () => { describe('deleteById()', () => { it('존재하는 id로 삭제할 경우', async () => { - const existsId = existUserServer.id; + const existsId = existUserServerId; const returnedDeleteResult = new DeleteResult(); returnedDeleteResult.affected = existsId == existUserServer.id ? 1 : 0; userServerRepository.delete.mockResolvedValue(returnedDeleteResult); @@ -141,11 +135,26 @@ describe('UserServerService', () => { existUserServer, ]); - const userServerList = await service.getServerListByUserId( - existUserServer.user.id, - ); + const userServerList = await service.getServerListByUserId(userId); expect(userServerList[0]).toBe(existUserServer); }); }); + + const refreshVariables = () => { + user = new User(); + user.id = userId; + + server = new Server(); + server.id = serverId; + + userServer = new UserServer(); + userServer.user = user; + userServer.server = server; + + existUserServer = new UserServer(); + existUserServer.id = existUserServerId; + existUserServer.user = user; + existUserServer.server = server; + }; }); From 966543f2a04457832bc61b9372cdd82ef0e861fe Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Mon, 22 Nov 2021 00:39:57 +0900 Subject: [PATCH 041/172] =?UTF-8?q?Fix=20:=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Main/ChannelList.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/components/Main/ChannelList.tsx b/frontend/src/components/Main/ChannelList.tsx index 66ab6b1..7987b48 100644 --- a/frontend/src/components/Main/ChannelList.tsx +++ b/frontend/src/components/Main/ChannelList.tsx @@ -131,8 +131,6 @@ function ChannelList(): JSX.Element { const onRightClickChannelItem = (e: React.MouseEvent) => { e.preventDefault(); - const { currentTarget } = e; - console.log(`rightClick ${currentTarget.dataset.id}`); }; useEffect(() => { From bde9c4244e743d3c41a11ff6b5a844712d2133b1 Mon Sep 17 00:00:00 2001 From: Korung <76931330+korung3195@users.noreply.github.com> Date: Mon, 22 Nov 2021 10:06:03 +0900 Subject: [PATCH 042/172] Update README.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로고 배경 수정 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 56ea8c5..43c706c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ## boostCam - 화상채팅을 지원하는 웹 메신저 서비스
- +
## 소개 From 2148be8438bc07fdb3a82cea3b8d2986c3d47db3 Mon Sep 17 00:00:00 2001 From: K Date: Mon, 22 Nov 2021 15:02:29 +0900 Subject: [PATCH 043/172] =?UTF-8?q?Chore=20:=20ncloud=20=EB=B0=B0=ED=8F=AC?= =?UTF-8?q?=20=EC=8B=9C=20=EC=82=AC=EC=9A=A9=ED=95=A0=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=20=EB=B3=80=EC=88=98=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cicd.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index ce7541f..ce2b9f0 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -59,6 +59,10 @@ jobs: ^DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }} ^DATABASE_NAME: ${{ secrets.DATABASE_NAME }} ^DATABASE_HOST: ${{ secrets.DATABASE_HOST }} + ^NCP_STORAGE_ACCESS_KEY: ${{ secrets.NCP_STORAGE_ACCESS_KEY }} + ^NCP_STORAGE_SECRET_KEY: ${{ secrets.NCP_STORAGE_SECRET_KEY }} + ^NCP_STORAGE_BUCKET_NAME: ${{ secrets.NCP_STORAGE_BUCKET_NAME }} + ^NCP_STORAGE_REGION: ${{ secrets.NCP_STORAGE_REGION }} - name: Create .env.github uses: weyheyhey/create-dotenv-action@v1 with: @@ -138,6 +142,11 @@ jobs: ^DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }} ^DATABASE_NAME: ${{ secrets.DATABASE_NAME }} ^DATABASE_HOST: ${{ secrets.DATABASE_HOST }} + ^NCP_STORAGE_ACCESS_KEY: ${{ secrets.NCP_STORAGE_ACCESS_KEY }} + ^NCP_STORAGE_SECRET_KEY: ${{ secrets.NCP_STORAGE_SECRET_KEY }} + ^NCP_STORAGE_BUCKET_NAME: ${{ secrets.NCP_STORAGE_BUCKET_NAME }} + ^NCP_STORAGE_REGION: ${{ secrets.NCP_STORAGE_REGION }} + - name: Create .env.github uses: weyheyhey/create-dotenv-action@v1 with: From 52abd92bf70bfe29e530a453640d076eacc834d9 Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Mon, 22 Nov 2021 15:40:16 +0900 Subject: [PATCH 044/172] =?UTF-8?q?Fix=20:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EB=A1=9C=EA=B7=B8=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/channel/channel.controller.ts | 1 - backend/src/user-channel/user-channel.service.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/backend/src/channel/channel.controller.ts b/backend/src/channel/channel.controller.ts index 3b1b0a4..b5f1434 100644 --- a/backend/src/channel/channel.controller.ts +++ b/backend/src/channel/channel.controller.ts @@ -30,7 +30,6 @@ export class ChannelController { } @Get() async findAll(): Promise> { const channelList = await this.channelService.findAll(); - console.log(channelList); return ResponseEntity.ok(channelList); } @Get(':id') async findOne( diff --git a/backend/src/user-channel/user-channel.service.ts b/backend/src/user-channel/user-channel.service.ts index 2af6e8e..1cf3ebb 100644 --- a/backend/src/user-channel/user-channel.service.ts +++ b/backend/src/user-channel/user-channel.service.ts @@ -35,7 +35,6 @@ export class UserChannelService { serverId: number, userId: number, ): Promise { - console.log(serverId, userId); return this.userChannelRepository.getJoinedChannelListByUserId( userId, serverId, From 4662640948e312259e496496546cca82c7e93a28 Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Mon, 22 Nov 2021 15:49:20 +0900 Subject: [PATCH 045/172] =?UTF-8?q?Refactor=20:=20=EC=A1=B0=EA=B1=B4?= =?UTF-8?q?=EC=8B=9D=20=EB=B3=80=EA=B2=BD,=20=EC=97=90=EB=9F=AC=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95,=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 내용은 반영하여 부적절한 에러코드와 함수명을 수정하고 조건문을 더 간결하게 수정하였습니다. --- backend/src/server/server.controller.ts | 4 ++-- backend/src/server/server.service.ts | 2 +- backend/src/user-server/user-server.service.ts | 4 ++-- frontend/src/components/Main/ChannelList.tsx | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/src/server/server.controller.ts b/backend/src/server/server.controller.ts index 319b0bf..8b74a60 100644 --- a/backend/src/server/server.controller.ts +++ b/backend/src/server/server.controller.ts @@ -82,7 +82,7 @@ export class ServerController { } } - @Patch('/:id') async updateUser( + @Patch('/:id') async updateServer( @Param('id') id: number, @Body() server: Server, ): Promise { @@ -94,7 +94,7 @@ export class ServerController { }); } - @Delete('/:id') async deleteUser(@Param('id') id: number): Promise { + @Delete('/:id') async deleteServer(@Param('id') id: number): Promise { await this.serverService.deleteServer(id); return Object.assign({ data: { id }, diff --git a/backend/src/server/server.service.ts b/backend/src/server/server.service.ts index 3981ed6..2bfe35d 100644 --- a/backend/src/server/server.service.ts +++ b/backend/src/server/server.service.ts @@ -35,7 +35,7 @@ export class ServerService { newServer.name = requestServerDto.name; newServer.description = requestServerDto.description; newServer.owner = user; - newServer.imgUrl = imgUrl !== undefined ? imgUrl : ''; + newServer.imgUrl = imgUrl || ''; const createdServer = await this.serverRepository.save(newServer); this.userServerService.create(user, createdServer.id); diff --git a/backend/src/user-server/user-server.service.ts b/backend/src/user-server/user-server.service.ts index 24e171a..08bf8cb 100644 --- a/backend/src/user-server/user-server.service.ts +++ b/backend/src/user-server/user-server.service.ts @@ -21,14 +21,14 @@ export class UserServerService { newUserServer.server = await this.serverService.findOne(serverId); if (newUserServer.server == undefined) { - throw new HttpException('해당 서버가 존재하지 않습니다.', 403); + throw new HttpException('해당 서버가 존재하지 않습니다.', 400); } const userServer = await this.userServerRepository.findByUserIdAndServerId( user.id, serverId, ); if (userServer !== undefined) { - throw new HttpException('이미 등록된 서버입니다.', 403); + throw new HttpException('이미 등록된 서버입니다.', 400); } return this.userServerRepository.save(newUserServer); diff --git a/frontend/src/components/Main/ChannelList.tsx b/frontend/src/components/Main/ChannelList.tsx index 4c8238d..dbd42b6 100644 --- a/frontend/src/components/Main/ChannelList.tsx +++ b/frontend/src/components/Main/ChannelList.tsx @@ -141,7 +141,7 @@ function ChannelList(): JSX.Element { }, []); useEffect(() => { - const serverId = selectedServer !== undefined ? selectedServer.server.id : 'none'; + const serverId = selectedServer?.server?.id || 'none'; navigate({ search: `?${createSearchParams({ serverId, From f9c3bd14af27830bda848791af0f6a16b504564c Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Mon, 22 Nov 2021 16:04:16 +0900 Subject: [PATCH 046/172] =?UTF-8?q?Fix=20:=20=EC=A4=91=EB=B3=B5=EB=90=98?= =?UTF-8?q?=EB=8A=94=20import=20=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Main/MainStore.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/components/Main/MainStore.tsx b/frontend/src/components/Main/MainStore.tsx index c66d1be..cec97cf 100644 --- a/frontend/src/components/Main/MainStore.tsx +++ b/frontend/src/components/Main/MainStore.tsx @@ -1,7 +1,5 @@ import { createContext, useEffect, useState } from 'react'; -import { ChannelData } from '../../types/main'; - export const MainStoreContext = createContext(null); type MainStoreProps = { From f1bd85c6b6baa06b18a238c9d6b11db23810a70a Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Mon, 22 Nov 2021 16:07:56 +0900 Subject: [PATCH 047/172] =?UTF-8?q?Fix=20:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Main/ChannelList.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/frontend/src/components/Main/ChannelList.tsx b/frontend/src/components/Main/ChannelList.tsx index ed212a2..d08dd22 100644 --- a/frontend/src/components/Main/ChannelList.tsx +++ b/frontend/src/components/Main/ChannelList.tsx @@ -133,10 +133,6 @@ function ChannelList(): JSX.Element { e.preventDefault(); }; - useEffect(() => { - getChannelList(); - }, []); - useEffect(() => { const serverId = selectedServer?.server?.id || 'none'; From 2fbde49d9c0ecf3e2fdafaf985b5e1e639d04b7e Mon Sep 17 00:00:00 2001 From: korung3195 Date: Mon, 22 Nov 2021 15:57:13 +0900 Subject: [PATCH 048/172] =?UTF-8?q?Fix=20:=20=EB=B0=B1=EC=97=94=EB=93=9C?= =?UTF-8?q?=20tsconfig=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존에 import를 하면 자동으로 src부터 시작되는 resolution을 가졌는데, 테스트 과정에서 오류가 발생했기 때문에 관련 tsconfig 설정을 수정하였습니다. --- backend/.eslintrc.js | 2 +- backend/src/cam/cam.gateway.ts | 2 +- backend/src/cam/cam.service.ts | 2 +- backend/src/channel/channel.entity.ts | 2 +- backend/src/comment/comment.entity.ts | 2 +- backend/src/message/message.entity.ts | 4 ++-- backend/src/user-server/user-server.controller.ts | 2 +- backend/tsconfig.json | 4 ++-- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/backend/.eslintrc.js b/backend/.eslintrc.js index 7357464..2060c58 100644 --- a/backend/.eslintrc.js +++ b/backend/.eslintrc.js @@ -1,7 +1,7 @@ module.exports = { parser: '@typescript-eslint/parser', parserOptions: { - project: 'tsconfig.json', + project: './tsconfig.json', sourceType: 'module', tsconfigRootDir: __dirname, }, diff --git a/backend/src/cam/cam.gateway.ts b/backend/src/cam/cam.gateway.ts index 3ddbf62..255653c 100644 --- a/backend/src/cam/cam.gateway.ts +++ b/backend/src/cam/cam.gateway.ts @@ -5,7 +5,7 @@ import { } from '@nestjs/websockets'; import { Socket, Server } from 'socket.io'; -import { Status, MessageInfo } from 'src/types/cam'; +import { Status, MessageInfo } from '../types/cam'; import { CamService } from './cam.service'; @WebSocketGateway() diff --git a/backend/src/cam/cam.service.ts b/backend/src/cam/cam.service.ts index 3ad4c6f..b51694b 100644 --- a/backend/src/cam/cam.service.ts +++ b/backend/src/cam/cam.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { Status, CamMap } from 'src/types/cam'; +import { Status, CamMap } from '../types/cam'; type RoomId = string; type SocketId = string; diff --git a/backend/src/channel/channel.entity.ts b/backend/src/channel/channel.entity.ts index 5425f9e..489ffc4 100644 --- a/backend/src/channel/channel.entity.ts +++ b/backend/src/channel/channel.entity.ts @@ -1,4 +1,4 @@ -import { Server } from 'src/server/server.entity'; +import { Server } from '../server/server.entity'; import { Entity, Column, PrimaryGeneratedColumn, ManyToOne } from 'typeorm'; @Entity() diff --git a/backend/src/comment/comment.entity.ts b/backend/src/comment/comment.entity.ts index f3f1262..73225ba 100644 --- a/backend/src/comment/comment.entity.ts +++ b/backend/src/comment/comment.entity.ts @@ -1,4 +1,3 @@ -import { Message } from 'src/message/message.entity'; import { Entity, Column, @@ -6,6 +5,7 @@ import { ManyToOne, CreateDateColumn, } from 'typeorm'; +import { Message } from '../message/message.entity'; import { User } from '../user/user.entity'; @Entity() diff --git a/backend/src/message/message.entity.ts b/backend/src/message/message.entity.ts index dd6f714..0a74c03 100644 --- a/backend/src/message/message.entity.ts +++ b/backend/src/message/message.entity.ts @@ -1,5 +1,5 @@ -import { Channel } from 'src/channel/channel.entity'; -import { User } from 'src/user/user.entity'; +import { Channel } from '../channel/channel.entity'; +import { User } from '../user/user.entity'; import { Entity, PrimaryGeneratedColumn, diff --git a/backend/src/user-server/user-server.controller.ts b/backend/src/user-server/user-server.controller.ts index 31e4ac9..690392a 100644 --- a/backend/src/user-server/user-server.controller.ts +++ b/backend/src/user-server/user-server.controller.ts @@ -13,7 +13,7 @@ import { Server } from '../server/server.entity'; import { LoginGuard } from '../login/login.guard'; import { ExpressSession } from '../types/session'; import { UserServerService } from './user-server.service'; -import ResponseEntity from 'src/common/response-entity'; +import ResponseEntity from '../common/response-entity'; @Controller('/api/users/servers') @UseGuards(LoginGuard) diff --git a/backend/tsconfig.json b/backend/tsconfig.json index adb614c..112f8d5 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -9,7 +9,6 @@ "target": "es2017", "sourceMap": true, "outDir": "./dist", - "baseUrl": "./", "incremental": true, "skipLibCheck": true, "strictNullChecks": false, @@ -17,5 +16,6 @@ "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false - } + }, + "include": ["src"] } From 1c9fd8b45350b5c6a1b406514eeb12c570dbd21e Mon Sep 17 00:00:00 2001 From: korung3195 Date: Mon, 22 Nov 2021 16:43:22 +0900 Subject: [PATCH 049/172] =?UTF-8?q?Fix=20:=20src=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tsconfig를 수정함에 따라 src/ 를 참고했던 경로를 모두 수정하였습니다. --- backend/src/channel/channel.controller.ts | 4 ++-- backend/src/channel/channel.module.ts | 8 ++++---- backend/src/channel/channel.service.ts | 2 +- .../src/user-channel/user-channel.controller.ts | 8 ++++---- backend/src/user-channel/user-channel.entity.ts | 2 +- backend/src/user-channel/user-channel.module.ts | 14 +++++++------- backend/src/user-channel/user-channel.service.ts | 4 ++-- backend/src/user/user.module.ts | 2 +- 8 files changed, 22 insertions(+), 22 deletions(-) diff --git a/backend/src/channel/channel.controller.ts b/backend/src/channel/channel.controller.ts index b5f1434..6ec4a50 100644 --- a/backend/src/channel/channel.controller.ts +++ b/backend/src/channel/channel.controller.ts @@ -15,8 +15,8 @@ import { ExpressSession } from '../types/session'; import { ChannelService } from './channel.service'; import { Channel } from './channel.entity'; import { CreateChannelDto } from './channe.dto'; -import { UserChannelService } from 'src/user-channel/user-channel.service'; -import ResponseEntity from 'src/lib/ResponseEntity'; +import { UserChannelService } from '../user-channel/user-channel.service'; +import ResponseEntity from '../lib/ResponseEntity'; @Controller('api/channel') @UseGuards(LoginGuard) diff --git a/backend/src/channel/channel.module.ts b/backend/src/channel/channel.module.ts index 05bbfa4..118b697 100644 --- a/backend/src/channel/channel.module.ts +++ b/backend/src/channel/channel.module.ts @@ -4,10 +4,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Channel } from './channel.entity'; import { ChannelController } from './channel.controller'; import { ChannelService } from './channel.service'; -import { Server } from 'src/server/server.entity'; -import { UserChannelService } from 'src/user-channel/user-channel.service'; -import { UserChannelRepository } from 'src/user-channel/user-channel.repository'; -import { UserRepository } from 'src/user/user.repository'; +import { Server } from '../server/server.entity'; +import { UserChannelService } from '../user-channel/user-channel.service'; +import { UserChannelRepository } from '../user-channel/user-channel.repository'; +import { UserRepository } from '../user/user.repository'; import { ChannelRepository } from './user.repository'; @Module({ diff --git a/backend/src/channel/channel.service.ts b/backend/src/channel/channel.service.ts index a308d75..06b2e12 100644 --- a/backend/src/channel/channel.service.ts +++ b/backend/src/channel/channel.service.ts @@ -4,7 +4,7 @@ import { Repository } from 'typeorm/index'; import { CreateChannelDto } from './channe.dto'; import { Channel } from './channel.entity'; -import { Server } from 'src/server/server.entity'; +import { Server } from '../server/server.entity'; import { ChannelRepository } from './user.repository'; @Injectable() diff --git a/backend/src/user-channel/user-channel.controller.ts b/backend/src/user-channel/user-channel.controller.ts index 74d1e56..c48d485 100644 --- a/backend/src/user-channel/user-channel.controller.ts +++ b/backend/src/user-channel/user-channel.controller.ts @@ -8,13 +8,13 @@ import { Post, Body, } from '@nestjs/common'; -import { Channel } from 'src/channel/channel.entity'; -import ResponseEntity from 'src/lib/ResponseEntity'; -import { LoginGuard } from 'src/login/login.guard'; +import ResponseEntity from '../lib/ResponseEntity'; +import { LoginGuard } from '../login/login.guard'; import { ExpressSession } from '../types/session'; import { UserChannelService } from './user-channel.service'; -import { ChannelService } from 'src/channel/channel.service'; +import { ChannelService } from '../channel/channel.service'; import { UserChannel } from './user-channel.entity'; +import { Channel } from '../channel/channel.entity'; @Controller('/api/user/channels') @UseGuards(LoginGuard) diff --git a/backend/src/user-channel/user-channel.entity.ts b/backend/src/user-channel/user-channel.entity.ts index 625206b..c6b9963 100644 --- a/backend/src/user-channel/user-channel.entity.ts +++ b/backend/src/user-channel/user-channel.entity.ts @@ -1,7 +1,7 @@ import { Entity, PrimaryGeneratedColumn, ManyToOne } from 'typeorm'; import { User } from '../user/user.entity'; import { Server } from '../server/server.entity'; -import { Channel } from 'src/channel/channel.entity'; +import { Channel } from '../channel/channel.entity'; @Entity() export class UserChannel { diff --git a/backend/src/user-channel/user-channel.module.ts b/backend/src/user-channel/user-channel.module.ts index 546a158..a8672cf 100644 --- a/backend/src/user-channel/user-channel.module.ts +++ b/backend/src/user-channel/user-channel.module.ts @@ -1,17 +1,17 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Channel } from 'src/channel/channel.entity'; -import { ChannelService } from 'src/channel/channel.service'; -import { User } from 'src/user/user.entity'; -import { UserRepository } from 'src/user/user.repository'; +import { Channel } from '../channel/channel.entity'; +import { ChannelService } from '../channel/channel.service'; +import { User } from '../user/user.entity'; +import { UserRepository } from '../user/user.repository'; import { UserChannelController } from './user-channel.controller'; import { UserChannel } from './user-channel.entity'; import { UserChannelRepository } from './user-channel.repository'; import { UserChannelService } from './user-channel.service'; -import { ServerRepository } from 'src/server/server.repository'; -import { Server } from 'src/server/server.entity'; -import { ChannelRepository } from 'src/channel/user.repository'; +import { ServerRepository } from '../server/server.repository'; +import { Server } from '../server/server.entity'; +import { ChannelRepository } from '../channel/user.repository'; @Module({ imports: [ diff --git a/backend/src/user-channel/user-channel.service.ts b/backend/src/user-channel/user-channel.service.ts index 1cf3ebb..a7f699a 100644 --- a/backend/src/user-channel/user-channel.service.ts +++ b/backend/src/user-channel/user-channel.service.ts @@ -4,8 +4,8 @@ import { DeleteQueryBuilder, DeleteResult } from 'typeorm'; import { UserChannelRepository } from './user-channel.repository'; import { UserChannel } from './user-channel.entity'; -import { Channel } from 'src/channel/channel.entity'; -import { UserRepository } from 'src/user/user.repository'; +import { Channel } from '../channel/channel.entity'; +import { UserRepository } from '../user/user.repository'; @Injectable() export class UserChannelService { diff --git a/backend/src/user/user.module.ts b/backend/src/user/user.module.ts index 498e506..d52624e 100644 --- a/backend/src/user/user.module.ts +++ b/backend/src/user/user.module.ts @@ -4,7 +4,7 @@ import { Server } from '../server/server.entity'; import { UserService } from './user.service'; import { User } from './user.entity'; import { UserController } from './user.controller'; -import { UserServerModule } from 'src/user-server/user-server.module'; +import { UserServerModule } from '../user-server/user-server.module'; @Module({ imports: [UserServerModule, TypeOrmModule.forFeature([User, Server])], From cebb371ec494dcc816d9cc3c29c3805c5f7bac4a Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Mon, 22 Nov 2021 17:15:33 +0900 Subject: [PATCH 050/172] =?UTF-8?q?Fix=20:=20Merge=EB=A1=9C=20=EC=9D=B8?= =?UTF-8?q?=ED=95=B4=20=EC=B6=A9=EB=8F=8C=EB=90=98=EB=8A=94=20=EB=B6=80?= =?UTF-8?q?=EB=B6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Channel 리스트를 가져오는 API명을 수정하였습니다. - MainStore 의 selectedServer의 타입이 변경됨에 따라 server의 id를 가져오는 부분들을 수정하였습니다. --- backend/src/channel/channel.controller.ts | 2 +- backend/src/lib/ResponseEntity.ts | 15 --------------- .../src/user-channel/user-channel.controller.ts | 8 ++++---- frontend/src/components/Main/MainStore.tsx | 2 +- .../components/Main/Modal/CreateChannelModal.tsx | 4 ++-- .../components/Main/Modal/JoinChannelModal.tsx | 2 +- 6 files changed, 9 insertions(+), 24 deletions(-) delete mode 100644 backend/src/lib/ResponseEntity.ts diff --git a/backend/src/channel/channel.controller.ts b/backend/src/channel/channel.controller.ts index 6ec4a50..64530f7 100644 --- a/backend/src/channel/channel.controller.ts +++ b/backend/src/channel/channel.controller.ts @@ -16,7 +16,7 @@ import { ChannelService } from './channel.service'; import { Channel } from './channel.entity'; import { CreateChannelDto } from './channe.dto'; import { UserChannelService } from '../user-channel/user-channel.service'; -import ResponseEntity from '../lib/ResponseEntity'; +import ResponseEntity from '../common/response-entity'; @Controller('api/channel') @UseGuards(LoginGuard) diff --git a/backend/src/lib/ResponseEntity.ts b/backend/src/lib/ResponseEntity.ts deleted file mode 100644 index 0dabb6e..0000000 --- a/backend/src/lib/ResponseEntity.ts +++ /dev/null @@ -1,15 +0,0 @@ -class ResponseEntity { - statusCode: number; - message: string; - data: T; - constructor(statusCode: number, data: T) { - this.statusCode = statusCode; - this.data = data; - } - - static ok(data: T): ResponseEntity { - return new ResponseEntity(200, data); - } -} - -export default ResponseEntity; diff --git a/backend/src/user-channel/user-channel.controller.ts b/backend/src/user-channel/user-channel.controller.ts index c48d485..e044400 100644 --- a/backend/src/user-channel/user-channel.controller.ts +++ b/backend/src/user-channel/user-channel.controller.ts @@ -8,7 +8,7 @@ import { Post, Body, } from '@nestjs/common'; -import ResponseEntity from '../lib/ResponseEntity'; +import ResponseEntity from '../common/response-entity'; import { LoginGuard } from '../login/login.guard'; import { ExpressSession } from '../types/session'; import { UserChannelService } from './user-channel.service'; @@ -16,7 +16,7 @@ import { ChannelService } from '../channel/channel.service'; import { UserChannel } from './user-channel.entity'; import { Channel } from '../channel/channel.entity'; -@Controller('/api/user/channels') +@Controller('/api/user/servers') @UseGuards(LoginGuard) export class UserChannelController { constructor( @@ -27,7 +27,7 @@ export class UserChannelController { this.channelService = channelService; } - @Get('/joined/:id') + @Get('/:id/channels/joined/') async getJoinedChannelList( @Param('id') serverId: number, @Session() session: ExpressSession, @@ -42,7 +42,7 @@ export class UserChannelController { return ResponseEntity.ok(joinedChannelList); } - @Get('/notjoined/:id') + @Get('/:id/channels/notjoined/') async getNotJoinedChannelList( @Param('id') serverId: number, @Session() session: ExpressSession, diff --git a/frontend/src/components/Main/MainStore.tsx b/frontend/src/components/Main/MainStore.tsx index da2b99f..f4c3f4e 100644 --- a/frontend/src/components/Main/MainStore.tsx +++ b/frontend/src/components/Main/MainStore.tsx @@ -22,7 +22,7 @@ function MainStore(props: MainStoreProps): JSX.Element { const [serverList, setServerList] = useState([]); const getServerChannelList = async (): Promise => { - const response = await fetch(`/api/user/channels/joined/${selectedServer}`); + const response = await fetch(`/api/user/servers/${selectedServer?.server.id}/channels/joined/`); const list = await response.json(); setServerChannelList(list.data); }; diff --git a/frontend/src/components/Main/Modal/CreateChannelModal.tsx b/frontend/src/components/Main/Modal/CreateChannelModal.tsx index 5768886..79ee028 100644 --- a/frontend/src/components/Main/Modal/CreateChannelModal.tsx +++ b/frontend/src/components/Main/Modal/CreateChannelModal.tsx @@ -180,7 +180,7 @@ function CreateChannelModal(): JSX.Element { const onSubmitCreateChannelModal = async (data: { name: string; description: string }) => { const { name, description } = data; - await fetch('api/channel', { + await fetch('/api/channel', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -188,7 +188,7 @@ function CreateChannelModal(): JSX.Element { body: JSON.stringify({ name: name.trim(), description: description.trim(), - serverId: +selectedServer, + serverId: selectedServer.server.id, }), }); getServerChannelList(); diff --git a/frontend/src/components/Main/Modal/JoinChannelModal.tsx b/frontend/src/components/Main/Modal/JoinChannelModal.tsx index da9d8c5..c16f7cf 100644 --- a/frontend/src/components/Main/Modal/JoinChannelModal.tsx +++ b/frontend/src/components/Main/Modal/JoinChannelModal.tsx @@ -195,7 +195,7 @@ function JoinChannelModal(): JSX.Element { const [channelList, setChannelList] = useState([]); const getNotJoinedChannelList = async () => { - const response = await fetch(`/api/user/channels/notjoined/${selectedServer}`); + const response = await fetch(`/api/user/servers/${selectedServer?.server.id}/channels/notjoined/`); const list = await response.json(); setChannelList(list.data); }; From e7dd502d348a79bbc6271a624a7f1c0deac50fac Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Mon, 22 Nov 2021 17:18:06 +0900 Subject: [PATCH 051/172] =?UTF-8?q?Fix=20:=20Merge=EB=A1=9C=20=EC=9D=B8?= =?UTF-8?q?=ED=95=B4=20=EC=B6=A9=EB=8F=8C=EB=90=98=EB=8A=94=20=EB=B6=80?= =?UTF-8?q?=EB=B6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Channel 리스트를 가져오는 API명을 수정하였습니다. - MainStore 의 selectedServer의 타입이 변경됨에 따라 server의 id를 가져오는 부분들을 수정하였습니다. --- backend/src/channel/channel.controller.ts | 2 +- backend/src/lib/ResponseEntity.ts | 15 +++++++++++++++ .../src/user-channel/user-channel.controller.ts | 8 ++++---- frontend/src/components/Main/MainStore.tsx | 2 +- .../components/Main/Modal/CreateChannelModal.tsx | 4 ++-- .../components/Main/Modal/JoinChannelModal.tsx | 2 +- 6 files changed, 24 insertions(+), 9 deletions(-) create mode 100644 backend/src/lib/ResponseEntity.ts diff --git a/backend/src/channel/channel.controller.ts b/backend/src/channel/channel.controller.ts index 64530f7..6ec4a50 100644 --- a/backend/src/channel/channel.controller.ts +++ b/backend/src/channel/channel.controller.ts @@ -16,7 +16,7 @@ import { ChannelService } from './channel.service'; import { Channel } from './channel.entity'; import { CreateChannelDto } from './channe.dto'; import { UserChannelService } from '../user-channel/user-channel.service'; -import ResponseEntity from '../common/response-entity'; +import ResponseEntity from '../lib/ResponseEntity'; @Controller('api/channel') @UseGuards(LoginGuard) diff --git a/backend/src/lib/ResponseEntity.ts b/backend/src/lib/ResponseEntity.ts new file mode 100644 index 0000000..0dabb6e --- /dev/null +++ b/backend/src/lib/ResponseEntity.ts @@ -0,0 +1,15 @@ +class ResponseEntity { + statusCode: number; + message: string; + data: T; + constructor(statusCode: number, data: T) { + this.statusCode = statusCode; + this.data = data; + } + + static ok(data: T): ResponseEntity { + return new ResponseEntity(200, data); + } +} + +export default ResponseEntity; diff --git a/backend/src/user-channel/user-channel.controller.ts b/backend/src/user-channel/user-channel.controller.ts index e044400..c48d485 100644 --- a/backend/src/user-channel/user-channel.controller.ts +++ b/backend/src/user-channel/user-channel.controller.ts @@ -8,7 +8,7 @@ import { Post, Body, } from '@nestjs/common'; -import ResponseEntity from '../common/response-entity'; +import ResponseEntity from '../lib/ResponseEntity'; import { LoginGuard } from '../login/login.guard'; import { ExpressSession } from '../types/session'; import { UserChannelService } from './user-channel.service'; @@ -16,7 +16,7 @@ import { ChannelService } from '../channel/channel.service'; import { UserChannel } from './user-channel.entity'; import { Channel } from '../channel/channel.entity'; -@Controller('/api/user/servers') +@Controller('/api/user/channels') @UseGuards(LoginGuard) export class UserChannelController { constructor( @@ -27,7 +27,7 @@ export class UserChannelController { this.channelService = channelService; } - @Get('/:id/channels/joined/') + @Get('/joined/:id') async getJoinedChannelList( @Param('id') serverId: number, @Session() session: ExpressSession, @@ -42,7 +42,7 @@ export class UserChannelController { return ResponseEntity.ok(joinedChannelList); } - @Get('/:id/channels/notjoined/') + @Get('/notjoined/:id') async getNotJoinedChannelList( @Param('id') serverId: number, @Session() session: ExpressSession, diff --git a/frontend/src/components/Main/MainStore.tsx b/frontend/src/components/Main/MainStore.tsx index f4c3f4e..da2b99f 100644 --- a/frontend/src/components/Main/MainStore.tsx +++ b/frontend/src/components/Main/MainStore.tsx @@ -22,7 +22,7 @@ function MainStore(props: MainStoreProps): JSX.Element { const [serverList, setServerList] = useState([]); const getServerChannelList = async (): Promise => { - const response = await fetch(`/api/user/servers/${selectedServer?.server.id}/channels/joined/`); + const response = await fetch(`/api/user/channels/joined/${selectedServer}`); const list = await response.json(); setServerChannelList(list.data); }; diff --git a/frontend/src/components/Main/Modal/CreateChannelModal.tsx b/frontend/src/components/Main/Modal/CreateChannelModal.tsx index 79ee028..5768886 100644 --- a/frontend/src/components/Main/Modal/CreateChannelModal.tsx +++ b/frontend/src/components/Main/Modal/CreateChannelModal.tsx @@ -180,7 +180,7 @@ function CreateChannelModal(): JSX.Element { const onSubmitCreateChannelModal = async (data: { name: string; description: string }) => { const { name, description } = data; - await fetch('/api/channel', { + await fetch('api/channel', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -188,7 +188,7 @@ function CreateChannelModal(): JSX.Element { body: JSON.stringify({ name: name.trim(), description: description.trim(), - serverId: selectedServer.server.id, + serverId: +selectedServer, }), }); getServerChannelList(); diff --git a/frontend/src/components/Main/Modal/JoinChannelModal.tsx b/frontend/src/components/Main/Modal/JoinChannelModal.tsx index c16f7cf..da9d8c5 100644 --- a/frontend/src/components/Main/Modal/JoinChannelModal.tsx +++ b/frontend/src/components/Main/Modal/JoinChannelModal.tsx @@ -195,7 +195,7 @@ function JoinChannelModal(): JSX.Element { const [channelList, setChannelList] = useState([]); const getNotJoinedChannelList = async () => { - const response = await fetch(`/api/user/servers/${selectedServer?.server.id}/channels/notjoined/`); + const response = await fetch(`/api/user/channels/notjoined/${selectedServer}`); const list = await response.json(); setChannelList(list.data); }; From 3e17304f1184733944146cb58ce33d9b069c6a37 Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Mon, 22 Nov 2021 17:24:01 +0900 Subject: [PATCH 052/172] =?UTF-8?q?Fix=20:=20=EB=B0=98=EC=98=81=EB=90=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EC=9D=80=20=EB=B6=80=EB=B6=84=EB=93=A4=20?= =?UTF-8?q?=EC=9E=AC=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/channel/channel.controller.ts | 2 +- backend/src/lib/ResponseEntity.ts | 15 --------------- .../src/user-channel/user-channel.controller.ts | 8 ++++---- frontend/src/components/Main/MainStore.tsx | 2 +- .../components/Main/Modal/CreateChannelModal.tsx | 2 +- .../components/Main/Modal/JoinChannelModal.tsx | 2 +- 6 files changed, 8 insertions(+), 23 deletions(-) delete mode 100644 backend/src/lib/ResponseEntity.ts diff --git a/backend/src/channel/channel.controller.ts b/backend/src/channel/channel.controller.ts index 6ec4a50..64530f7 100644 --- a/backend/src/channel/channel.controller.ts +++ b/backend/src/channel/channel.controller.ts @@ -16,7 +16,7 @@ import { ChannelService } from './channel.service'; import { Channel } from './channel.entity'; import { CreateChannelDto } from './channe.dto'; import { UserChannelService } from '../user-channel/user-channel.service'; -import ResponseEntity from '../lib/ResponseEntity'; +import ResponseEntity from '../common/response-entity'; @Controller('api/channel') @UseGuards(LoginGuard) diff --git a/backend/src/lib/ResponseEntity.ts b/backend/src/lib/ResponseEntity.ts deleted file mode 100644 index 0dabb6e..0000000 --- a/backend/src/lib/ResponseEntity.ts +++ /dev/null @@ -1,15 +0,0 @@ -class ResponseEntity { - statusCode: number; - message: string; - data: T; - constructor(statusCode: number, data: T) { - this.statusCode = statusCode; - this.data = data; - } - - static ok(data: T): ResponseEntity { - return new ResponseEntity(200, data); - } -} - -export default ResponseEntity; diff --git a/backend/src/user-channel/user-channel.controller.ts b/backend/src/user-channel/user-channel.controller.ts index c48d485..e044400 100644 --- a/backend/src/user-channel/user-channel.controller.ts +++ b/backend/src/user-channel/user-channel.controller.ts @@ -8,7 +8,7 @@ import { Post, Body, } from '@nestjs/common'; -import ResponseEntity from '../lib/ResponseEntity'; +import ResponseEntity from '../common/response-entity'; import { LoginGuard } from '../login/login.guard'; import { ExpressSession } from '../types/session'; import { UserChannelService } from './user-channel.service'; @@ -16,7 +16,7 @@ import { ChannelService } from '../channel/channel.service'; import { UserChannel } from './user-channel.entity'; import { Channel } from '../channel/channel.entity'; -@Controller('/api/user/channels') +@Controller('/api/user/servers') @UseGuards(LoginGuard) export class UserChannelController { constructor( @@ -27,7 +27,7 @@ export class UserChannelController { this.channelService = channelService; } - @Get('/joined/:id') + @Get('/:id/channels/joined/') async getJoinedChannelList( @Param('id') serverId: number, @Session() session: ExpressSession, @@ -42,7 +42,7 @@ export class UserChannelController { return ResponseEntity.ok(joinedChannelList); } - @Get('/notjoined/:id') + @Get('/:id/channels/notjoined/') async getNotJoinedChannelList( @Param('id') serverId: number, @Session() session: ExpressSession, diff --git a/frontend/src/components/Main/MainStore.tsx b/frontend/src/components/Main/MainStore.tsx index da2b99f..f4c3f4e 100644 --- a/frontend/src/components/Main/MainStore.tsx +++ b/frontend/src/components/Main/MainStore.tsx @@ -22,7 +22,7 @@ function MainStore(props: MainStoreProps): JSX.Element { const [serverList, setServerList] = useState([]); const getServerChannelList = async (): Promise => { - const response = await fetch(`/api/user/channels/joined/${selectedServer}`); + const response = await fetch(`/api/user/servers/${selectedServer?.server.id}/channels/joined/`); const list = await response.json(); setServerChannelList(list.data); }; diff --git a/frontend/src/components/Main/Modal/CreateChannelModal.tsx b/frontend/src/components/Main/Modal/CreateChannelModal.tsx index 5768886..543d418 100644 --- a/frontend/src/components/Main/Modal/CreateChannelModal.tsx +++ b/frontend/src/components/Main/Modal/CreateChannelModal.tsx @@ -188,7 +188,7 @@ function CreateChannelModal(): JSX.Element { body: JSON.stringify({ name: name.trim(), description: description.trim(), - serverId: +selectedServer, + serverId: selectedServer.server.id, }), }); getServerChannelList(); diff --git a/frontend/src/components/Main/Modal/JoinChannelModal.tsx b/frontend/src/components/Main/Modal/JoinChannelModal.tsx index da9d8c5..c16f7cf 100644 --- a/frontend/src/components/Main/Modal/JoinChannelModal.tsx +++ b/frontend/src/components/Main/Modal/JoinChannelModal.tsx @@ -195,7 +195,7 @@ function JoinChannelModal(): JSX.Element { const [channelList, setChannelList] = useState([]); const getNotJoinedChannelList = async () => { - const response = await fetch(`/api/user/channels/notjoined/${selectedServer}`); + const response = await fetch(`/api/user/servers/${selectedServer?.server.id}/channels/notjoined/`); const list = await response.json(); setChannelList(list.data); }; From a08e46a2f5bec6204ebf85b0452bd4b01dcd4de8 Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Mon, 22 Nov 2021 17:44:20 +0900 Subject: [PATCH 053/172] =?UTF-8?q?Refactor=20:=20Modal=EC=9D=98=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=20=EB=AA=85=EC=9D=84=20ChannelModal=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Main/{Modal => ChannelModal}/CreateChannelModal.tsx | 0 .../Main/{Modal => ChannelModal}/JoinChannelModal.tsx | 0 frontend/src/components/Main/MainPage.tsx | 4 ++-- 3 files changed, 2 insertions(+), 2 deletions(-) rename frontend/src/components/Main/{Modal => ChannelModal}/CreateChannelModal.tsx (100%) rename frontend/src/components/Main/{Modal => ChannelModal}/JoinChannelModal.tsx (100%) diff --git a/frontend/src/components/Main/Modal/CreateChannelModal.tsx b/frontend/src/components/Main/ChannelModal/CreateChannelModal.tsx similarity index 100% rename from frontend/src/components/Main/Modal/CreateChannelModal.tsx rename to frontend/src/components/Main/ChannelModal/CreateChannelModal.tsx diff --git a/frontend/src/components/Main/Modal/JoinChannelModal.tsx b/frontend/src/components/Main/ChannelModal/JoinChannelModal.tsx similarity index 100% rename from frontend/src/components/Main/Modal/JoinChannelModal.tsx rename to frontend/src/components/Main/ChannelModal/JoinChannelModal.tsx diff --git a/frontend/src/components/Main/MainPage.tsx b/frontend/src/components/Main/MainPage.tsx index 6df1304..e6195de 100644 --- a/frontend/src/components/Main/MainPage.tsx +++ b/frontend/src/components/Main/MainPage.tsx @@ -4,8 +4,8 @@ import styled from 'styled-components'; import ServerListTab from './ServerListTab'; import MainSection from './MainSection'; import { MainStoreContext } from './MainStore'; -import CreateChannelModal from './Modal/CreateChannelModal'; -import JoinChannelModal from './Modal/JoinChannelModal'; +import CreateChannelModal from './ChannelModal/CreateChannelModal'; +import JoinChannelModal from './ChannelModal/JoinChannelModal'; import CreateServerModal from './ServerModal/CreateServerModal'; import JoinServerModal from './ServerModal/JoinServerModal'; import ServerSettingModal from './ServerModal/ServerSettingModal'; From 599fbaace04195536e69572d02e698dee1ab7001 Mon Sep 17 00:00:00 2001 From: korung3195 Date: Mon, 22 Nov 2021 17:52:02 +0900 Subject: [PATCH 054/172] =?UTF-8?q?Fix=20:=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20=EB=B0=A9=EC=96=B4=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 화면 공유 종료 시 본인의 stream이 없으면 발생하는 에러를 막는 로직을 추가하였습니다. --- frontend/src/components/Cam/Screen/UserScreen.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Cam/Screen/UserScreen.tsx b/frontend/src/components/Cam/Screen/UserScreen.tsx index 73722b9..d0a8965 100644 --- a/frontend/src/components/Cam/Screen/UserScreen.tsx +++ b/frontend/src/components/Cam/Screen/UserScreen.tsx @@ -72,8 +72,12 @@ function UserScreen(props: UserScreenProps): JSX.Element { socket.emit('getUserStatus', { userId }); return () => { if (stream) { - stream.getAudioTracks()[0].enabled = true; - stream.getVideoTracks()[0].enabled = true; + if (stream.getAudioTracks()[0]) { + stream.getAudioTracks()[0].enabled = true; + } + if (stream.getVideoTracks()[0]) { + stream.getVideoTracks()[0].enabled = true; + } } }; }, []); From ae26ec56fad7268eb328f93ba0dde168ed331a38 Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Mon, 22 Nov 2021 17:52:15 +0900 Subject: [PATCH 055/172] =?UTF-8?q?Refactor=20:=20ChannelList=EC=9D=98=20C?= =?UTF-8?q?hannelListHeader=20=EB=B6=80=EB=B6=84=EC=9D=84=20=EB=B3=84?= =?UTF-8?q?=EB=8F=84=EC=9D=98=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Main/ChannelList.tsx | 90 +------------- .../src/components/Main/ChannelListHeader.tsx | 117 ++++++++++++++++++ 2 files changed, 121 insertions(+), 86 deletions(-) create mode 100644 frontend/src/components/Main/ChannelListHeader.tsx diff --git a/frontend/src/components/Main/ChannelList.tsx b/frontend/src/components/Main/ChannelList.tsx index d08dd22..19cec3e 100644 --- a/frontend/src/components/Main/ChannelList.tsx +++ b/frontend/src/components/Main/ChannelList.tsx @@ -5,10 +5,9 @@ import styled from 'styled-components'; import { BoostCamMainIcons } from '../../utils/SvgIcons'; import { ChannelData } from '../../types/main'; import { MainStoreContext } from './MainStore'; -import Dropdown from '../core/Dropdown'; -import DropdownMenu from '../core/DropdownMenu'; +import ChannelListHeader from './ChannelListHeader'; -const { Hash, Plus, ListArrow } = BoostCamMainIcons; +const { Hash } = BoostCamMainIcons; const Container = styled.div` width: 100%; @@ -22,33 +21,6 @@ const Container = styled.div` align-items: flex-start; `; -const ChannelListHeader = styled.div` - width: 90%; - height: 30px; - - margin-left: 15px; - color: #a69c96; - font-size: 17px; - - display: flex; - flex-direction: row; - justify-content: flex-start; - align-items: center; - - &:hover { - cursor: pointer; - } -`; - -const ChannelListHeaderSpan = styled.span` - margin-left: 5px; -`; - -const ChannelListHeaderButton = styled.div<{ isButtonVisible: boolean }>` - margin-left: 70px; - visibility: ${(props) => (props.isButtonVisible ? 'visible' : 'hidden')}; -`; - const ChannelListBody = styled.div` width: 100%; display: flex; @@ -83,20 +55,6 @@ const ChannelNameSpan = styled.span` padding: 5px 0px 5px 5px; `; -const ListArrowIcon = styled(ListArrow)<{ isListOpen: boolean }>` - width: 20px; - height: 20px; - fill: #a69c96; - transition: all ease-out 0.3s; - ${(props) => (props.isListOpen ? 'transform: rotate(90deg);' : 'transform: rotate(0deg);')} -`; - -const PlusIcon = styled(Plus)` - width: 20px; - height: 20px; - fill: #a69c96; -`; - const HashIcon = styled(Hash)` width: 15px; height: 15px; @@ -104,19 +62,8 @@ const HashIcon = styled(Hash)` `; function ChannelList(): JSX.Element { - const [isButtonVisible, setIsButtonVisible] = useState(false); - const [isDropdownActivated, setIsDropdownActivated] = useState(false); const [isListOpen, setIsListOpen] = useState(false); - const { - selectedServer, - selectedChannel, - serverChannelList, - isCreateModalOpen, - isJoinModalOpen, - setSelectedChannel, - setIsCreateModalOpen, - setIsJoinModalOpen, - } = useContext(MainStoreContext); + const { selectedServer, selectedChannel, serverChannelList, setSelectedChannel } = useContext(MainStoreContext); const navigate = useNavigate(); const onClickChannelBlock = ({ currentTarget }: React.MouseEvent) => { @@ -124,11 +71,6 @@ function ChannelList(): JSX.Element { if (channelId) setSelectedChannel(channelId); }; - const onClickChannelAddButton = (e: React.MouseEvent) => { - e.stopPropagation(); - setIsDropdownActivated(!isDropdownActivated); - }; - const onRightClickChannelItem = (e: React.MouseEvent) => { e.preventDefault(); }; @@ -162,31 +104,7 @@ function ChannelList(): JSX.Element { return ( - setIsButtonVisible(true)} - onMouseLeave={() => setIsButtonVisible(false)} - onClick={() => setIsListOpen(!isListOpen)} - > - - 채널 - - - - - - - - + {isListOpen && {listElements}} ); diff --git a/frontend/src/components/Main/ChannelListHeader.tsx b/frontend/src/components/Main/ChannelListHeader.tsx new file mode 100644 index 0000000..b4d98de --- /dev/null +++ b/frontend/src/components/Main/ChannelListHeader.tsx @@ -0,0 +1,117 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { useNavigate, createSearchParams } from 'react-router-dom'; +import styled from 'styled-components'; + +import { BoostCamMainIcons } from '../../utils/SvgIcons'; +import { MainStoreContext } from './MainStore'; +import Dropdown from '../core/Dropdown'; +import DropdownMenu from '../core/DropdownMenu'; + +const { Plus, ListArrow } = BoostCamMainIcons; + +const Container = styled.div` + width: 90%; + height: 30px; + + margin-left: 15px; + color: #a69c96; + font-size: 17px; + + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + + &:hover { + cursor: pointer; + } +`; + +const ChannelListHeaderSpan = styled.span` + margin-left: 5px; +`; + +const ChannelListHeaderButton = styled.div<{ isButtonVisible: boolean }>` + margin-left: 70px; + visibility: ${(props) => (props.isButtonVisible ? 'visible' : 'hidden')}; +`; + +const ListArrowIcon = styled(ListArrow)<{ isListOpen: boolean }>` + width: 20px; + height: 20px; + fill: #a69c96; + transition: all ease-out 0.3s; + ${(props) => (props.isListOpen ? 'transform: rotate(90deg);' : 'transform: rotate(0deg);')} +`; + +const PlusIcon = styled(Plus)` + width: 20px; + height: 20px; + fill: #a69c96; +`; + +type ChannelListHeaderProps = { + isListOpen: boolean; + setIsListOpen: React.Dispatch>; +}; + +function ChannelListHeader(props: ChannelListHeaderProps): JSX.Element { + const [isButtonVisible, setIsButtonVisible] = useState(false); + const [isDropdownActivated, setIsDropdownActivated] = useState(false); + const { isListOpen, setIsListOpen } = props; + const { + selectedServer, + selectedChannel, + isCreateModalOpen, + isJoinModalOpen, + setIsCreateModalOpen, + setIsJoinModalOpen, + } = useContext(MainStoreContext); + const navigate = useNavigate(); + + const onClickChannelAddButton = (e: React.MouseEvent) => { + e.stopPropagation(); + setIsDropdownActivated(!isDropdownActivated); + }; + + useEffect(() => { + const serverId = selectedServer?.server?.id || 'none'; + + navigate({ + search: `?${createSearchParams({ + serverId, + channelId: selectedChannel, + })}`, + }); + }, [selectedChannel]); + + return ( + setIsButtonVisible(true)} + onMouseLeave={() => setIsButtonVisible(false)} + onClick={() => setIsListOpen(!isListOpen)} + > + + 채널 + + + + + + + + + ); +} + +export default ChannelListHeader; From f3a25f451885dcbcfbba7d4fc8b02c20a997faa8 Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Mon, 22 Nov 2021 17:55:45 +0900 Subject: [PATCH 056/172] =?UTF-8?q?Feat=20:=20Server=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ServerSettingModal의 서버 삭제 클릭 시 선택된 서버를 삭제할 수 있도록 구현했습니다. --- backend/src/server/server.controller.ts | 11 +++--- .../Main/ServerModal/ServerSettingModal.tsx | 38 +++++++++++++++++-- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/backend/src/server/server.controller.ts b/backend/src/server/server.controller.ts index 8b74a60..57b34d3 100644 --- a/backend/src/server/server.controller.ts +++ b/backend/src/server/server.controller.ts @@ -11,6 +11,7 @@ import { HttpException, UseInterceptors, UploadedFile, + HttpCode, } from '@nestjs/common'; import { ServerService } from './server.service'; @@ -94,12 +95,10 @@ export class ServerController { }); } - @Delete('/:id') async deleteServer(@Param('id') id: number): Promise { + @Delete('/:id') + @HttpCode(204) + async deleteServer(@Param('id') id: number): Promise> { await this.serverService.deleteServer(id); - return Object.assign({ - data: { id }, - statusCode: 200, - statusMsg: `deleted successfully`, - }); + return ResponseEntity.noContent(); } } diff --git a/frontend/src/components/Main/ServerModal/ServerSettingModal.tsx b/frontend/src/components/Main/ServerModal/ServerSettingModal.tsx index c3e1f9f..53767dc 100644 --- a/frontend/src/components/Main/ServerModal/ServerSettingModal.tsx +++ b/frontend/src/components/Main/ServerModal/ServerSettingModal.tsx @@ -164,9 +164,11 @@ const CloseIcon = styled(Close)` `; function ServerSettingModal(): JSX.Element { - const { setIsServerSettingModalOpen } = useContext(MainStoreContext); + const { setIsServerSettingModalOpen, selectedServer, setServerList, setSelectedServer } = + useContext(MainStoreContext); const isButtonActive = true; const [imagePreview, setImagePreview] = useState(); + const [messageFailToPost, setMessageFailToPost] = useState(''); const onChangePreviewImage = (e: React.ChangeEvent & { target: HTMLInputElement }) => { const file = e.target.files; @@ -176,6 +178,36 @@ function ServerSettingModal(): JSX.Element { } }; + const getServerList = async (): Promise => { + const response = await fetch(`/api/user/servers`); + const list = await response.json(); + + if (response.status === 200 && list.data.length !== 0) { + setServerList(list.data); + setSelectedServer(list.data[list.data.length - 1]); + } + }; + + const onClickDeleteServer = async () => { + const serverId = selectedServer?.server.id; + + if (serverId) { + const response = await fetch(`api/servers/${serverId}`, { + method: 'DELETE', + }); + + if (response.status === 204) { + getServerList(); + setIsServerSettingModalOpen(false); + } else { + const body = await response.json(); + setMessageFailToPost(body.message); + } + } else { + setMessageFailToPost('선택된 서버가 없습니다.'); + } + }; + /* eslint-disable react/jsx-props-no-spreading */ return ( @@ -222,11 +254,11 @@ function ServerSettingModal(): JSX.Element { 서버 삭제 - + 서버 삭제 - 에러메시지 + {messageFailToPost} From b540bdd90f4b50ef81f4a4d88bcca7ba12c3771a Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Mon, 22 Nov 2021 18:11:30 +0900 Subject: [PATCH 057/172] =?UTF-8?q?Refactor=20:=20ChannelList=EC=9D=98=20C?= =?UTF-8?q?hannelListItem=EC=9D=84=20=EB=B3=84=EB=8F=84=EC=9D=98=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/channel/channel.module.ts | 2 +- ...er.repository.ts => channel.repository.ts} | 0 backend/src/channel/channel.service.ts | 2 +- .../src/user-channel/user-channel.module.ts | 2 +- frontend/src/components/Main/ChannelList.tsx | 59 +----------- .../src/components/Main/ChannelListItem.tsx | 89 +++++++++++++++++++ 6 files changed, 96 insertions(+), 58 deletions(-) rename backend/src/channel/{user.repository.ts => channel.repository.ts} (100%) create mode 100644 frontend/src/components/Main/ChannelListItem.tsx diff --git a/backend/src/channel/channel.module.ts b/backend/src/channel/channel.module.ts index 118b697..0998999 100644 --- a/backend/src/channel/channel.module.ts +++ b/backend/src/channel/channel.module.ts @@ -8,7 +8,7 @@ import { Server } from '../server/server.entity'; import { UserChannelService } from '../user-channel/user-channel.service'; import { UserChannelRepository } from '../user-channel/user-channel.repository'; import { UserRepository } from '../user/user.repository'; -import { ChannelRepository } from './user.repository'; +import { ChannelRepository } from './channel.repository'; @Module({ imports: [ diff --git a/backend/src/channel/user.repository.ts b/backend/src/channel/channel.repository.ts similarity index 100% rename from backend/src/channel/user.repository.ts rename to backend/src/channel/channel.repository.ts diff --git a/backend/src/channel/channel.service.ts b/backend/src/channel/channel.service.ts index 06b2e12..4a36843 100644 --- a/backend/src/channel/channel.service.ts +++ b/backend/src/channel/channel.service.ts @@ -5,7 +5,7 @@ import { Repository } from 'typeorm/index'; import { CreateChannelDto } from './channe.dto'; import { Channel } from './channel.entity'; import { Server } from '../server/server.entity'; -import { ChannelRepository } from './user.repository'; +import { ChannelRepository } from './channel.repository'; @Injectable() export class ChannelService { diff --git a/backend/src/user-channel/user-channel.module.ts b/backend/src/user-channel/user-channel.module.ts index a8672cf..7fd52c2 100644 --- a/backend/src/user-channel/user-channel.module.ts +++ b/backend/src/user-channel/user-channel.module.ts @@ -11,7 +11,7 @@ import { UserChannelRepository } from './user-channel.repository'; import { UserChannelService } from './user-channel.service'; import { ServerRepository } from '../server/server.repository'; import { Server } from '../server/server.entity'; -import { ChannelRepository } from '../channel/user.repository'; +import { ChannelRepository } from '../channel/channel.repository'; @Module({ imports: [ diff --git a/frontend/src/components/Main/ChannelList.tsx b/frontend/src/components/Main/ChannelList.tsx index 19cec3e..14840c6 100644 --- a/frontend/src/components/Main/ChannelList.tsx +++ b/frontend/src/components/Main/ChannelList.tsx @@ -6,8 +6,7 @@ import { BoostCamMainIcons } from '../../utils/SvgIcons'; import { ChannelData } from '../../types/main'; import { MainStoreContext } from './MainStore'; import ChannelListHeader from './ChannelListHeader'; - -const { Hash } = BoostCamMainIcons; +import ChannelListItem from './ChannelListItem'; const Container = styled.div` width: 100%; @@ -31,50 +30,11 @@ const ChannelListBody = styled.div` color: #a69c96; font-size: 15px; `; - -const ChannelListItem = styled.div<{ selected: boolean }>` - width: 100%; - height: 25px; - - display: flex; - flex-direction: row; - justify-content: flex-start; - align-items: center; - - box-sizing: border-box; - padding: 15px 0px 15px 25px; - ${(props) => (props.selected ? 'background-color:#21557C;' : '')} - - &:hover { - cursor: pointer; - background-color: ${(props) => (props.selected ? '#21557C' : '#321832')}; - } -`; - -const ChannelNameSpan = styled.span` - padding: 5px 0px 5px 5px; -`; - -const HashIcon = styled(Hash)` - width: 15px; - height: 15px; - fill: #a69c96; -`; - function ChannelList(): JSX.Element { const [isListOpen, setIsListOpen] = useState(false); - const { selectedServer, selectedChannel, serverChannelList, setSelectedChannel } = useContext(MainStoreContext); + const [clickedChannel, setClickedChannel] = useState(-1); + const { selectedServer, selectedChannel, serverChannelList } = useContext(MainStoreContext); const navigate = useNavigate(); - - const onClickChannelBlock = ({ currentTarget }: React.MouseEvent) => { - const channelId = currentTarget.dataset.id; - if (channelId) setSelectedChannel(channelId); - }; - - const onRightClickChannelItem = (e: React.MouseEvent) => { - e.preventDefault(); - }; - useEffect(() => { const serverId = selectedServer?.server?.id || 'none'; @@ -88,18 +48,7 @@ function ChannelList(): JSX.Element { const listElements = serverChannelList.map((val: ChannelData): JSX.Element => { const selected = val.id === selectedChannel; - return ( - - - {val.name} - - ); + return ; }); return ( diff --git a/frontend/src/components/Main/ChannelListItem.tsx b/frontend/src/components/Main/ChannelListItem.tsx new file mode 100644 index 0000000..d05a5cf --- /dev/null +++ b/frontend/src/components/Main/ChannelListItem.tsx @@ -0,0 +1,89 @@ +import React, { useContext, useEffect, useState } from 'react'; +import styled from 'styled-components'; + +import { BoostCamMainIcons } from '../../utils/SvgIcons'; +import { MainStoreContext } from './MainStore'; +import Dropdown from '../core/Dropdown'; +import DropdownMenu from '../core/DropdownMenu'; + +const { Hash } = BoostCamMainIcons; + +const Container = styled.div<{ selected: boolean }>` + width: 100%; + height: 25px; + + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + + box-sizing: border-box; + padding: 15px 0px 15px 25px; + ${(props) => (props.selected ? 'background-color:#21557C;' : '')} + + &:hover { + cursor: pointer; + background-color: ${(props) => (props.selected ? '#21557C' : '#321832')}; + } +`; + +const ChannelNameSpan = styled.span` + padding: 5px 0px 5px 5px; +`; + +const HashIcon = styled(Hash)` + width: 15px; + height: 15px; + fill: #a69c96; +`; + +type ChannelListItemProps = { + dataId: string; + selected: boolean; + name: string; +}; + +function ChannelListItem(props: ChannelListItemProps): JSX.Element { + const { setSelectedChannel } = useContext(MainStoreContext); + const [isDropdownActivated, setIsDropdownActivated] = useState(false); + const { dataId, selected, name } = props; + + const onClickChannelBlock = ({ currentTarget }: React.MouseEvent) => { + const channelId = currentTarget.dataset.id; + if (channelId) setSelectedChannel(channelId); + }; + + const onRightClickChannelItem = (e: React.MouseEvent) => { + e.preventDefault(); + const channelId = e.currentTarget.dataset.id; + console.log(`channelId = ${channelId}`); + }; + + return ( + + + {name} + + {/* + */} + + + ); +} + +export default ChannelListItem; From 2033a08177c769f661210634580527883cb0d54f Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Mon, 22 Nov 2021 18:22:05 +0900 Subject: [PATCH 058/172] =?UTF-8?q?Refactor=20:=20getUserServerList=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공통으로 사용되는 getUserServerList 함수를 store에 분리하였습니다. --- frontend/src/components/Main/MainStore.tsx | 12 ++++++++++++ .../src/components/Main/ServerListTab.tsx | 15 +++------------ .../Main/ServerModal/CreateServerModal.tsx | 15 +++------------ .../Main/ServerModal/JoinServerModal.tsx | 15 +++------------ .../Main/ServerModal/QuitServerModal.tsx | 19 +++---------------- .../Main/ServerModal/ServerSettingModal.tsx | 16 +++------------- 6 files changed, 27 insertions(+), 65 deletions(-) diff --git a/frontend/src/components/Main/MainStore.tsx b/frontend/src/components/Main/MainStore.tsx index f4c3f4e..1b31820 100644 --- a/frontend/src/components/Main/MainStore.tsx +++ b/frontend/src/components/Main/MainStore.tsx @@ -27,6 +27,17 @@ function MainStore(props: MainStoreProps): JSX.Element { setServerChannelList(list.data); }; + const getUserServerList = async (isServerOrUserServerCreated: boolean): Promise => { + const response = await fetch(`/api/user/servers`); + const list = await response.json(); + + if (response.status === 200 && list.data.length !== 0) { + const selectedServerIndex = isServerOrUserServerCreated ? list.data.length - 1 : 0; + setServerList(list.data); + setSelectedServer(list.data[selectedServerIndex]); + } + }; + useEffect(() => { if (selectedServer) getServerChannelList(); }, [selectedServer]); @@ -57,6 +68,7 @@ function MainStore(props: MainStoreProps): JSX.Element { setIsServerSettingModalOpen, setIsQuitServerModalOpen, setServerList, + getUserServerList, }} > {children} diff --git a/frontend/src/components/Main/ServerListTab.tsx b/frontend/src/components/Main/ServerListTab.tsx index 7439e6b..3f7558b 100644 --- a/frontend/src/components/Main/ServerListTab.tsx +++ b/frontend/src/components/Main/ServerListTab.tsx @@ -93,22 +93,12 @@ function ServerListTab(): JSX.Element { setIsCreateServerModalOpen, setIsJoinServerModalOpen, serverList, - setServerList, + getUserServerList, } = useContext(MainStoreContext); const initChannel = '1'; const navigate = useNavigate(); - const getServerList = async (): Promise => { - const response = await fetch(`/api/user/servers`); - const list = await response.json(); - - if (response.status === 200 && list.data.length !== 0) { - setServerList(list.data); - setSelectedServer(list.data[0]); - } - }; - const onClickServerAddButton = (e: React.MouseEvent) => { e.stopPropagation(); setIsDropdownActivated(!isDropdownActivated); @@ -134,7 +124,8 @@ function ServerListTab(): JSX.Element { }); useEffect(() => { - getServerList(); + const isServerOrUserServerCreated = false; + getUserServerList(isServerOrUserServerCreated); }, []); useEffect(() => { diff --git a/frontend/src/components/Main/ServerModal/CreateServerModal.tsx b/frontend/src/components/Main/ServerModal/CreateServerModal.tsx index 3c52c80..e47ddc2 100644 --- a/frontend/src/components/Main/ServerModal/CreateServerModal.tsx +++ b/frontend/src/components/Main/ServerModal/CreateServerModal.tsx @@ -192,21 +192,11 @@ function CreateServerModal(): JSX.Element { watch, formState: { errors }, } = useForm(); - const { setIsCreateServerModalOpen, setServerList, setSelectedServer } = useContext(MainStoreContext); + const { setIsCreateServerModalOpen, getUserServerList } = useContext(MainStoreContext); const [isButtonActive, setIsButtonActive] = useState(false); const [messageFailToPost, setMessageFailToPost] = useState(''); const [imagePreview, setImagePreview] = useState(); - const getServerList = async (): Promise => { - const response = await fetch(`/api/user/servers`); - const list = await response.json(); - - if (response.status === 200 && list.data.length !== 0) { - setServerList(list.data); - setSelectedServer(list.data[list.data.length - 1]); - } - }; - const onSubmitCreateServerModal = async (data: { name: string; description: string; file: FileList }) => { const formData = new FormData(); const { name, description, file } = data; @@ -221,7 +211,8 @@ function CreateServerModal(): JSX.Element { }); if (response.status === 201) { - getServerList(); + const isServerOrUserServerCreated = true; + getUserServerList(isServerOrUserServerCreated); setIsCreateServerModalOpen(false); } else { const body = await response.json(); diff --git a/frontend/src/components/Main/ServerModal/JoinServerModal.tsx b/frontend/src/components/Main/ServerModal/JoinServerModal.tsx index 88384e1..09378d9 100644 --- a/frontend/src/components/Main/ServerModal/JoinServerModal.tsx +++ b/frontend/src/components/Main/ServerModal/JoinServerModal.tsx @@ -179,20 +179,10 @@ function JoinServerModal(): JSX.Element { watch, formState: { errors }, } = useForm(); - const { setIsJoinServerModalOpen, setServerList, setSelectedServer } = useContext(MainStoreContext); + const { setIsJoinServerModalOpen, getUserServerList } = useContext(MainStoreContext); const [isButtonActive, setIsButtonActive] = useState(false); const [messageFailToPost, setMessageFailToPost] = useState(''); - const getServerList = async (): Promise => { - const response = await fetch(`/api/user/servers`); - const list = await response.json(); - - if (response.status === 200 && list.data.length !== 0) { - setServerList(list.data); - setSelectedServer(list.data[list.data.length - 1]); - } - }; - const onSubmitJoinServerModal = async (data: { serverId: string }) => { const { serverId } = data; const response = await fetch('api/users/servers', { @@ -206,7 +196,8 @@ function JoinServerModal(): JSX.Element { }); if (response.status === 201) { - getServerList(); + const isServerOrUserServerCreated = true; + getUserServerList(isServerOrUserServerCreated); setIsJoinServerModalOpen(false); } else { const body = await response.json(); diff --git a/frontend/src/components/Main/ServerModal/QuitServerModal.tsx b/frontend/src/components/Main/ServerModal/QuitServerModal.tsx index d92b398..0107fa2 100644 --- a/frontend/src/components/Main/ServerModal/QuitServerModal.tsx +++ b/frontend/src/components/Main/ServerModal/QuitServerModal.tsx @@ -139,24 +139,10 @@ const CloseIcon = styled(Close)` `; function QuitServerModal(): JSX.Element { - const { setIsQuitServerModalOpen, setServerList, setSelectedServer, selectedServer } = useContext(MainStoreContext); + const { setIsQuitServerModalOpen, selectedServer, getUserServerList } = useContext(MainStoreContext); const isButtonActive = true; const [messageFailToPost, setMessageFailToPost] = useState(''); - const getServerList = async (): Promise => { - const response = await fetch(`/api/user/servers`); - const list = await response.json(); - - if (response.status === 200) { - setServerList(list.data); - if (list.data.length !== 0) { - setSelectedServer(list.data[0]); - } else { - setSelectedServer(undefined); - } - } - }; - const onClickQuitServer = async () => { const userServerId = selectedServer.id; const response = await fetch(`api/users/servers/${userServerId}`, { @@ -166,7 +152,8 @@ function QuitServerModal(): JSX.Element { }, }); if (response.status === 204) { - getServerList(); + const isServerOrUserServerCreated = false; + getUserServerList(isServerOrUserServerCreated); setIsQuitServerModalOpen(false); } else { const body = await response.json(); diff --git a/frontend/src/components/Main/ServerModal/ServerSettingModal.tsx b/frontend/src/components/Main/ServerModal/ServerSettingModal.tsx index 53767dc..22d40fe 100644 --- a/frontend/src/components/Main/ServerModal/ServerSettingModal.tsx +++ b/frontend/src/components/Main/ServerModal/ServerSettingModal.tsx @@ -164,8 +164,7 @@ const CloseIcon = styled(Close)` `; function ServerSettingModal(): JSX.Element { - const { setIsServerSettingModalOpen, selectedServer, setServerList, setSelectedServer } = - useContext(MainStoreContext); + const { setIsServerSettingModalOpen, selectedServer, getUserServerList } = useContext(MainStoreContext); const isButtonActive = true; const [imagePreview, setImagePreview] = useState(); const [messageFailToPost, setMessageFailToPost] = useState(''); @@ -178,16 +177,6 @@ function ServerSettingModal(): JSX.Element { } }; - const getServerList = async (): Promise => { - const response = await fetch(`/api/user/servers`); - const list = await response.json(); - - if (response.status === 200 && list.data.length !== 0) { - setServerList(list.data); - setSelectedServer(list.data[list.data.length - 1]); - } - }; - const onClickDeleteServer = async () => { const serverId = selectedServer?.server.id; @@ -197,7 +186,8 @@ function ServerSettingModal(): JSX.Element { }); if (response.status === 204) { - getServerList(); + const isServerOrUserServerCreated = false; + getUserServerList(isServerOrUserServerCreated); setIsServerSettingModalOpen(false); } else { const body = await response.json(); From fd9a915ed948be053555557c0d557b29606bbf04 Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Mon, 22 Nov 2021 18:27:48 +0900 Subject: [PATCH 059/172] =?UTF-8?q?Refactor=20:=20=EA=B5=AC=EB=B6=84?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=B4=20Channel=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20Modal=EC=9D=84=20=EC=97=B4=EA=B3=A0=20=EB=8B=AB=EB=8A=94=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=9D=B4=EB=A6=84=EB=93=A4=EC=9D=84=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is{행동}ChannelModalOpen 형식으로 통일하였습니다. --- frontend/src/components/Main/ChannelList.tsx | 1 - .../src/components/Main/ChannelListHeader.tsx | 16 +++++++------- .../src/components/Main/ChannelListItem.tsx | 20 +++++++++++------- .../Main/ChannelModal/CreateChannelModal.tsx | 8 +++---- .../Main/ChannelModal/JoinChannelModal.tsx | 8 +++---- frontend/src/components/Main/MainPage.tsx | 8 +++---- frontend/src/components/Main/MainStore.tsx | 21 +++++++++++++------ 7 files changed, 48 insertions(+), 34 deletions(-) diff --git a/frontend/src/components/Main/ChannelList.tsx b/frontend/src/components/Main/ChannelList.tsx index 14840c6..4a66721 100644 --- a/frontend/src/components/Main/ChannelList.tsx +++ b/frontend/src/components/Main/ChannelList.tsx @@ -32,7 +32,6 @@ const ChannelListBody = styled.div` `; function ChannelList(): JSX.Element { const [isListOpen, setIsListOpen] = useState(false); - const [clickedChannel, setClickedChannel] = useState(-1); const { selectedServer, selectedChannel, serverChannelList } = useContext(MainStoreContext); const navigate = useNavigate(); useEffect(() => { diff --git a/frontend/src/components/Main/ChannelListHeader.tsx b/frontend/src/components/Main/ChannelListHeader.tsx index b4d98de..4df1a0a 100644 --- a/frontend/src/components/Main/ChannelListHeader.tsx +++ b/frontend/src/components/Main/ChannelListHeader.tsx @@ -62,10 +62,10 @@ function ChannelListHeader(props: ChannelListHeaderProps): JSX.Element { const { selectedServer, selectedChannel, - isCreateModalOpen, - isJoinModalOpen, - setIsCreateModalOpen, - setIsJoinModalOpen, + isCreateChannelModalOpen, + isJoinChannelModalOpen, + setIsCreateChannelModalOpen, + setIsJoinChannelModalOpen, } = useContext(MainStoreContext); const navigate = useNavigate(); @@ -99,14 +99,14 @@ function ChannelListHeader(props: ChannelListHeaderProps): JSX.Element { diff --git a/frontend/src/components/Main/ChannelListItem.tsx b/frontend/src/components/Main/ChannelListItem.tsx index d05a5cf..dd985ee 100644 --- a/frontend/src/components/Main/ChannelListItem.tsx +++ b/frontend/src/components/Main/ChannelListItem.tsx @@ -44,7 +44,13 @@ type ChannelListItemProps = { }; function ChannelListItem(props: ChannelListItemProps): JSX.Element { - const { setSelectedChannel } = useContext(MainStoreContext); + const { + setSelectedChannel, + isUpdateChannelModalOpen, + isQuitChannelModalOpen, + setIsUpdateChannelModalOpen, + setIsQuitChannelModalOpen, + } = useContext(MainStoreContext); const [isDropdownActivated, setIsDropdownActivated] = useState(false); const { dataId, selected, name } = props; @@ -69,18 +75,18 @@ function ChannelListItem(props: ChannelListItemProps): JSX.Element { {name} - {/* */} + state={isQuitChannelModalOpen} + stateSetter={setIsQuitChannelModalOpen} + /> ); diff --git a/frontend/src/components/Main/ChannelModal/CreateChannelModal.tsx b/frontend/src/components/Main/ChannelModal/CreateChannelModal.tsx index 543d418..1b39487 100644 --- a/frontend/src/components/Main/ChannelModal/CreateChannelModal.tsx +++ b/frontend/src/components/Main/ChannelModal/CreateChannelModal.tsx @@ -175,7 +175,7 @@ function CreateChannelModal(): JSX.Element { watch, formState: { errors }, } = useForm(); - const { selectedServer, setIsCreateModalOpen, getServerChannelList } = useContext(MainStoreContext); + const { selectedServer, setIsCreateChannelModalOpen, getServerChannelList } = useContext(MainStoreContext); const [isButtonActive, setIsButtonActive] = useState(false); const onSubmitCreateChannelModal = async (data: { name: string; description: string }) => { @@ -192,7 +192,7 @@ function CreateChannelModal(): JSX.Element { }), }); getServerChannelList(); - setIsCreateModalOpen(false); + setIsCreateChannelModalOpen(false); }; useEffect(() => { @@ -204,12 +204,12 @@ function CreateChannelModal(): JSX.Element { /* eslint-disable react/jsx-props-no-spreading */ return ( - setIsCreateModalOpen(false)} /> + setIsCreateChannelModalOpen(false)} /> 채널 생성 - setIsCreateModalOpen(false)}> + setIsCreateChannelModalOpen(false)}> diff --git a/frontend/src/components/Main/ChannelModal/JoinChannelModal.tsx b/frontend/src/components/Main/ChannelModal/JoinChannelModal.tsx index c16f7cf..98196d8 100644 --- a/frontend/src/components/Main/ChannelModal/JoinChannelModal.tsx +++ b/frontend/src/components/Main/ChannelModal/JoinChannelModal.tsx @@ -191,7 +191,7 @@ const CloseIcon = styled(Close)` `; function JoinChannelModal(): JSX.Element { - const { selectedServer, setIsJoinModalOpen, getServerChannelList } = useContext(MainStoreContext); + const { selectedServer, setIsJoinChannelModalOpen, getServerChannelList } = useContext(MainStoreContext); const [channelList, setChannelList] = useState([]); const getNotJoinedChannelList = async () => { @@ -212,7 +212,7 @@ function JoinChannelModal(): JSX.Element { }), }); getServerChannelList(); - setIsJoinModalOpen(false); + setIsJoinChannelModalOpen(false); }; useEffect(() => { @@ -232,12 +232,12 @@ function JoinChannelModal(): JSX.Element { /* eslint-disable react/jsx-props-no-spreading */ return ( - setIsJoinModalOpen(false)} /> + setIsJoinChannelModalOpen(false)} /> 채널 참가 - setIsJoinModalOpen(false)}> + setIsJoinChannelModalOpen(false)}> diff --git a/frontend/src/components/Main/MainPage.tsx b/frontend/src/components/Main/MainPage.tsx index e6195de..0d52e66 100644 --- a/frontend/src/components/Main/MainPage.tsx +++ b/frontend/src/components/Main/MainPage.tsx @@ -24,8 +24,8 @@ const Container = styled.div` function MainPage(): JSX.Element { const { - isCreateModalOpen, - isJoinModalOpen, + isCreateChannelModalOpen, + isJoinChannelModalOpen, isCreateServerModalOpen, isJoinServerModalOpen, isServerInfoModalOpen, @@ -36,8 +36,8 @@ function MainPage(): JSX.Element { return ( - {isCreateModalOpen && } - {isJoinModalOpen && } + {isCreateChannelModalOpen && } + {isJoinChannelModalOpen && } {isCreateServerModalOpen && } {isJoinServerModalOpen && } {isServerSettingModalOpen && } diff --git a/frontend/src/components/Main/MainStore.tsx b/frontend/src/components/Main/MainStore.tsx index f4c3f4e..535fa2f 100644 --- a/frontend/src/components/Main/MainStore.tsx +++ b/frontend/src/components/Main/MainStore.tsx @@ -12,13 +12,18 @@ function MainStore(props: MainStoreProps): JSX.Element { const [selectedServer, setSelectedServer] = useState(); const [selectedChannel, setSelectedChannel] = useState('1'); const [serverChannelList, setServerChannelList] = useState([]); - const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); - const [isJoinModalOpen, setIsJoinModalOpen] = useState(false); + + const [isCreateChannelModalOpen, setIsCreateChannelModalOpen] = useState(false); + const [isJoinChannelModalOpen, setIsJoinChannelModalOpen] = useState(false); + const [isUpdateChannelModalOpen, setIsUpdateChannelModalOpen] = useState(false); + const [isQuitChannelModalOpen, setIsQuitChannelModalOpen] = useState(false); + const [isCreateServerModalOpen, setIsCreateServerModalOpen] = useState(false); const [isJoinServerModalOpen, setIsJoinServerModalOpen] = useState(false); const [isServerInfoModalOpen, setIsServerInfoModalOpen] = useState(false); const [isServerSettingModalOpen, setIsServerSettingModalOpen] = useState(false); const [isQuitServerModalOpen, setIsQuitServerModalOpen] = useState(false); + const [serverList, setServerList] = useState([]); const getServerChannelList = async (): Promise => { @@ -36,8 +41,10 @@ function MainStore(props: MainStoreProps): JSX.Element { value={{ selectedServer, selectedChannel, - isCreateModalOpen, - isJoinModalOpen, + isCreateChannelModalOpen, + isJoinChannelModalOpen, + isUpdateChannelModalOpen, + isQuitChannelModalOpen, serverChannelList, isCreateServerModalOpen, isJoinServerModalOpen, @@ -47,8 +54,10 @@ function MainStore(props: MainStoreProps): JSX.Element { serverList, setSelectedServer, setSelectedChannel, - setIsCreateModalOpen, - setIsJoinModalOpen, + setIsCreateChannelModalOpen, + setIsJoinChannelModalOpen, + setIsUpdateChannelModalOpen, + setIsQuitChannelModalOpen, setServerChannelList, getServerChannelList, setIsCreateServerModalOpen, From 28f97ff98960c5d1859671cee98ae44ed930e5e8 Mon Sep 17 00:00:00 2001 From: K Date: Mon, 22 Nov 2021 18:45:37 +0900 Subject: [PATCH 060/172] Feat : Message.newInstance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Message와 관련 있는 Entity를 받아 생성하는 메소드 --- backend/src/message/message.entity.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/src/message/message.entity.ts b/backend/src/message/message.entity.ts index 0a74c03..28f3f81 100644 --- a/backend/src/message/message.entity.ts +++ b/backend/src/message/message.entity.ts @@ -24,4 +24,12 @@ export class Message { @ManyToOne(() => User) sender: User; + + static newInstace(contents: string, channel: Channel, sender: User): Message { + const newMessage = new Message(); + newMessage.contents = contents; + newMessage.channel = channel; + newMessage.sender = sender; + return newMessage; + } } From 8b34f175dcb4e87dc6c5a48f127e7bb366c8cca7 Mon Sep 17 00:00:00 2001 From: K Date: Mon, 22 Nov 2021 18:50:04 +0900 Subject: [PATCH 061/172] Feat : MessagaService.sendMessage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 메시지 서비스에서 메시지를 db에 저장하고 반환합니다. - sender는 controller에서 session 에 user가 있을 것이기 때문에 존재하는지 확인을 따로 하지 않습니다. - 메시지를 보내려는 채널이 존재하지 않으면 NotFoundException이 발생합니다. --- backend/src/message/message.service.ts | 33 ++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 backend/src/message/message.service.ts diff --git a/backend/src/message/message.service.ts b/backend/src/message/message.service.ts new file mode 100644 index 0000000..a1ed4ca --- /dev/null +++ b/backend/src/message/message.service.ts @@ -0,0 +1,33 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Channel } from '../channel/channel.entity'; +import { User } from '../user/user.entity'; +import { Message } from './message.entity'; + +@Injectable() +export class MessageService { + constructor( + @InjectRepository(User) private userRepository: Repository, + @InjectRepository(Channel) private channelReposiotry: Repository, + @InjectRepository(Message) private messageRepository: Repository, + ) {} + + async sendMessage( + senderId: number, + channelId: number, + contents: string, + ): Promise { + let newMessage; + const sender = await this.userRepository.findOne(senderId); + const channel = await this.channelReposiotry.findOne(channelId); + + if (!channel) { + throw new NotFoundException('채널이 존재하지 않습니다.'); + } + + newMessage = Message.newInstace(contents, channel, sender); + newMessage = await this.messageRepository.save(newMessage); + return newMessage; + } +} From a59ece46bdee6b8c9cb637ed2b3fd21ffaf8be50 Mon Sep 17 00:00:00 2001 From: K Date: Mon, 22 Nov 2021 18:54:01 +0900 Subject: [PATCH 062/172] Feat : MessageController.sendMessage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/messages 메시지를 보내는 endpoint입니다. - 로그인한 사용자만 메시지를 보낼 수 있습니다. - 바디는 다음과 같습니다 { "channelId": number, "contents": string, } --- backend/src/message/message.controller.ts | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 backend/src/message/message.controller.ts diff --git a/backend/src/message/message.controller.ts b/backend/src/message/message.controller.ts new file mode 100644 index 0000000..5ad44b8 --- /dev/null +++ b/backend/src/message/message.controller.ts @@ -0,0 +1,24 @@ +import { Body, Controller, Post, Session, UseGuards } from '@nestjs/common'; +import { LoginGuard } from '../login/login.guard'; +import { ExpressSession } from '../types/session'; +import { MessageService } from './message.service'; + +@Controller('/api/messages') +@UseGuards(LoginGuard) +export class MessageController { + constructor(private messageService: MessageService) {} + + @Post() + async sendMessage( + @Session() session: ExpressSession, + @Body('channelId') channelId: number, + @Body('contents') contents: string, + ) { + const sender = session.user; + return await this.messageService.sendMessage( + sender.id, + channelId, + contents, + ); + } +} From 1c3ce5693e31aeafa19ba97028c8810b35dd0a28 Mon Sep 17 00:00:00 2001 From: K Date: Mon, 22 Nov 2021 18:56:19 +0900 Subject: [PATCH 063/172] =?UTF-8?q?Feat=20:=20MessageModule=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EA=B4=80=EB=A0=A8=20=EA=B0=9D=EC=B2=B4=EB=93=A4?= =?UTF-8?q?=EC=9D=84=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Service와 Controller, 그리고 typeorm 관련 entity를 추가합니다. --- backend/src/message/message.module.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/src/message/message.module.ts b/backend/src/message/message.module.ts index f5ca102..3e11f9e 100644 --- a/backend/src/message/message.module.ts +++ b/backend/src/message/message.module.ts @@ -2,8 +2,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Message } from './message.entity'; +import { MessageController } from './message.controller'; +import { MessageService } from './message.service'; +import { User } from '../user/user.entity'; +import { Channel } from '../channel/channel.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Message])], + imports: [TypeOrmModule.forFeature([Message, User, Channel])], + controllers: [MessageController], + providers: [MessageService], }) export class MessageModule {} From 8e57d0ea5e3d65b08aea52899fe2b1baaf803c39 Mon Sep 17 00:00:00 2001 From: K Date: Mon, 22 Nov 2021 18:57:59 +0900 Subject: [PATCH 064/172] =?UTF-8?q?Chore=20:=20.env.sample=EC=97=90=20?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EB=B2=84=20=ED=81=B4=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=EB=93=9C=20=EA=B4=80=EB=A0=A8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.sample | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/.env.sample b/backend/.env.sample index 86c2c5e..a2000a7 100644 --- a/backend/.env.sample +++ b/backend/.env.sample @@ -2,3 +2,7 @@ DATABASE_USER=scott DATABASE_PASSWORD=tiger DATABASE_NAME=boostcam DATABASE_HOST=localhost +NCP_STORAGE_ACCESS_KEY= +NCP_STORAGE_SECRET_KEY= +NCP_STORAGE_BUCKET_NAME= +NCP_STORAGE_REGION= From 524e5d78615094110bf58c9a29a626551c9996ab Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Mon, 22 Nov 2021 19:18:25 +0900 Subject: [PATCH 065/172] =?UTF-8?q?Feat=20:=20Server=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20=EA=B6=8C=ED=95=9C=20=EA=B4=80=EB=A0=A8=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 삭제하려는 사용자가 Server의 owner가 아닐 경우 에러를 발생시키도록 변경하였습니다. - 상태코드 및 throw하는 exception을 더 명확하게 처리하였습니다. --- backend/src/server/server.controller.ts | 28 +++++++++++++++---- backend/src/server/server.repository.ts | 7 +++++ backend/src/server/server.service.ts | 22 +++++++++++++-- .../src/user-server/user-server.controller.ts | 15 +++++++--- .../src/user-server/user-server.service.ts | 11 ++++++-- 5 files changed, 67 insertions(+), 16 deletions(-) diff --git a/backend/src/server/server.controller.ts b/backend/src/server/server.controller.ts index 57b34d3..c7baa57 100644 --- a/backend/src/server/server.controller.ts +++ b/backend/src/server/server.controller.ts @@ -12,6 +12,7 @@ import { UseInterceptors, UploadedFile, HttpCode, + HttpStatus, } from '@nestjs/common'; import { ServerService } from './server.service'; @@ -58,7 +59,7 @@ export class ServerController { @Post() @UseGuards(LoginGuard) @UseInterceptors(FileInterceptor('icon')) - async saveServer( + async createServer( @Session() session: ExpressSession, @Body() requestServerDto: RequestServerDto, @@ -79,7 +80,10 @@ export class ServerController { ); return ResponseEntity.created(newServer.id); } catch (error) { - throw new HttpException(error.response, 403); + if (error instanceof HttpException) { + throw error; + } + throw new HttpException(error.message, HttpStatus.BAD_REQUEST); } } @@ -96,9 +100,21 @@ export class ServerController { } @Delete('/:id') - @HttpCode(204) - async deleteServer(@Param('id') id: number): Promise> { - await this.serverService.deleteServer(id); - return ResponseEntity.noContent(); + @HttpCode(HttpStatus.NO_CONTENT) + async deleteServer( + @Session() + session: ExpressSession, + @Param('id') id: number, + ): Promise> { + try { + const user = session.user; + await this.serverService.deleteServer(id, user); + return ResponseEntity.noContent(); + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + throw new HttpException(error.message, HttpStatus.BAD_REQUEST); + } } } diff --git a/backend/src/server/server.repository.ts b/backend/src/server/server.repository.ts index 3dad626..6a1793c 100644 --- a/backend/src/server/server.repository.ts +++ b/backend/src/server/server.repository.ts @@ -10,4 +10,11 @@ export class ServerRepository extends Repository { .where('server.id = :serverId', { serverId: serverId }) .getOne(); } + + findOneWithOwner(serverId: number) { + return this.createQueryBuilder('server') + .leftJoinAndSelect('server.owner', 'user') + .where('server.id = :serverId', { serverId: serverId }) + .getOne(); + } } diff --git a/backend/src/server/server.service.ts b/backend/src/server/server.service.ts index 2bfe35d..bf0deac 100644 --- a/backend/src/server/server.service.ts +++ b/backend/src/server/server.service.ts @@ -1,4 +1,10 @@ -import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { + BadRequestException, + ForbiddenException, + forwardRef, + Inject, + Injectable, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { User } from '../user/user.entity'; @@ -6,6 +12,7 @@ import { Server } from './server.entity'; import RequestServerDto from './dto/RequestServerDto'; import { UserServerService } from '../user-server/user-server.service'; import { ServerRepository } from './server.repository'; +import { DeleteResult } from 'typeorm'; @Injectable() export class ServerService { @@ -47,8 +54,17 @@ export class ServerService { await this.serverRepository.update(id, server); } - async deleteServer(id: number): Promise { - await this.serverRepository.delete({ id: id }); + async deleteServer(id: number, user: User) { + const server = await this.serverRepository.findOneWithOwner(id); + + if (server.owner.id !== user.id) { + throw new ForbiddenException('삭제 권한이 없습니다.'); + } + if (!server) { + throw new BadRequestException('해당 서버가 존재하지 않습니다.'); + } + + return this.serverRepository.delete({ id: id }); } findOneWithUsers(serverId: number): Promise { diff --git a/backend/src/user-server/user-server.controller.ts b/backend/src/user-server/user-server.controller.ts index 690392a..4ab3a08 100644 --- a/backend/src/user-server/user-server.controller.ts +++ b/backend/src/user-server/user-server.controller.ts @@ -8,6 +8,7 @@ import { UseGuards, HttpException, HttpCode, + HttpStatus, } from '@nestjs/common'; import { Server } from '../server/server.entity'; import { LoginGuard } from '../login/login.guard'; @@ -21,7 +22,7 @@ export class UserServerController { constructor(private userServerService: UserServerService) {} @Post() - async create( + async createUserServer( @Session() session: ExpressSession, @Body() server: Server, @@ -34,18 +35,24 @@ export class UserServerController { ); return ResponseEntity.created(newUserServer.id); } catch (error) { - throw new HttpException(error.response, 403); + if (error instanceof HttpException) { + throw error; + } + throw new HttpException(error.message, HttpStatus.BAD_REQUEST); } } @Delete('/:id') - @HttpCode(204) + @HttpCode(HttpStatus.NO_CONTENT) delete(@Param('id') id: number) { try { this.userServerService.deleteById(id); return ResponseEntity.noContent(); } catch (error) { - throw new HttpException(error.response, 403); + if (error instanceof HttpException) { + throw error; + } + throw new HttpException(error.message, HttpStatus.BAD_REQUEST); } } } diff --git a/backend/src/user-server/user-server.service.ts b/backend/src/user-server/user-server.service.ts index 08bf8cb..c3b5bd8 100644 --- a/backend/src/user-server/user-server.service.ts +++ b/backend/src/user-server/user-server.service.ts @@ -1,4 +1,9 @@ -import { forwardRef, HttpException, Inject, Injectable } from '@nestjs/common'; +import { + BadRequestException, + forwardRef, + Inject, + Injectable, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { UserServerRepository } from './user-server.repository'; import { UserServer } from './user-server.entity'; @@ -21,14 +26,14 @@ export class UserServerService { newUserServer.server = await this.serverService.findOne(serverId); if (newUserServer.server == undefined) { - throw new HttpException('해당 서버가 존재하지 않습니다.', 400); + throw new BadRequestException('존재하지 않는 서버입니다.'); } const userServer = await this.userServerRepository.findByUserIdAndServerId( user.id, serverId, ); if (userServer !== undefined) { - throw new HttpException('이미 등록된 서버입니다.', 400); + throw new BadRequestException('이미 등록된 서버입니다.'); } return this.userServerRepository.save(newUserServer); From c9b0ca244cfad01d15de3beb0e62bd8a253cdbdc Mon Sep 17 00:00:00 2001 From: K Date: Mon, 22 Nov 2021 19:32:20 +0900 Subject: [PATCH 066/172] =?UTF-8?q?Feat=20:=20=EC=9C=A0=EC=A0=80=EA=B0=80?= =?UTF-8?q?=20=ED=95=B4=EB=8B=B9=20=EC=B1=84=EB=84=90=EC=97=90=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=ED=95=A0=20=EC=88=98=20=EC=97=86=EB=8A=94=20=EA=B2=BD?= =?UTF-8?q?=EC=9A=B0=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit userServer와 channel을 inner join하여 결과가 없는 경우면, 해당 채널이 서버에 존재하지 않고, 유저가 server에 참여한 상태가 아니므로 접근할 수 없도록 합니다. --- backend/src/message/message.module.ts | 3 ++- backend/src/message/message.service.ts | 21 ++++++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/backend/src/message/message.module.ts b/backend/src/message/message.module.ts index 3e11f9e..079b3da 100644 --- a/backend/src/message/message.module.ts +++ b/backend/src/message/message.module.ts @@ -6,9 +6,10 @@ import { MessageController } from './message.controller'; import { MessageService } from './message.service'; import { User } from '../user/user.entity'; import { Channel } from '../channel/channel.entity'; +import { UserServer } from '../user-server/user-server.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Message, User, Channel])], + imports: [TypeOrmModule.forFeature([Message, User, Channel, UserServer])], controllers: [MessageController], providers: [MessageService], }) diff --git a/backend/src/message/message.service.ts b/backend/src/message/message.service.ts index a1ed4ca..1a98773 100644 --- a/backend/src/message/message.service.ts +++ b/backend/src/message/message.service.ts @@ -1,7 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Channel } from '../channel/channel.entity'; +import { UserServer } from '../user-server/user-server.entity'; import { User } from '../user/user.entity'; import { Message } from './message.entity'; @@ -9,6 +10,8 @@ import { Message } from './message.entity'; export class MessageService { constructor( @InjectRepository(User) private userRepository: Repository, + @InjectRepository(UserServer) + private userServerRepository: Repository, @InjectRepository(Channel) private channelReposiotry: Repository, @InjectRepository(Message) private messageRepository: Repository, ) {} @@ -19,13 +22,21 @@ export class MessageService { contents: string, ): Promise { let newMessage; - const sender = await this.userRepository.findOne(senderId); - const channel = await this.channelReposiotry.findOne(channelId); - if (!channel) { - throw new NotFoundException('채널이 존재하지 않습니다.'); + const userServer = await this.userServerRepository + .createQueryBuilder('userServer') + .innerJoin(Channel, 'channel', 'channel.serverId = userServer.serverId') + .where('channel.id = :channelId', { channelId }) + .andWhere('userServer.userId = :senderId', { senderId }) + .getOne(); + + if (!userServer) { + throw new BadRequestException('잘못된 요청'); } + const sender = await this.userRepository.findOne(senderId); + const channel = await this.channelReposiotry.findOne(channelId); + newMessage = Message.newInstace(contents, channel, sender); newMessage = await this.messageRepository.save(newMessage); return newMessage; From e739f2d526c43db1c0eba8c394fa0c5540cb10a6 Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Mon, 22 Nov 2021 23:16:53 +0900 Subject: [PATCH 067/172] =?UTF-8?q?Feat=20:=20UpdateChannelModal=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/Main/ChannelListItem.tsx | 41 ++- .../Main/ChannelModal/UpdateChannelModal.tsx | 260 ++++++++++++++++++ frontend/src/components/Main/MainPage.tsx | 3 + frontend/src/components/core/DropdownMenu.tsx | 2 + 4 files changed, 292 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/Main/ChannelModal/UpdateChannelModal.tsx diff --git a/frontend/src/components/Main/ChannelListItem.tsx b/frontend/src/components/Main/ChannelListItem.tsx index dd985ee..52d36fe 100644 --- a/frontend/src/components/Main/ChannelListItem.tsx +++ b/frontend/src/components/Main/ChannelListItem.tsx @@ -37,6 +37,14 @@ const HashIcon = styled(Hash)` fill: #a69c96; `; +const DropdownContainer = styled.div` + margin-left: 25px; +`; + +const QuitDropdownMenu = styled.div` + color: red; +`; + type ChannelListItemProps = { dataId: string; selected: boolean; @@ -63,6 +71,7 @@ function ChannelListItem(props: ChannelListItemProps): JSX.Element { e.preventDefault(); const channelId = e.currentTarget.dataset.id; console.log(`channelId = ${channelId}`); + setIsDropdownActivated(!isDropdownActivated); }; return ( @@ -74,20 +83,24 @@ function ChannelListItem(props: ChannelListItemProps): JSX.Element { > {name} - - - - + + + + + + + + ); } diff --git a/frontend/src/components/Main/ChannelModal/UpdateChannelModal.tsx b/frontend/src/components/Main/ChannelModal/UpdateChannelModal.tsx new file mode 100644 index 0000000..31df01d --- /dev/null +++ b/frontend/src/components/Main/ChannelModal/UpdateChannelModal.tsx @@ -0,0 +1,260 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import styled from 'styled-components'; + +import { MainStoreContext } from '../MainStore'; +import { BoostCamMainIcons } from '../../../utils/SvgIcons'; + +const { Close } = BoostCamMainIcons; + +const Container = styled.div` + position: fixed; + width: 100vw; + height: 100vh; + left: 0px; + right: 0px; + + display: flex; + justify-content: space-around; + align-items: center; +`; + +const ModalBackground = styled.div` + position: fixed; + left: 0px; + right: 0px; + width: 100%; + height: 100%; + background-color: rgb(0, 0, 0, 0.5); +`; + +const ModalBox = styled.div` + width: 35%; + min-width: 400px; + height: 50%; + min-height: 450px; + + background-color: #222322; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + border-radius: 20px; + + z-index: 3; +`; + +const ModalInnerBox = styled.div` + width: 100%; + height: 100%; + padding: 30px 10px; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; +`; + +const ModalHeader = styled.div` + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +`; + +const ModalTitle = styled.span` + margin-left: 25px; + padding: 10px 5px; + + color: #cbc4b9; + font-size: 32px; + font-weight: 600; +`; + +const ModalDescription = styled.span` + margin-left: 25px; + padding: 10px 5px; + + color: #cbc4b9; + font-size: 15px; +`; + +const Form = styled.form` + width: 90%; + height: 70%; + border-radius: 20px; + margin: 30px 0px 0px 25px; + + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; +`; + +const InputDiv = styled.div` + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; +`; + +const InputName = styled.span` + color: #cbc4b9; + font-size: 20px; + font-weight: 500; +`; + +const Input = styled.input` + width: 90%; + border: none; + outline: none; + padding: 15px 10px; + margin-top: 10px; + border-radius: 10px; +`; + +const InputErrorMessage = styled.span` + padding: 5px 0px; + color: red; +`; + +const SubmitButton = styled.button<{ isButtonActive: boolean }>` + width: 100px; + height: 50px; + background: none; + + padding: 15px 10px; + + border: 0; + outline: 0; + + text-align: center; + vertical-align: middle; + + border-radius: 10px; + background-color: ${(props) => (props.isButtonActive ? '#26a9ca' : 'gray')}; + cursor: pointer; + transition: all 0.3s; + + &:hover { + background-color: ${(props) => (props.isButtonActive ? '#2dc2e6' : 'gray')}; + transition: all 0.3s; + } +`; + +const ModalCloseButton = styled.div` + width: 32px; + height: 32px; + display: flex; + flex-direction: center; + align-items: center; + + cursor: pointer; + margin-right: 25px; +`; + +const CloseIcon = styled(Close)` + width: 20px; + height: 20px; + fill: #a69c96; +`; + +type UpdateModalForm = { + name: string; + description: string; +}; + +function UpdateChannelModal(): JSX.Element { + const { + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm(); + const { selectedServer, selectedChannel, setIsUpdateChannelModalOpen, getServerChannelList } = + useContext(MainStoreContext); + const [isButtonActive, setIsButtonActive] = useState(false); + + const onSubmitUpdateChannelModal = async (data: { name: string; description: string }) => { + const { name, description } = data; + await fetch('api/channel', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: name.trim(), + description: description.trim(), + serverId: selectedServer.server.id, + }), + }); + getServerChannelList(); + setIsUpdateChannelModalOpen(false); + }; + + const getSelectedChannelData = async () => { + console.log(selectedServer, selectedChannel); + const response = await fetch(`/api/channel/${selectedChannel}`); + const data = await response.json(); + console.log(data); + }; + + useEffect(() => { + getSelectedChannelData(); + }, []); + + useEffect(() => { + const { name, description } = watch(); + const isActive = name.trim().length > 2 && description.trim().length > 0; + setIsButtonActive(isActive); + }, [watch()]); + + /* eslint-disable react/jsx-props-no-spreading */ + return ( + + setIsUpdateChannelModalOpen(false)} /> + + + + 채널 수정 + setIsUpdateChannelModalOpen(false)}> + + + + 선택한 채널에 대한 내용을 변경할 수 있습니다. +
+ + 이름 + value.trim().length > 2 || '"이름" 칸은 3글자 이상 입력되어야합니다!', + })} + placeholder="채널명을 입력해주세요" + /> + {errors.name && {errors.name.message}} + + + 설명 + value.trim().length > 0 || '"설명" 칸은 꼭 입력되어야합니다!', + })} + placeholder="채널 설명을 입력해주세요" + /> + {errors.description && {errors.description.message}} + + + 수정 + +
+
+
+
+ ); +} + +export default UpdateChannelModal; diff --git a/frontend/src/components/Main/MainPage.tsx b/frontend/src/components/Main/MainPage.tsx index 0d52e66..1fd17c5 100644 --- a/frontend/src/components/Main/MainPage.tsx +++ b/frontend/src/components/Main/MainPage.tsx @@ -11,6 +11,7 @@ import JoinServerModal from './ServerModal/JoinServerModal'; import ServerSettingModal from './ServerModal/ServerSettingModal'; import ServerInfoModal from './ServerModal/ServerInfoModal'; import QuitServerModal from './ServerModal/QuitServerModal'; +import UpdateChannelModal from './ChannelModal/UpdateChannelModal'; const Container = styled.div` width: 100vw; @@ -26,6 +27,7 @@ function MainPage(): JSX.Element { const { isCreateChannelModalOpen, isJoinChannelModalOpen, + isUpdateChannelModalOpen, isCreateServerModalOpen, isJoinServerModalOpen, isServerInfoModalOpen, @@ -38,6 +40,7 @@ function MainPage(): JSX.Element { {isCreateChannelModalOpen && } {isJoinChannelModalOpen && } + {isUpdateChannelModalOpen && } {isCreateServerModalOpen && } {isJoinServerModalOpen && } {isServerSettingModalOpen && } diff --git a/frontend/src/components/core/DropdownMenu.tsx b/frontend/src/components/core/DropdownMenu.tsx index 39fb518..abd42c4 100644 --- a/frontend/src/components/core/DropdownMenu.tsx +++ b/frontend/src/components/core/DropdownMenu.tsx @@ -4,6 +4,8 @@ import styled from 'styled-components'; const Container = styled.li` border-bottom: 1px solid #dddddd; + padding: 2px 5px; + &:last-child { border: none; } From 95ba4a2913e172a812fb1fb35d1052e480f3d115 Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Mon, 22 Nov 2021 23:33:51 +0900 Subject: [PATCH 068/172] =?UTF-8?q?Test=20:=20owner=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20exception=20=EC=B2=98=EB=A6=AC=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server service의 deleteServer함수에 대한 테스트코드를 추가하였습니다. - user-server service의 create함수에서 에러발생시 테스트코드 기댓값을 변경하였습니다. --- backend/src/server/server.service.spec.ts | 55 +++++++++++++++++-- backend/src/server/server.service.ts | 11 ++-- .../user-server/user-server.service.spec.ts | 11 +++- 3 files changed, 62 insertions(+), 15 deletions(-) diff --git a/backend/src/server/server.service.spec.ts b/backend/src/server/server.service.spec.ts index 110da0a..03e45bc 100644 --- a/backend/src/server/server.service.spec.ts +++ b/backend/src/server/server.service.spec.ts @@ -1,5 +1,7 @@ +import { HttpStatus } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; +import { DeleteResult } from 'typeorm'; import { UserServer } from '../user-server/user-server.entity'; import { UserServerRepository } from '../user-server/user-server.repository'; import { UserServerService } from '../user-server/user-server.service'; @@ -21,6 +23,8 @@ const mockServerRepository = () => ({ save: jest.fn(), findOne: jest.fn(), findOneWithUsers: jest.fn(), + findOneWithOwner: jest.fn(), + delete: jest.fn(), }); type MockUserServerRepository = Partial< @@ -36,7 +40,7 @@ describe('ServerService', () => { let requestServerDto: RequestServerDto; let newServer: Server; let newUserServer: UserServer; - let existServer: Server; + let existsServer: Server; const existsServerId = 1; const userId = 1; @@ -89,13 +93,53 @@ describe('ServerService', () => { describe('findOneWithUsers()', () => { it('정상적인 값을 입력할 경우', async () => { - serverRepository.findOneWithUsers.mockResolvedValue(existServer); + serverRepository.findOneWithUsers.mockResolvedValue(existsServer); const serverWithUseres = await serverService.findOneWithUsers( existsServerId, ); - expect(serverWithUseres).toBe(existServer); + expect(serverWithUseres).toBe(existsServer); + }); + }); + + describe('deleteServer()', () => { + it('정상적인 값을 입력할 경우', async () => { + const deleteResult = new DeleteResult(); + deleteResult.affected = 1; + serverRepository.findOneWithOwner.mockResolvedValue(existsServer); + serverRepository.delete.mockResolvedValue(deleteResult); + + const result = await serverService.deleteServer(existsServerId, user); + + expect(result.affected).toBe(deleteResult.affected); + }); + + it('서버가 존재하지 않을 경우', async () => { + const nonExistsId = 0; + serverRepository.findOneWithOwner.mockResolvedValue(undefined); + + try { + await serverService.deleteServer(nonExistsId, user); + } catch (error) { + expect(error.response.message).toBe('존재하지 않는 서버입니다.'); + expect(error.response.error).toBe('Bad Request'); + expect(error.response.statusCode).toBe(HttpStatus.BAD_REQUEST); + } + }); + + it('삭제 권한이 없을 경우', async () => { + const userNotOwner = new User(); + userNotOwner.id = 0; + serverRepository.findOneWithOwner.mockResolvedValue(existsServer); + + try { + await serverService.deleteServer(existsServerId, userNotOwner); + } catch (error) { + expect(error.response.message).toBe('삭제 권한이 없습니다.'); + expect(error.response.error).toBe('Forbidden'); + expect(error.response.statusCode).toBe(HttpStatus.FORBIDDEN); + } }); }); @@ -111,7 +155,8 @@ describe('ServerService', () => { newServer.name = serverName; newServer.owner = user; - existServer = new Server(); - existServer.id = existsServerId; + existsServer = new Server(); + existsServer.id = existsServerId; + existsServer.owner = user; }; }); diff --git a/backend/src/server/server.service.ts b/backend/src/server/server.service.ts index bf0deac..ef2e506 100644 --- a/backend/src/server/server.service.ts +++ b/backend/src/server/server.service.ts @@ -12,7 +12,6 @@ import { Server } from './server.entity'; import RequestServerDto from './dto/RequestServerDto'; import { UserServerService } from '../user-server/user-server.service'; import { ServerRepository } from './server.repository'; -import { DeleteResult } from 'typeorm'; @Injectable() export class ServerService { @@ -21,9 +20,7 @@ export class ServerService { private readonly userServerService: UserServerService, @InjectRepository(ServerRepository) private serverRepository: ServerRepository, - ) { - this.serverRepository = serverRepository; - } + ) {} findAll(): Promise { return this.serverRepository.find({ relations: ['owner'] }); @@ -57,12 +54,12 @@ export class ServerService { async deleteServer(id: number, user: User) { const server = await this.serverRepository.findOneWithOwner(id); + if (!server) { + throw new BadRequestException('존재하지 않는 서버입니다.'); + } if (server.owner.id !== user.id) { throw new ForbiddenException('삭제 권한이 없습니다.'); } - if (!server) { - throw new BadRequestException('해당 서버가 존재하지 않습니다.'); - } return this.serverRepository.delete({ id: id }); } diff --git a/backend/src/user-server/user-server.service.spec.ts b/backend/src/user-server/user-server.service.spec.ts index ddd612a..fee86fb 100644 --- a/backend/src/user-server/user-server.service.spec.ts +++ b/backend/src/user-server/user-server.service.spec.ts @@ -8,6 +8,7 @@ import { UserServerRepository } from './user-server.repository'; import { DeleteResult } from 'typeorm'; import { ServerService } from '../server/server.service'; import { ServerRepository } from '../server/server.repository'; +import { HttpStatus } from '@nestjs/common'; const mockUserServerRepository = () => ({ save: jest.fn(), @@ -77,7 +78,7 @@ describe('UserServerService', () => { expect(newUserServer.server).toBe(server); }); - it('해당 서버가 존재하지 않는 경우', async () => { + it('서버가 존재하지 않는 경우', async () => { const nonExistsId = 0; userServerRepository.save.mockResolvedValue(userServer); serverRepository.findOne.mockResolvedValue(undefined); @@ -85,7 +86,9 @@ describe('UserServerService', () => { try { await service.create(user, nonExistsId); } catch (error) { - expect(error.response).toBe('해당 서버가 존재하지 않습니다.'); + expect(error.response.message).toBe('존재하지 않는 서버입니다.'); + expect(error.response.error).toBe('Bad Request'); + expect(error.response.statusCode).toBe(HttpStatus.BAD_REQUEST); } }); @@ -99,7 +102,9 @@ describe('UserServerService', () => { try { await service.create(user, serverId); } catch (error) { - expect(error.response).toBe('이미 등록된 서버입니다.'); + expect(error.response.message).toBe('이미 등록된 서버입니다.'); + expect(error.response.error).toBe('Bad Request'); + expect(error.response.statusCode).toBe(HttpStatus.BAD_REQUEST); } }); }); From 4be599510caf75f772a88a8cefe8dad6e2cb924d Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Mon, 22 Nov 2021 23:51:36 +0900 Subject: [PATCH 069/172] =?UTF-8?q?Fix=20:=20MainPage=20=EC=8B=9C=EC=9E=91?= =?UTF-8?q?=20=EC=8B=9C=20=EC=B4=88=EA=B8=B0=20=EC=B1=84=EB=84=90=EC=9D=84?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Main/MainStore.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/Main/MainStore.tsx b/frontend/src/components/Main/MainStore.tsx index 535fa2f..2877e16 100644 --- a/frontend/src/components/Main/MainStore.tsx +++ b/frontend/src/components/Main/MainStore.tsx @@ -11,6 +11,7 @@ function MainStore(props: MainStoreProps): JSX.Element { const { children } = props; const [selectedServer, setSelectedServer] = useState(); const [selectedChannel, setSelectedChannel] = useState('1'); + const [rightClickedChannel, setRightClickedChannel] = useState('1'); const [serverChannelList, setServerChannelList] = useState([]); const [isCreateChannelModalOpen, setIsCreateChannelModalOpen] = useState(false); @@ -29,7 +30,11 @@ function MainStore(props: MainStoreProps): JSX.Element { const getServerChannelList = async (): Promise => { const response = await fetch(`/api/user/servers/${selectedServer?.server.id}/channels/joined/`); const list = await response.json(); - setServerChannelList(list.data); + const channelList = list.data; + if (channelList.length) { + setSelectedChannel(channelList[0].id); + setServerChannelList(channelList); + } }; useEffect(() => { @@ -41,6 +46,7 @@ function MainStore(props: MainStoreProps): JSX.Element { value={{ selectedServer, selectedChannel, + rightClickedChannel, isCreateChannelModalOpen, isJoinChannelModalOpen, isUpdateChannelModalOpen, @@ -54,6 +60,7 @@ function MainStore(props: MainStoreProps): JSX.Element { serverList, setSelectedServer, setSelectedChannel, + setRightClickedChannel, setIsCreateChannelModalOpen, setIsJoinChannelModalOpen, setIsUpdateChannelModalOpen, From 4f2e20aa910eac1afa3368827bfb13c3db92044b Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Mon, 22 Nov 2021 23:52:34 +0900 Subject: [PATCH 070/172] =?UTF-8?q?Feat=20:=20UpdateChannelModal=EC=9D=84?= =?UTF-8?q?=20=EC=9C=84=ED=95=9C=20rightClickedChannel=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 현재 선택된 채널이 아닌 우클릭을 누른 채널 id를 UpdateChannelModal로 넘기기 위한 rightClickedChannel 상태를 추가하였습니다. --- frontend/src/components/Main/ChannelListItem.tsx | 3 ++- .../src/components/Main/ChannelModal/UpdateChannelModal.tsx | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/Main/ChannelListItem.tsx b/frontend/src/components/Main/ChannelListItem.tsx index 52d36fe..f379b4e 100644 --- a/frontend/src/components/Main/ChannelListItem.tsx +++ b/frontend/src/components/Main/ChannelListItem.tsx @@ -58,6 +58,7 @@ function ChannelListItem(props: ChannelListItemProps): JSX.Element { isQuitChannelModalOpen, setIsUpdateChannelModalOpen, setIsQuitChannelModalOpen, + setRightClickedChannel, } = useContext(MainStoreContext); const [isDropdownActivated, setIsDropdownActivated] = useState(false); const { dataId, selected, name } = props; @@ -70,7 +71,7 @@ function ChannelListItem(props: ChannelListItemProps): JSX.Element { const onRightClickChannelItem = (e: React.MouseEvent) => { e.preventDefault(); const channelId = e.currentTarget.dataset.id; - console.log(`channelId = ${channelId}`); + setRightClickedChannel(channelId); setIsDropdownActivated(!isDropdownActivated); }; diff --git a/frontend/src/components/Main/ChannelModal/UpdateChannelModal.tsx b/frontend/src/components/Main/ChannelModal/UpdateChannelModal.tsx index 31df01d..c81b570 100644 --- a/frontend/src/components/Main/ChannelModal/UpdateChannelModal.tsx +++ b/frontend/src/components/Main/ChannelModal/UpdateChannelModal.tsx @@ -175,7 +175,7 @@ function UpdateChannelModal(): JSX.Element { watch, formState: { errors }, } = useForm(); - const { selectedServer, selectedChannel, setIsUpdateChannelModalOpen, getServerChannelList } = + const { selectedServer, selectedChannel, rightClickedChannel, setIsUpdateChannelModalOpen, getServerChannelList } = useContext(MainStoreContext); const [isButtonActive, setIsButtonActive] = useState(false); @@ -197,8 +197,8 @@ function UpdateChannelModal(): JSX.Element { }; const getSelectedChannelData = async () => { - console.log(selectedServer, selectedChannel); - const response = await fetch(`/api/channel/${selectedChannel}`); + console.log(selectedServer, selectedChannel, rightClickedChannel); + const response = await fetch(`/api/channel/${rightClickedChannel}`); const data = await response.json(); console.log(data); }; From 7bffeb125923776a5efaa1f9f9e4292a335cfa8f Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Tue, 23 Nov 2021 00:22:20 +0900 Subject: [PATCH 071/172] =?UTF-8?q?Feat=20:=20=EC=9D=B4=EC=A0=9C=20UpdateC?= =?UTF-8?q?hannelModal=EC=9D=84=20=ED=86=B5=ED=95=B4=20=EC=B1=84=EB=84=90?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20=EC=88=98=EC=A0=95=EC=9D=B4=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=ED=95=A9=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/channel/channe.dto.ts | 2 +- backend/src/channel/channel.controller.ts | 12 +++---- backend/src/channel/channel.service.ts | 36 +++++++++++-------- .../Main/ChannelModal/UpdateChannelModal.tsx | 15 ++++---- 4 files changed, 37 insertions(+), 28 deletions(-) diff --git a/backend/src/channel/channe.dto.ts b/backend/src/channel/channe.dto.ts index d727c5e..80134d7 100644 --- a/backend/src/channel/channe.dto.ts +++ b/backend/src/channel/channe.dto.ts @@ -1,4 +1,4 @@ -export type CreateChannelDto = { +export type ChannelFormDto = { name: string; description: string; serverId: number; diff --git a/backend/src/channel/channel.controller.ts b/backend/src/channel/channel.controller.ts index 64530f7..25b2e9b 100644 --- a/backend/src/channel/channel.controller.ts +++ b/backend/src/channel/channel.controller.ts @@ -14,7 +14,7 @@ import { ExpressSession } from '../types/session'; import { ChannelService } from './channel.service'; import { Channel } from './channel.entity'; -import { CreateChannelDto } from './channe.dto'; +import { ChannelFormDto } from './channe.dto'; import { UserChannelService } from '../user-channel/user-channel.service'; import ResponseEntity from '../common/response-entity'; @@ -39,19 +39,19 @@ export class ChannelController { return ResponseEntity.ok(foundServer); } @Post() async saveChannel( - @Body() channel: CreateChannelDto, + @Body() channel: ChannelFormDto, @Session() session: ExpressSession, ): Promise> { - const savedChannel = await this.channelService.addChannel(channel); + const savedChannel = await this.channelService.createChannel(channel); await this.userChannelService.addNewChannel(savedChannel, session.user.id); return ResponseEntity.ok(savedChannel); } @Patch(':id') async updateUser( @Param('id') id: number, - @Body() channel: Channel, + @Body() channel: ChannelFormDto, ): Promise> { - await this.channelService.updateChannel(id, channel); - return ResponseEntity.ok(channel); + const changedChannel = await this.channelService.updateChannel(id, channel); + return ResponseEntity.ok(changedChannel); } @Delete(':id') async deleteChannel( diff --git a/backend/src/channel/channel.service.ts b/backend/src/channel/channel.service.ts index 4a36843..d440acb 100644 --- a/backend/src/channel/channel.service.ts +++ b/backend/src/channel/channel.service.ts @@ -2,7 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm/index'; -import { CreateChannelDto } from './channe.dto'; +import { ChannelFormDto } from './channe.dto'; import { Channel } from './channel.entity'; import { Server } from '../server/server.entity'; import { ChannelRepository } from './channel.repository'; @@ -25,28 +25,34 @@ export class ChannelService { { relations: ['server'] }, ); } - async addChannel(channel: CreateChannelDto): Promise { + async createChannel(channel: ChannelFormDto): Promise { + const channelEntity = await this.createChannelEntity(channel); + const savedChannel = await this.channelRepository.save(channelEntity); + + return savedChannel; + } + + async updateChannel(id: number, channel: ChannelFormDto): Promise { + const channelEntity = await this.createChannelEntity(channel); + await this.channelRepository.update(id, channelEntity); + return channelEntity; + } + + async deleteChannel(id: number): Promise { + await this.channelRepository.delete({ id: id }); + } + + async createChannelEntity(channel: ChannelFormDto): Promise { const channelEntity = this.channelRepository.create(); const server = await this.serverRepository.findOne({ id: channel.serverId, }); - if (!server) { - throw new BadRequestException(); - } + if (!server) throw new BadRequestException(); channelEntity.name = channel.name; channelEntity.description = channel.description; channelEntity.server = server; - - const savedChannel = await this.channelRepository.save(channelEntity); - - return savedChannel; - } - async updateChannel(id: number, channel: Channel): Promise { - await this.channelRepository.update(id, channel); - } - async deleteChannel(id: number): Promise { - await this.channelRepository.delete({ id: id }); + return channelEntity; } } diff --git a/frontend/src/components/Main/ChannelModal/UpdateChannelModal.tsx b/frontend/src/components/Main/ChannelModal/UpdateChannelModal.tsx index c81b570..30a727d 100644 --- a/frontend/src/components/Main/ChannelModal/UpdateChannelModal.tsx +++ b/frontend/src/components/Main/ChannelModal/UpdateChannelModal.tsx @@ -172,17 +172,18 @@ function UpdateChannelModal(): JSX.Element { const { register, handleSubmit, + setValue, watch, formState: { errors }, } = useForm(); - const { selectedServer, selectedChannel, rightClickedChannel, setIsUpdateChannelModalOpen, getServerChannelList } = + const { selectedServer, rightClickedChannel, setIsUpdateChannelModalOpen, getServerChannelList } = useContext(MainStoreContext); const [isButtonActive, setIsButtonActive] = useState(false); const onSubmitUpdateChannelModal = async (data: { name: string; description: string }) => { const { name, description } = data; - await fetch('api/channel', { - method: 'POST', + await fetch(`api/channel/${rightClickedChannel}`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json', }, @@ -197,10 +198,12 @@ function UpdateChannelModal(): JSX.Element { }; const getSelectedChannelData = async () => { - console.log(selectedServer, selectedChannel, rightClickedChannel); const response = await fetch(`/api/channel/${rightClickedChannel}`); - const data = await response.json(); - console.log(data); + const responseObj = await response.json(); + const channelData = responseObj.data; + console.log(channelData); + setValue('name', channelData.name); + setValue('description', channelData.description); }; useEffect(() => { From d3898fdf33c81b220f45d72e4d4242eaca8c15e8 Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Tue, 23 Nov 2021 00:29:20 +0900 Subject: [PATCH 072/172] =?UTF-8?q?Refactor=20:=20getSelectedChannelData?= =?UTF-8?q?=20=EC=9D=84=20setSelectedChannelData=20=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 추가적으로 불필요한 참조나 로그를 삭제하였습니다. --- frontend/src/components/Main/ChannelList.tsx | 1 - frontend/src/components/Main/ChannelListItem.tsx | 2 +- .../src/components/Main/ChannelModal/UpdateChannelModal.tsx | 5 ++--- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/Main/ChannelList.tsx b/frontend/src/components/Main/ChannelList.tsx index 4a66721..e8a0038 100644 --- a/frontend/src/components/Main/ChannelList.tsx +++ b/frontend/src/components/Main/ChannelList.tsx @@ -2,7 +2,6 @@ import React, { useContext, useEffect, useState } from 'react'; import { useNavigate, createSearchParams } from 'react-router-dom'; import styled from 'styled-components'; -import { BoostCamMainIcons } from '../../utils/SvgIcons'; import { ChannelData } from '../../types/main'; import { MainStoreContext } from './MainStore'; import ChannelListHeader from './ChannelListHeader'; diff --git a/frontend/src/components/Main/ChannelListItem.tsx b/frontend/src/components/Main/ChannelListItem.tsx index f379b4e..2885142 100644 --- a/frontend/src/components/Main/ChannelListItem.tsx +++ b/frontend/src/components/Main/ChannelListItem.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect, useState } from 'react'; +import React, { useContext, useState } from 'react'; import styled from 'styled-components'; import { BoostCamMainIcons } from '../../utils/SvgIcons'; diff --git a/frontend/src/components/Main/ChannelModal/UpdateChannelModal.tsx b/frontend/src/components/Main/ChannelModal/UpdateChannelModal.tsx index 30a727d..daeeac6 100644 --- a/frontend/src/components/Main/ChannelModal/UpdateChannelModal.tsx +++ b/frontend/src/components/Main/ChannelModal/UpdateChannelModal.tsx @@ -197,17 +197,16 @@ function UpdateChannelModal(): JSX.Element { setIsUpdateChannelModalOpen(false); }; - const getSelectedChannelData = async () => { + const setSelectedChannelData = async () => { const response = await fetch(`/api/channel/${rightClickedChannel}`); const responseObj = await response.json(); const channelData = responseObj.data; - console.log(channelData); setValue('name', channelData.name); setValue('description', channelData.description); }; useEffect(() => { - getSelectedChannelData(); + setSelectedChannelData(); }, []); useEffect(() => { From 72beaa65e209de418781a3f0a347251ea6cccc8b Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Tue, 23 Nov 2021 01:14:30 +0900 Subject: [PATCH 073/172] =?UTF-8?q?Feat=20:=20DeleteChannelModal=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChannelList의 항목에 우클릭을 하여 나온 Dropdown으로 진입할 수 있습니다. - Modal에서 확인 버튼을 누르면 선택한 채널을 삭제할 수 있습니다. --- .../Main/ChannelModal/QuitChannelModal .tsx | 216 ++++++++++++++++++ frontend/src/components/Main/MainPage.tsx | 3 + 2 files changed, 219 insertions(+) create mode 100644 frontend/src/components/Main/ChannelModal/QuitChannelModal .tsx diff --git a/frontend/src/components/Main/ChannelModal/QuitChannelModal .tsx b/frontend/src/components/Main/ChannelModal/QuitChannelModal .tsx new file mode 100644 index 0000000..0f84c74 --- /dev/null +++ b/frontend/src/components/Main/ChannelModal/QuitChannelModal .tsx @@ -0,0 +1,216 @@ +import React, { useState, useContext, useEffect } from 'react'; +import styled from 'styled-components'; + +import { MainStoreContext } from '../MainStore'; +import { BoostCamMainIcons } from '../../../utils/SvgIcons'; +import { ChannelData } from '../../../types/main'; + +const { Close } = BoostCamMainIcons; + +const Container = styled.div` + position: fixed; + width: 100vw; + height: 100vh; + left: 0px; + right: 0px; + + display: flex; + justify-content: space-around; + align-items: center; +`; + +const ModalBackground = styled.div` + position: fixed; + left: 0px; + right: 0px; + width: 100%; + height: 100%; + background-color: rgb(0, 0, 0, 0.5); +`; + +const ModalBox = styled.div` + width: 50%; + min-width: 400px; + height: 25%; + min-height: 250px; + + background-color: #222322; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + border-radius: 20px; + + z-index: 3; +`; + +const ModalInnerBox = styled.div` + width: 100%; + height: 100%; + padding: 30px 10px; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; +`; + +const ModalHeader = styled.div` + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + flex: 1; +`; + +const ModalTitle = styled.span` + margin-left: 25px; + padding: 10px 5px; + + color: #cbc4b9; + font-size: 32px; + font-weight: 600; +`; + +const ModalDescription = styled.div` + width: 100%; + flex: 1; + margin-left: 25px; + padding: 10px 5px; + + color: #cbc4b9; + font-size: 18px; +`; + +const DescriptionSpan = styled.span``; + +const HighlightDescriptionSpan = styled.span` + color: red; +`; + +const ModalCloseButton = styled.div` + width: 32px; + height: 32px; + display: flex; + flex-direction: center; + align-items: center; + + cursor: pointer; + margin-right: 25px; +`; + +const ModalButtonContainer = styled.div` + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-around; + align-items: center; +`; + +const SubmitButton = styled.button` + width: 100px; + height: 50px; + background: none; + + padding: 15px 10px; + + border: 0; + outline: 0; + + text-align: center; + vertical-align: middle; + + border-radius: 10px; + background-color: #26a9ca; + cursor: pointer; + transition: all 0.3s; + + &:hover { + background-color: #54c8e6; + transition: all 0.3s; + } +`; + +const CancelButton = styled.button` + width: 100px; + height: 50px; + background: none; + + padding: 15px 10px; + + border: 0; + outline: 0; + + text-align: center; + vertical-align: middle; + + border-radius: 10px; + background-color: gray; + cursor: pointer; + transition: all 0.3s; + + &:hover { + background-color: #d4d0d0; + transition: all 0.3s; + } +`; + +const CloseIcon = styled(Close)` + width: 20px; + height: 20px; + fill: #a69c96; +`; + +function QuitChannelModal(): JSX.Element { + const { rightClickedChannel, setIsQuitChannelModalOpen, getServerChannelList } = useContext(MainStoreContext); + const [selectedChannelName, setSelectedChannelName] = useState(''); + + const onClickSubmitButton = async () => { + await fetch(`api/channel/${rightClickedChannel}`, { + method: 'DELETE', + }); + getServerChannelList(); + setIsQuitChannelModalOpen(false); + }; + + const getSelectedChannelData = async () => { + const response = await fetch(`/api/channel/${rightClickedChannel}`); + const responseObj = await response.json(); + const channelData = responseObj.data; + setSelectedChannelName(channelData.name); + }; + + useEffect(() => { + getSelectedChannelData(); + }, []); + + return ( + + setIsQuitChannelModalOpen(false)} /> + + + + 채널 나가기 + setIsQuitChannelModalOpen(false)}> + + + + + 정말 + {selectedChannelName} + 에서 나가시겠습니까? + + + 확인 + setIsQuitChannelModalOpen(false)}>취소 + + + + + ); +} + +export default QuitChannelModal; diff --git a/frontend/src/components/Main/MainPage.tsx b/frontend/src/components/Main/MainPage.tsx index 1fd17c5..1cb16fb 100644 --- a/frontend/src/components/Main/MainPage.tsx +++ b/frontend/src/components/Main/MainPage.tsx @@ -12,6 +12,7 @@ import ServerSettingModal from './ServerModal/ServerSettingModal'; import ServerInfoModal from './ServerModal/ServerInfoModal'; import QuitServerModal from './ServerModal/QuitServerModal'; import UpdateChannelModal from './ChannelModal/UpdateChannelModal'; +import QuitChannelModal from './ChannelModal/QuitChannelModal '; const Container = styled.div` width: 100vw; @@ -28,6 +29,7 @@ function MainPage(): JSX.Element { isCreateChannelModalOpen, isJoinChannelModalOpen, isUpdateChannelModalOpen, + isQuitChannelModalOpen, isCreateServerModalOpen, isJoinServerModalOpen, isServerInfoModalOpen, @@ -41,6 +43,7 @@ function MainPage(): JSX.Element { {isCreateChannelModalOpen && } {isJoinChannelModalOpen && } {isUpdateChannelModalOpen && } + {isQuitChannelModalOpen && } {isCreateServerModalOpen && } {isJoinServerModalOpen && } {isServerSettingModalOpen && } From 781289c8e9d61be56498d5ffb3d8697b5ad65b73 Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Tue, 23 Nov 2021 01:22:31 +0900 Subject: [PATCH 074/172] =?UTF-8?q?Feat=20:=20DeleteChannelModal=EC=97=90?= =?UTF-8?q?=EC=84=9C=20Channel=20=EC=9D=B4=EB=A6=84=EC=9D=84=20=EA=B0=80?= =?UTF-8?q?=EC=A0=B8=EC=98=A4=EB=8A=94=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChannelListItem에 오른쪽 클릭을 할 때 rightClickedChannelName이라는 상태에 채널 이름을 저장하고 QuitChannelModal에서 이를 사용하도록 수정하였습니다. --- frontend/src/components/Main/ChannelListItem.tsx | 6 ++++-- .../Main/ChannelModal/QuitChannelModal .tsx | 14 ++++---------- .../Main/ChannelModal/UpdateChannelModal.tsx | 6 +++--- frontend/src/components/Main/MainStore.tsx | 9 ++++++--- 4 files changed, 17 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/Main/ChannelListItem.tsx b/frontend/src/components/Main/ChannelListItem.tsx index 2885142..b902e3f 100644 --- a/frontend/src/components/Main/ChannelListItem.tsx +++ b/frontend/src/components/Main/ChannelListItem.tsx @@ -58,7 +58,8 @@ function ChannelListItem(props: ChannelListItemProps): JSX.Element { isQuitChannelModalOpen, setIsUpdateChannelModalOpen, setIsQuitChannelModalOpen, - setRightClickedChannel, + setRightClickedChannelId, + setRightClickedChannelName, } = useContext(MainStoreContext); const [isDropdownActivated, setIsDropdownActivated] = useState(false); const { dataId, selected, name } = props; @@ -71,7 +72,8 @@ function ChannelListItem(props: ChannelListItemProps): JSX.Element { const onRightClickChannelItem = (e: React.MouseEvent) => { e.preventDefault(); const channelId = e.currentTarget.dataset.id; - setRightClickedChannel(channelId); + setRightClickedChannelId(channelId); + setRightClickedChannelName(name); setIsDropdownActivated(!isDropdownActivated); }; diff --git a/frontend/src/components/Main/ChannelModal/QuitChannelModal .tsx b/frontend/src/components/Main/ChannelModal/QuitChannelModal .tsx index 0f84c74..4ed05eb 100644 --- a/frontend/src/components/Main/ChannelModal/QuitChannelModal .tsx +++ b/frontend/src/components/Main/ChannelModal/QuitChannelModal .tsx @@ -165,26 +165,20 @@ const CloseIcon = styled(Close)` `; function QuitChannelModal(): JSX.Element { - const { rightClickedChannel, setIsQuitChannelModalOpen, getServerChannelList } = useContext(MainStoreContext); + const { rightClickedChannelId, rightClickedChannelName, setIsQuitChannelModalOpen, getServerChannelList } = + useContext(MainStoreContext); const [selectedChannelName, setSelectedChannelName] = useState(''); const onClickSubmitButton = async () => { - await fetch(`api/channel/${rightClickedChannel}`, { + await fetch(`api/channel/${rightClickedChannelId}`, { method: 'DELETE', }); getServerChannelList(); setIsQuitChannelModalOpen(false); }; - const getSelectedChannelData = async () => { - const response = await fetch(`/api/channel/${rightClickedChannel}`); - const responseObj = await response.json(); - const channelData = responseObj.data; - setSelectedChannelName(channelData.name); - }; - useEffect(() => { - getSelectedChannelData(); + setSelectedChannelName(rightClickedChannelName); }, []); return ( diff --git a/frontend/src/components/Main/ChannelModal/UpdateChannelModal.tsx b/frontend/src/components/Main/ChannelModal/UpdateChannelModal.tsx index daeeac6..25160a6 100644 --- a/frontend/src/components/Main/ChannelModal/UpdateChannelModal.tsx +++ b/frontend/src/components/Main/ChannelModal/UpdateChannelModal.tsx @@ -176,13 +176,13 @@ function UpdateChannelModal(): JSX.Element { watch, formState: { errors }, } = useForm(); - const { selectedServer, rightClickedChannel, setIsUpdateChannelModalOpen, getServerChannelList } = + const { selectedServer, rightClickedChannelId, setIsUpdateChannelModalOpen, getServerChannelList } = useContext(MainStoreContext); const [isButtonActive, setIsButtonActive] = useState(false); const onSubmitUpdateChannelModal = async (data: { name: string; description: string }) => { const { name, description } = data; - await fetch(`api/channel/${rightClickedChannel}`, { + await fetch(`api/channel/${rightClickedChannelId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', @@ -198,7 +198,7 @@ function UpdateChannelModal(): JSX.Element { }; const setSelectedChannelData = async () => { - const response = await fetch(`/api/channel/${rightClickedChannel}`); + const response = await fetch(`/api/channel/${rightClickedChannelId}`); const responseObj = await response.json(); const channelData = responseObj.data; setValue('name', channelData.name); diff --git a/frontend/src/components/Main/MainStore.tsx b/frontend/src/components/Main/MainStore.tsx index 2877e16..60aa162 100644 --- a/frontend/src/components/Main/MainStore.tsx +++ b/frontend/src/components/Main/MainStore.tsx @@ -11,7 +11,8 @@ function MainStore(props: MainStoreProps): JSX.Element { const { children } = props; const [selectedServer, setSelectedServer] = useState(); const [selectedChannel, setSelectedChannel] = useState('1'); - const [rightClickedChannel, setRightClickedChannel] = useState('1'); + const [rightClickedChannelId, setRightClickedChannelId] = useState(''); + const [rightClickedChannelName, setRightClickedChannelName] = useState(''); const [serverChannelList, setServerChannelList] = useState([]); const [isCreateChannelModalOpen, setIsCreateChannelModalOpen] = useState(false); @@ -46,7 +47,8 @@ function MainStore(props: MainStoreProps): JSX.Element { value={{ selectedServer, selectedChannel, - rightClickedChannel, + rightClickedChannelId, + rightClickedChannelName, isCreateChannelModalOpen, isJoinChannelModalOpen, isUpdateChannelModalOpen, @@ -60,7 +62,8 @@ function MainStore(props: MainStoreProps): JSX.Element { serverList, setSelectedServer, setSelectedChannel, - setRightClickedChannel, + setRightClickedChannelId, + setRightClickedChannelName, setIsCreateChannelModalOpen, setIsJoinChannelModalOpen, setIsUpdateChannelModalOpen, From c5dc71ee86eab5146832c262673114fd6a0cc6d6 Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Tue, 23 Nov 2021 01:32:40 +0900 Subject: [PATCH 075/172] =?UTF-8?q?Feat=20:=20ChannelListItem=EC=9D=98=20?= =?UTF-8?q?=EC=B1=84=EB=84=90=EB=AA=85=EC=9D=B4=20=EB=84=88=EB=AC=B4=20?= =?UTF-8?q?=EA=B8=B8=20=EB=95=8C=20css=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit text-overflow: ellipsis 속성을 통해 채널명이 너무 길 때 적절하게 ... 을 출력해주도록 작성하였습니다. --- frontend/src/components/Main/ChannelListItem.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/components/Main/ChannelListItem.tsx b/frontend/src/components/Main/ChannelListItem.tsx index b902e3f..3358534 100644 --- a/frontend/src/components/Main/ChannelListItem.tsx +++ b/frontend/src/components/Main/ChannelListItem.tsx @@ -29,11 +29,16 @@ const Container = styled.div<{ selected: boolean }>` const ChannelNameSpan = styled.span` padding: 5px 0px 5px 5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; `; const HashIcon = styled(Hash)` width: 15px; + min-width: 15px; height: 15px; + min-height: 15px; fill: #a69c96; `; From 1ec737d8614d087f88b14f7bce737eb2e0a36a5b Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Tue, 23 Nov 2021 01:38:17 +0900 Subject: [PATCH 076/172] =?UTF-8?q?Fix=20:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=B0=B8=EC=A1=B0=EB=A5=BC=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=ED=95=98=EC=98=80=EC=8A=B5=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Main/ChannelModal/QuitChannelModal .tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/components/Main/ChannelModal/QuitChannelModal .tsx b/frontend/src/components/Main/ChannelModal/QuitChannelModal .tsx index 4ed05eb..ed03526 100644 --- a/frontend/src/components/Main/ChannelModal/QuitChannelModal .tsx +++ b/frontend/src/components/Main/ChannelModal/QuitChannelModal .tsx @@ -3,7 +3,6 @@ import styled from 'styled-components'; import { MainStoreContext } from '../MainStore'; import { BoostCamMainIcons } from '../../../utils/SvgIcons'; -import { ChannelData } from '../../../types/main'; const { Close } = BoostCamMainIcons; From 7dace2801f42d1b6f11fc92149d267b41849cc54 Mon Sep 17 00:00:00 2001 From: korung3195 Date: Mon, 22 Nov 2021 17:52:02 +0900 Subject: [PATCH 077/172] =?UTF-8?q?Fix=20:=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20=EB=B0=A9=EC=96=B4=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 화면 공유 종료 시 본인의 stream이 없으면 발생하는 에러를 막는 로직을 추가하였습니다. --- frontend/src/components/Cam/Screen/UserScreen.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Cam/Screen/UserScreen.tsx b/frontend/src/components/Cam/Screen/UserScreen.tsx index 73722b9..d0a8965 100644 --- a/frontend/src/components/Cam/Screen/UserScreen.tsx +++ b/frontend/src/components/Cam/Screen/UserScreen.tsx @@ -72,8 +72,12 @@ function UserScreen(props: UserScreenProps): JSX.Element { socket.emit('getUserStatus', { userId }); return () => { if (stream) { - stream.getAudioTracks()[0].enabled = true; - stream.getVideoTracks()[0].enabled = true; + if (stream.getAudioTracks()[0]) { + stream.getAudioTracks()[0].enabled = true; + } + if (stream.getVideoTracks()[0]) { + stream.getVideoTracks()[0].enabled = true; + } } }; }, []); From f2aab5ffb72139e0037f7375a2aae5ce907a0cf3 Mon Sep 17 00:00:00 2001 From: korung3195 Date: Mon, 22 Nov 2021 17:29:54 +0900 Subject: [PATCH 078/172] =?UTF-8?q?Feat=20:=20cams=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EA=B5=AC=EC=A1=B0=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cams 관련 repository, service, controller를 생성하였습니다. --- backend/src/cams/cams.controller.ts | 9 +++++++++ backend/src/cams/cams.module.ts | 9 ++++++--- backend/src/cams/cams.repository.ts | 5 +++++ backend/src/cams/cams.service.ts | 11 +++++++++++ 4 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 backend/src/cams/cams.controller.ts create mode 100644 backend/src/cams/cams.repository.ts create mode 100644 backend/src/cams/cams.service.ts diff --git a/backend/src/cams/cams.controller.ts b/backend/src/cams/cams.controller.ts new file mode 100644 index 0000000..cdebe9e --- /dev/null +++ b/backend/src/cams/cams.controller.ts @@ -0,0 +1,9 @@ +import { Controller, UseGuards } from '@nestjs/common'; + +import { LoginGuard } from '../login/login.guard'; + +@Controller('/api/cams') +@UseGuards(LoginGuard) +export class CamsController { + // @Post(':name') async createCams(@Param('name') name: string) {} +} diff --git a/backend/src/cams/cams.module.ts b/backend/src/cams/cams.module.ts index a15cc6e..325f5ad 100644 --- a/backend/src/cams/cams.module.ts +++ b/backend/src/cams/cams.module.ts @@ -1,11 +1,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Server } from '../server/server.entity'; +import { CamsController } from './cams.controller'; import { Cams } from './cams.entity'; +import { CamsRepository } from './cams.repository'; +import { CamsService } from './cams.service'; @Module({ - imports: [TypeOrmModule.forFeature([Cams, Server])], - providers: [], - controllers: [], + imports: [TypeOrmModule.forFeature([Cams, Server, CamsRepository])], + providers: [CamsService], + controllers: [CamsController], }) export class CamsModule {} diff --git a/backend/src/cams/cams.repository.ts b/backend/src/cams/cams.repository.ts new file mode 100644 index 0000000..9dba229 --- /dev/null +++ b/backend/src/cams/cams.repository.ts @@ -0,0 +1,5 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { Cams } from './cams.entity'; + +@EntityRepository(Cams) +export class CamsRepository extends Repository {} diff --git a/backend/src/cams/cams.service.ts b/backend/src/cams/cams.service.ts new file mode 100644 index 0000000..26f56d3 --- /dev/null +++ b/backend/src/cams/cams.service.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Cams } from './cams.entity'; +import { CamsRepository } from './cams.repository'; + +@Injectable() +export class CamsService { + constructor(@InjectRepository(Cams) private camsRepository: CamsRepository) { + this.camsRepository = camsRepository; + } +} From b1e14ca02b1f511ad1be9eb3ec0cb914316c8baf Mon Sep 17 00:00:00 2001 From: korung3195 Date: Mon, 22 Nov 2021 18:34:09 +0900 Subject: [PATCH 079/172] =?UTF-8?q?Feat=20:=20uuid=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cams의 url을 생성하기 위해 uuid 모듈 의존성을 추가하였습니다. --- backend/package-lock.json | 13 +++++++++++++ backend/package.json | 1 + 2 files changed, 14 insertions(+) diff --git a/backend/package-lock.json b/backend/package-lock.json index a6bb274..2c2aa7b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -37,6 +37,7 @@ "@types/multer": "^1.4.7", "@types/node": "^16.0.0", "@types/supertest": "^2.0.11", + "@types/uuid": "^8.3.3", "@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/parser": "^5.0.0", "eslint": "^8.0.1", @@ -2084,6 +2085,12 @@ "@types/superagent": "*" } }, + "node_modules/@types/uuid": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.3.tgz", + "integrity": "sha512-0LbEEx1zxrYB3pgpd1M5lEhLcXjKJnYghvhTRgaBeUivLHMDM1TzF3IJ6hXU2+8uA4Xz+5BA63mtZo5DjVT8iA==", + "dev": true + }, "node_modules/@types/ws": { "version": "7.4.7", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz", @@ -11472,6 +11479,12 @@ "@types/superagent": "*" } }, + "@types/uuid": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.3.tgz", + "integrity": "sha512-0LbEEx1zxrYB3pgpd1M5lEhLcXjKJnYghvhTRgaBeUivLHMDM1TzF3IJ6hXU2+8uA4Xz+5BA63mtZo5DjVT8iA==", + "dev": true + }, "@types/ws": { "version": "7.4.7", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz", diff --git a/backend/package.json b/backend/package.json index bbbe1a9..325ce58 100644 --- a/backend/package.json +++ b/backend/package.json @@ -49,6 +49,7 @@ "@types/multer": "^1.4.7", "@types/node": "^16.0.0", "@types/supertest": "^2.0.11", + "@types/uuid": "^8.3.3", "@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/parser": "^5.0.0", "eslint": "^8.0.1", From 5c7530f9aa24688cc1ff88e62eaf5ca7a93200d2 Mon Sep 17 00:00:00 2001 From: korung3195 Date: Mon, 22 Nov 2021 19:01:58 +0900 Subject: [PATCH 080/172] =?UTF-8?q?Feat=20:=20=EB=B0=B1=EC=97=94=EB=93=9C?= =?UTF-8?q?=20cam=20=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit camsService에 addCams 메소드를 이용해 cam을 생성할 수 있는 로직을 구현하였습니다. --- backend/src/cam/cam.gateway.ts | 12 +------- backend/src/cams/cams.controller.ts | 16 +++++++++-- backend/src/cams/cams.dto.ts | 4 +++ backend/src/cams/cams.module.ts | 8 ++++-- backend/src/cams/cams.service.ts | 44 +++++++++++++++++++++++++++-- 5 files changed, 67 insertions(+), 17 deletions(-) create mode 100644 backend/src/cams/cams.dto.ts diff --git a/backend/src/cam/cam.gateway.ts b/backend/src/cam/cam.gateway.ts index 255653c..8d11731 100644 --- a/backend/src/cam/cam.gateway.ts +++ b/backend/src/cam/cam.gateway.ts @@ -11,17 +11,7 @@ import { CamService } from './cam.service'; @WebSocketGateway() export class CamGateway { @WebSocketServer() server: Server; - constructor(private camService: CamService) { - this.camService.createRoom('1'); - } - - handleConnection(client: Socket) { - console.log(`${client.id} is connected!`); - } - - handleDisconnect(client: Socket) { - console.log(`${client.id} is disconnected!`); - } + constructor(private camService: CamService) {} @SubscribeMessage('joinRoom') handleJoinRoom( diff --git a/backend/src/cams/cams.controller.ts b/backend/src/cams/cams.controller.ts index cdebe9e..7aa8e99 100644 --- a/backend/src/cams/cams.controller.ts +++ b/backend/src/cams/cams.controller.ts @@ -1,9 +1,21 @@ -import { Controller, UseGuards } from '@nestjs/common'; +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import ResponseEntity from '../common/response-entity'; import { LoginGuard } from '../login/login.guard'; +import { CreateCamsDto } from './cams.dto'; +import { CamsService } from './cams.service'; @Controller('/api/cams') @UseGuards(LoginGuard) export class CamsController { - // @Post(':name') async createCams(@Param('name') name: string) {} + constructor(private camsService: CamsService) { + this.camsService = camsService; + } + + @Post() async createCams( + @Body() cams: CreateCamsDto, + ): Promise> { + const savedCams = await this.camsService.createCams(cams); + return ResponseEntity.created(savedCams.id); + } } diff --git a/backend/src/cams/cams.dto.ts b/backend/src/cams/cams.dto.ts new file mode 100644 index 0000000..c3075ec --- /dev/null +++ b/backend/src/cams/cams.dto.ts @@ -0,0 +1,4 @@ +export type CreateCamsDto = { + name: string; + serverId: number; +}; diff --git a/backend/src/cams/cams.module.ts b/backend/src/cams/cams.module.ts index 325f5ad..4b7be4e 100644 --- a/backend/src/cams/cams.module.ts +++ b/backend/src/cams/cams.module.ts @@ -1,14 +1,18 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { CamService } from '../cam/cam.service'; import { Server } from '../server/server.entity'; +import { ServerRepository } from '../server/server.repository'; import { CamsController } from './cams.controller'; import { Cams } from './cams.entity'; import { CamsRepository } from './cams.repository'; import { CamsService } from './cams.service'; @Module({ - imports: [TypeOrmModule.forFeature([Cams, Server, CamsRepository])], - providers: [CamsService], + imports: [ + TypeOrmModule.forFeature([Cams, Server, CamsRepository, ServerRepository]), + ], + providers: [CamsService, CamService], controllers: [CamsController], }) export class CamsModule {} diff --git a/backend/src/cams/cams.service.ts b/backend/src/cams/cams.service.ts index 26f56d3..9ce7c79 100644 --- a/backend/src/cams/cams.service.ts +++ b/backend/src/cams/cams.service.ts @@ -1,11 +1,51 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { Server } from '../server/server.entity'; +import { ServerRepository } from '../server/server.repository'; +import { CreateCamsDto } from './cams.dto'; import { Cams } from './cams.entity'; import { CamsRepository } from './cams.repository'; +import { v4 } from 'uuid'; +import { CamService } from '../cam/cam.service'; @Injectable() export class CamsService { - constructor(@InjectRepository(Cams) private camsRepository: CamsRepository) { + constructor( + @InjectRepository(Cams) private camsRepository: CamsRepository, + @InjectRepository(Server) private serverRepository: ServerRepository, + @Inject(CamService) private readonly camService: CamService, + ) { this.camsRepository = camsRepository; + this.serverRepository = serverRepository; + this.camService = camService; + } + + findOne(id: number): Promise { + return this.camsRepository.findOne({ id: id }, { relations: ['server'] }); + } + + async createCams(cams: CreateCamsDto): Promise { + const camsEntity = this.camsRepository.create(); + const server = await this.serverRepository.findOne({ + id: cams.serverId, + }); + + if (!server) { + throw new BadRequestException(); + } + + camsEntity.name = cams.name; + camsEntity.server = server; + camsEntity.url = v4(); + + this.camService.createRoom(camsEntity.url); + const savedCams = await this.camsRepository.save(camsEntity); + + return savedCams; + } + + // cam의 exitRooms 로직과 연결이 필요함! + async deleteCams(id: number): Promise { + await this.camsRepository.delete({ id: id }); } } From 7e9e8b3cfa4bc9ebfc6bb952c481ded4aaebfb2b Mon Sep 17 00:00:00 2001 From: korung3195 Date: Tue, 23 Nov 2021 15:57:21 +0900 Subject: [PATCH 081/172] =?UTF-8?q?Fix=20:=20=EB=B0=B1=EC=97=94=EB=93=9C?= =?UTF-8?q?=20cam=20=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 cam 모듈과 cams 모듈의 결합이 이상하게 되어있어서 service가 두 개가 생기는 문제가 있어서 수정하였습니다. --- backend/src/cam/cam.module.ts | 1 + backend/src/cams/cams.module.ts | 5 +++-- backend/src/cams/cams.service.ts | 6 +----- .../Main/ChannelModal/CreateChannelModal.tsx | 16 ++++++++++++++++ 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/backend/src/cam/cam.module.ts b/backend/src/cam/cam.module.ts index 50a1d01..1eb3b95 100644 --- a/backend/src/cam/cam.module.ts +++ b/backend/src/cam/cam.module.ts @@ -6,5 +6,6 @@ import { CamController } from './cam.controller'; @Module({ providers: [CamGateway, CamService], controllers: [CamController], + exports: [CamService], }) export class CamModule {} diff --git a/backend/src/cams/cams.module.ts b/backend/src/cams/cams.module.ts index 4b7be4e..ffb49d9 100644 --- a/backend/src/cams/cams.module.ts +++ b/backend/src/cams/cams.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { CamService } from '../cam/cam.service'; +import { CamModule } from '../cam/cam.module'; import { Server } from '../server/server.entity'; import { ServerRepository } from '../server/server.repository'; import { CamsController } from './cams.controller'; @@ -11,8 +11,9 @@ import { CamsService } from './cams.service'; @Module({ imports: [ TypeOrmModule.forFeature([Cams, Server, CamsRepository, ServerRepository]), + CamModule, ], - providers: [CamsService, CamService], + providers: [CamsService], controllers: [CamsController], }) export class CamsModule {} diff --git a/backend/src/cams/cams.service.ts b/backend/src/cams/cams.service.ts index 9ce7c79..e8cdc0a 100644 --- a/backend/src/cams/cams.service.ts +++ b/backend/src/cams/cams.service.ts @@ -14,11 +14,7 @@ export class CamsService { @InjectRepository(Cams) private camsRepository: CamsRepository, @InjectRepository(Server) private serverRepository: ServerRepository, @Inject(CamService) private readonly camService: CamService, - ) { - this.camsRepository = camsRepository; - this.serverRepository = serverRepository; - this.camService = camService; - } + ) {} findOne(id: number): Promise { return this.camsRepository.findOne({ id: id }, { relations: ['server'] }); diff --git a/frontend/src/components/Main/ChannelModal/CreateChannelModal.tsx b/frontend/src/components/Main/ChannelModal/CreateChannelModal.tsx index 1b39487..f9bab44 100644 --- a/frontend/src/components/Main/ChannelModal/CreateChannelModal.tsx +++ b/frontend/src/components/Main/ChannelModal/CreateChannelModal.tsx @@ -195,6 +195,19 @@ function CreateChannelModal(): JSX.Element { setIsCreateChannelModalOpen(false); }; + const createCams = async () => { + await fetch('api/cams', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: 'test', + serverId: selectedServer.server.id, + }), + }); + }; + useEffect(() => { const { name, description } = watch(); const isActive = name.trim().length > 2 && description.trim().length > 0; @@ -239,6 +252,9 @@ function CreateChannelModal(): JSX.Element { 생성
+ From f8e1f52d80373afa596c078919d4238a87e4ccd8 Mon Sep 17 00:00:00 2001 From: Suppplier <49611158+Suppplier@users.noreply.github.com> Date: Tue, 23 Nov 2021 15:57:22 +0900 Subject: [PATCH 082/172] =?UTF-8?q?Fix=20:=20Server=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=20=EC=8B=9C=20=EC=A0=95=EC=83=81=EC=A0=81=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?ChannelList=EB=A5=BC=20=EA=B0=80=EC=A0=B8=EC=98=A4=EC=A7=80=20?= =?UTF-8?q?=EB=AA=BB=ED=95=98=EB=8A=94=20=EC=97=90=EB=9F=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Main/MainStore.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Main/MainStore.tsx b/frontend/src/components/Main/MainStore.tsx index 98bd301..2607806 100644 --- a/frontend/src/components/Main/MainStore.tsx +++ b/frontend/src/components/Main/MainStore.tsx @@ -10,7 +10,7 @@ type MainStoreProps = { function MainStore(props: MainStoreProps): JSX.Element { const { children } = props; const [selectedServer, setSelectedServer] = useState(); - const [selectedChannel, setSelectedChannel] = useState('1'); + const [selectedChannel, setSelectedChannel] = useState('-1'); const [rightClickedChannelId, setRightClickedChannelId] = useState(''); const [rightClickedChannelName, setRightClickedChannelName] = useState(''); const [serverChannelList, setServerChannelList] = useState([]); @@ -29,13 +29,17 @@ function MainStore(props: MainStoreProps): JSX.Element { const [serverList, setServerList] = useState([]); const getServerChannelList = async (): Promise => { + console.log(selectedServer?.server.id); const response = await fetch(`/api/user/servers/${selectedServer?.server.id}/channels/joined/`); const list = await response.json(); const channelList = list.data; + console.log(channelList); if (channelList.length) { setSelectedChannel(channelList[0].id); - setServerChannelList(channelList); + } else { + setSelectedChannel('-1'); } + setServerChannelList(channelList); }; const getUserServerList = async (isServerOrUserServerCreated: boolean): Promise => { @@ -50,6 +54,7 @@ function MainStore(props: MainStoreProps): JSX.Element { }; useEffect(() => { + console.log(selectedServer); if (selectedServer) getServerChannelList(); }, [selectedServer]); From 9cfee3727499b156d4a2243fb0a3e37003ccb9f2 Mon Sep 17 00:00:00 2001 From: Suppplier <49611158+Suppplier@users.noreply.github.com> Date: Tue, 23 Nov 2021 16:23:13 +0900 Subject: [PATCH 083/172] =?UTF-8?q?Feat=20:=20ContentsSection=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=EB=93=A4=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ContentsSection 파일을 ContentsSecion 폴더로 이동하였습니다. - ChattingSection 과 ThreadSection 파일을 생성하였습니다. --- .../src/components/Main/ContentsSection.tsx | 15 -------------- .../Main/ContentsSection/ChattingSection.tsx | 13 ++++++++++++ .../Main/ContentsSection/ContentsSection.tsx | 20 +++++++++++++++++++ .../Main/ContentsSection/ThreadSection.tsx | 13 ++++++++++++ frontend/src/components/Main/MainSection.tsx | 2 +- frontend/src/components/Main/MainStore.tsx | 3 --- 6 files changed, 47 insertions(+), 19 deletions(-) delete mode 100644 frontend/src/components/Main/ContentsSection.tsx create mode 100644 frontend/src/components/Main/ContentsSection/ChattingSection.tsx create mode 100644 frontend/src/components/Main/ContentsSection/ContentsSection.tsx create mode 100644 frontend/src/components/Main/ContentsSection/ThreadSection.tsx diff --git a/frontend/src/components/Main/ContentsSection.tsx b/frontend/src/components/Main/ContentsSection.tsx deleted file mode 100644 index 8f91301..0000000 --- a/frontend/src/components/Main/ContentsSection.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React, { useEffect } from 'react'; -import styled from 'styled-components'; - -const Container = styled.div` - flex: 1; - height: 100%; -`; - -function ContentsSection(): JSX.Element { - useEffect(() => {}, []); - - return ContentsSection; -} - -export default ContentsSection; diff --git a/frontend/src/components/Main/ContentsSection/ChattingSection.tsx b/frontend/src/components/Main/ContentsSection/ChattingSection.tsx new file mode 100644 index 0000000..c463fed --- /dev/null +++ b/frontend/src/components/Main/ContentsSection/ChattingSection.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import styled from 'styled-components'; + +const Container = styled.div` + flex: 1; + height: 100%; +`; + +function ChattingSection(): JSX.Element { + return ChattingSection; +} + +export default ChattingSection; diff --git a/frontend/src/components/Main/ContentsSection/ContentsSection.tsx b/frontend/src/components/Main/ContentsSection/ContentsSection.tsx new file mode 100644 index 0000000..8533bb6 --- /dev/null +++ b/frontend/src/components/Main/ContentsSection/ContentsSection.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import styled from 'styled-components'; +import ChattingSection from './ChattingSection'; +import ThreadSection from './ThreadSection'; + +const Container = styled.div` + flex: 1; + height: 100%; +`; + +function ContentsSection(): JSX.Element { + return ( + + + + + ); +} + +export default ContentsSection; diff --git a/frontend/src/components/Main/ContentsSection/ThreadSection.tsx b/frontend/src/components/Main/ContentsSection/ThreadSection.tsx new file mode 100644 index 0000000..84e9578 --- /dev/null +++ b/frontend/src/components/Main/ContentsSection/ThreadSection.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import styled from 'styled-components'; + +const Container = styled.div` + flex: 1; + height: 100%; +`; + +function ThreadSection(): JSX.Element { + return ThreadSection; +} + +export default ThreadSection; diff --git a/frontend/src/components/Main/MainSection.tsx b/frontend/src/components/Main/MainSection.tsx index ae5d517..5a92ebc 100644 --- a/frontend/src/components/Main/MainSection.tsx +++ b/frontend/src/components/Main/MainSection.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from 'react'; import styled from 'styled-components'; import RoomListSection from './RoomListSection'; -import ContentsSection from './ContentsSection'; +import ContentsSection from './ContentsSection/ContentsSection'; import MainHeader from './MainHeader'; const Container = styled.div` diff --git a/frontend/src/components/Main/MainStore.tsx b/frontend/src/components/Main/MainStore.tsx index 2607806..a068d02 100644 --- a/frontend/src/components/Main/MainStore.tsx +++ b/frontend/src/components/Main/MainStore.tsx @@ -29,11 +29,9 @@ function MainStore(props: MainStoreProps): JSX.Element { const [serverList, setServerList] = useState([]); const getServerChannelList = async (): Promise => { - console.log(selectedServer?.server.id); const response = await fetch(`/api/user/servers/${selectedServer?.server.id}/channels/joined/`); const list = await response.json(); const channelList = list.data; - console.log(channelList); if (channelList.length) { setSelectedChannel(channelList[0].id); } else { @@ -54,7 +52,6 @@ function MainStore(props: MainStoreProps): JSX.Element { }; useEffect(() => { - console.log(selectedServer); if (selectedServer) getServerChannelList(); }, [selectedServer]); From 3d535e3672615a2aa5ed8b469e9700c43819d186 Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Tue, 23 Nov 2021 16:27:07 +0900 Subject: [PATCH 084/172] =?UTF-8?q?Feat=20:=20Server=20=EB=82=98=EA=B0=80?= =?UTF-8?q?=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20=EC=98=88=EC=99=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버 생성자는 해당서버에서 나갈 수 없도록 처리하였습니다. --- backend/src/user-server/user-server.controller.ts | 12 +++++++++--- backend/src/user-server/user-server.repository.ts | 8 ++++++++ backend/src/user-server/user-server.service.ts | 11 ++++++++++- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/backend/src/user-server/user-server.controller.ts b/backend/src/user-server/user-server.controller.ts index 4ab3a08..4037c34 100644 --- a/backend/src/user-server/user-server.controller.ts +++ b/backend/src/user-server/user-server.controller.ts @@ -15,9 +15,10 @@ import { LoginGuard } from '../login/login.guard'; import { ExpressSession } from '../types/session'; import { UserServerService } from './user-server.service'; import ResponseEntity from '../common/response-entity'; +import { User } from '../user/user.entity'; @Controller('/api/users/servers') -@UseGuards(LoginGuard) +// @UseGuards(LoginGuard) export class UserServerController { constructor(private userServerService: UserServerService) {} @@ -44,9 +45,14 @@ export class UserServerController { @Delete('/:id') @HttpCode(HttpStatus.NO_CONTENT) - delete(@Param('id') id: number) { + async delete( + @Session() + session: ExpressSession, + @Param('id') id: number, + ) { try { - this.userServerService.deleteById(id); + const userId = session.user.id; + await this.userServerService.deleteById(id, userId); return ResponseEntity.noContent(); } catch (error) { if (error instanceof HttpException) { diff --git a/backend/src/user-server/user-server.repository.ts b/backend/src/user-server/user-server.repository.ts index 7317340..a267b5b 100644 --- a/backend/src/user-server/user-server.repository.ts +++ b/backend/src/user-server/user-server.repository.ts @@ -23,4 +23,12 @@ export class UserServerRepository extends Repository { .andWhere('user_server.server = :serverId', { serverId: serverId }) .getOne(); } + + findWithServerOwner(id: number) { + return this.createQueryBuilder('user_server') + .leftJoinAndSelect('user_server.server', 'server') + .leftJoinAndSelect('server.owner', 'user') + .where('user_server.id = :id', { id: id }) + .getOne(); + } } diff --git a/backend/src/user-server/user-server.service.ts b/backend/src/user-server/user-server.service.ts index c3b5bd8..4d549c9 100644 --- a/backend/src/user-server/user-server.service.ts +++ b/backend/src/user-server/user-server.service.ts @@ -39,7 +39,16 @@ export class UserServerService { return this.userServerRepository.save(newUserServer); } - deleteById(id: number): Promise { + async deleteById(id: number, userId: number): Promise { + const userServer = await this.userServerRepository.findWithServerOwner(id); + + if (!userServer) { + throw new BadRequestException('해당 서버에 참가하고 있지 않습니다.'); + } + if (userServer.server.owner.id === userId) { + throw new BadRequestException('서버 생성자는 서버에서 나갈 수 없습니다.'); + } + return this.userServerRepository.delete(id); } From 64f9cf0fa00f30c2550f14877f6b9a28940c6721 Mon Sep 17 00:00:00 2001 From: Suppplier <49611158+Suppplier@users.noreply.github.com> Date: Tue, 23 Nov 2021 16:33:23 +0900 Subject: [PATCH 085/172] =?UTF-8?q?Feat=20:=20favicon=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=20=EB=B0=8F=20manifest=20=ED=8C=8C=EC=9D=BC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit favicon 및 아이콘용 png 파일들을 이전에 만들었던 프로젝트 로고로 수정하고, 이에 맞게 manifest 파일 내용도 수정하였습니다. --- frontend/public/favicon-16x16.png | Bin 0 -> 608 bytes frontend/public/favicon-32x32.png | Bin 0 -> 1436 bytes frontend/public/favicon.ico | Bin 3870 -> 15406 bytes frontend/public/logo192.png | Bin 5347 -> 19893 bytes frontend/public/logo512.png | Bin 9664 -> 93484 bytes frontend/public/manifest.json | 4 ++-- 6 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 frontend/public/favicon-16x16.png create mode 100644 frontend/public/favicon-32x32.png diff --git a/frontend/public/favicon-16x16.png b/frontend/public/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..1b1212de76c61d1445ef9d3117666c5a721039ff GIT binary patch literal 608 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 literal 3870 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 baj7?x%VD!5Cxk2YbYLNVeiXvvpMCWYo=by@ diff --git a/frontend/public/logo192.png b/frontend/public/logo192.png index fc44b0a3796c0e0a64c3d858ca038bd4570465d9..fc69cf4bbe1e9b0eb4327ab308be4d1dd4be1b2f 100644 GIT binary patch literal 19893 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 VQ!4C-9hbuf;OtJ>Hd~WY{|ET4Py+w} literal 5347 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 X@eDJUQo;Ye2mwlRs~kxuE7R6@Ezx_jn7PyC+i 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

p@B|Kr+x5{vOQH78fH%Ke1{5TpPS8 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 V#~wN9)Qe^ST`fb+$~$%u{|DikZ@B;f literal 9664 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 b{|{}U^kRn2wo}j7|0ATu<;8xA7zX}7|B6mN diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json index 080d6c7..9fcf3c3 100644 --- a/frontend/public/manifest.json +++ b/frontend/public/manifest.json @@ -1,6 +1,6 @@ { - "short_name": "React App", - "name": "Create React App Sample", + "short_name": "boostCam", + "name": "BoostCamp Group Project boostCam", "icons": [ { "src": "favicon.ico", From f45cc85df59a6ab0a6f061a27bff75d1f02fc115 Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Tue, 23 Nov 2021 16:37:22 +0900 Subject: [PATCH 086/172] =?UTF-8?q?Test=20:=20Server=20owner=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버 생성자가 서버를 삭제할 경우에 대한 테스트코드를 추가하였습니다. --- .../user-server/user-server.service.spec.ts | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/backend/src/user-server/user-server.service.spec.ts b/backend/src/user-server/user-server.service.spec.ts index fee86fb..23cd676 100644 --- a/backend/src/user-server/user-server.service.spec.ts +++ b/backend/src/user-server/user-server.service.spec.ts @@ -16,6 +16,7 @@ const mockUserServerRepository = () => ({ findByUserIdAndServerId: jest.fn(), deleteByUserIdAndServerId: jest.fn(), getServerListByUserId: jest.fn(), + findWithServerOwner: jest.fn(), }); const mockServerRepository = () => ({ @@ -111,26 +112,53 @@ describe('UserServerService', () => { describe('deleteById()', () => { it('존재하는 id로 삭제할 경우', async () => { + const userNotOwner = 0; const existsId = existUserServerId; const returnedDeleteResult = new DeleteResult(); returnedDeleteResult.affected = existsId == existUserServer.id ? 1 : 0; userServerRepository.delete.mockResolvedValue(returnedDeleteResult); + userServerRepository.findWithServerOwner.mockResolvedValue(userServer); - const deleteResult: DeleteResult = await service.deleteById(existsId); + const deleteResult: DeleteResult = await service.deleteById( + existsId, + userNotOwner, + ); expect(deleteResult.affected).toBe(1); }); it('존재하지 않는 id로 삭제할 경우', async () => { + const userNotOwner = 0; const nonExistsId = 0; const returnedDeleteResult = new DeleteResult(); returnedDeleteResult.affected = nonExistsId == existUserServer.id ? 1 : 0; userServerRepository.delete.mockResolvedValue(returnedDeleteResult); + userServerRepository.findWithServerOwner.mockResolvedValue(userServer); - const deleteResult: DeleteResult = await service.deleteById(nonExistsId); + const deleteResult: DeleteResult = await service.deleteById( + nonExistsId, + userNotOwner, + ); expect(deleteResult.affected).toBe(0); }); + + it('서버 생성자가 서버를 삭제할 경우', async () => { + const nonExistsId = 0; + const returnedDeleteResult = new DeleteResult(); + returnedDeleteResult.affected = nonExistsId == existUserServer.id ? 1 : 0; + userServerRepository.findWithServerOwner.mockResolvedValue(userServer); + + try { + await service.deleteById(nonExistsId, userId); + } catch (error) { + expect(error.response.message).toBe( + '서버 생성자는 서버에서 나갈 수 없습니다.', + ); + expect(error.response.error).toBe('Bad Request'); + expect(error.response.statusCode).toBe(HttpStatus.BAD_REQUEST); + } + }); }); describe('getServerListByUserId()', () => { @@ -152,6 +180,7 @@ describe('UserServerService', () => { server = new Server(); server.id = serverId; + server.owner = user; userServer = new UserServer(); userServer.user = user; From 0273d289453bd6345e9f01a88d5db120b0b4cc06 Mon Sep 17 00:00:00 2001 From: korung3195 Date: Tue, 23 Nov 2021 16:52:44 +0900 Subject: [PATCH 087/172] =?UTF-8?q?Feat=20:=20ChannelList=EC=97=90?= =?UTF-8?q?=EC=84=9C=20Cam=20=EC=83=9D=EC=84=B1=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChannelList에서 Cam을 추가하는 모달을 추가하고, 관련 로직을 구현하였습니다. --- frontend/src/components/Main/Cam/CamList.tsx | 42 ++++ .../src/components/Main/Cam/CamListHeader.tsx | 91 +++++++ .../components/Main/Cam/CreateCamModal.tsx | 236 ++++++++++++++++++ frontend/src/components/Main/CamList.tsx | 105 -------- .../Main/ChannelModal/CreateChannelModal.tsx | 16 -- frontend/src/components/Main/MainPage.tsx | 3 + frontend/src/components/Main/MainStore.tsx | 8 + .../src/components/Main/RoomListSection.tsx | 6 +- 8 files changed, 382 insertions(+), 125 deletions(-) create mode 100644 frontend/src/components/Main/Cam/CamList.tsx create mode 100644 frontend/src/components/Main/Cam/CamListHeader.tsx create mode 100644 frontend/src/components/Main/Cam/CreateCamModal.tsx delete mode 100644 frontend/src/components/Main/CamList.tsx diff --git a/frontend/src/components/Main/Cam/CamList.tsx b/frontend/src/components/Main/Cam/CamList.tsx new file mode 100644 index 0000000..749f8a2 --- /dev/null +++ b/frontend/src/components/Main/Cam/CamList.tsx @@ -0,0 +1,42 @@ +import React, { useState } from 'react'; +import styled from 'styled-components'; + +import CamListHeader from './CamListHeader'; + +const Container = styled.div` + width: 100%; + height: 100%; + background-color: #492148; + + margin-top: 10px; + + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; +`; + +const CamListBody = styled.div` + width: 100%; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + + color: #a69c96; + font-size: 15px; +`; + +function CamList(): JSX.Element { + const [isListOpen, setIsListOpen] = useState(false); + const listElements: Array = []; + + return ( + + + {isListOpen && {listElements}} + + ); +} + +export default CamList; diff --git a/frontend/src/components/Main/Cam/CamListHeader.tsx b/frontend/src/components/Main/Cam/CamListHeader.tsx new file mode 100644 index 0000000..8a768af --- /dev/null +++ b/frontend/src/components/Main/Cam/CamListHeader.tsx @@ -0,0 +1,91 @@ +import React, { useContext, useState } from 'react'; +import styled from 'styled-components'; + +import { BoostCamMainIcons } from '../../../utils/SvgIcons'; +import { MainStoreContext } from '../MainStore'; +import Dropdown from '../../core/Dropdown'; +import DropdownMenu from '../../core/DropdownMenu'; + +const { Plus, ListArrow } = BoostCamMainIcons; + +const Container = styled.div` + width: 90%; + height: 30px; + + margin-left: 15px; + color: #a69c96; + font-size: 17px; + + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + + &:hover { + cursor: pointer; + } +`; + +const CamListHeaderSpan = styled.span` + margin-left: 5px; +`; + +const CamListHeaderButton = styled.div<{ isButtonVisible: boolean }>` + margin-left: 70px; + visibility: ${(props) => (props.isButtonVisible ? 'visible' : 'hidden')}; +`; + +const ListArrowIcon = styled(ListArrow)<{ isListOpen: boolean }>` + width: 20px; + height: 20px; + fill: #a69c96; + transition: all ease-out 0.3s; + ${(props) => (props.isListOpen ? 'transform: rotate(90deg);' : 'transform: rotate(0deg);')} +`; + +const PlusIcon = styled(Plus)` + width: 20px; + height: 20px; + fill: #a69c96; +`; + +type CamListHeaderProps = { + isListOpen: boolean; + setIsListOpen: React.Dispatch>; +}; + +function CamListHeader(props: CamListHeaderProps): JSX.Element { + const [isButtonVisible, setIsButtonVisible] = useState(false); + const [isDropdownActivated, setIsDropdownActivated] = useState(false); + const { isListOpen, setIsListOpen } = props; + const { isCreateCamModalOpen, setIsCreateCamModalOpen } = useContext(MainStoreContext); + + const onClickCamAddButton = (e: React.MouseEvent) => { + e.stopPropagation(); + setIsDropdownActivated(!isDropdownActivated); + }; + + return ( + setIsButtonVisible(true)} + onMouseLeave={() => setIsButtonVisible(false)} + onClick={() => setIsListOpen(!isListOpen)} + > + + Cam + + + + + + + + ); +} + +export default CamListHeader; diff --git a/frontend/src/components/Main/Cam/CreateCamModal.tsx b/frontend/src/components/Main/Cam/CreateCamModal.tsx new file mode 100644 index 0000000..1cd785e --- /dev/null +++ b/frontend/src/components/Main/Cam/CreateCamModal.tsx @@ -0,0 +1,236 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import styled from 'styled-components'; + +import { MainStoreContext } from '../MainStore'; +import { BoostCamMainIcons } from '../../../utils/SvgIcons'; + +const { Close } = BoostCamMainIcons; + +const Container = styled.div` + position: fixed; + width: 100vw; + height: 100vh; + left: 0px; + right: 0px; + + display: flex; + justify-content: space-around; + align-items: center; +`; + +const ModalBackground = styled.div` + position: fixed; + left: 0px; + right: 0px; + width: 100%; + height: 100%; + background-color: rgb(0, 0, 0, 0.5); +`; + +const ModalBox = styled.div` + width: 35%; + min-width: 400px; + height: 50%; + min-height: 450px; + + background-color: #222322; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + border-radius: 20px; + + z-index: 3; +`; + +const ModalInnerBox = styled.div` + width: 100%; + height: 100%; + padding: 30px 10px; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; +`; + +const ModalHeader = styled.div` + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +`; + +const ModalTitle = styled.span` + margin-left: 25px; + padding: 10px 5px; + + color: #cbc4b9; + font-size: 32px; + font-weight: 600; +`; + +const ModalDescription = styled.span` + margin-left: 25px; + padding: 10px 5px; + + color: #cbc4b9; + font-size: 15px; +`; + +const Form = styled.form` + width: 90%; + height: 70%; + border-radius: 20px; + margin: 30px 0px 0px 25px; + + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; +`; + +const InputDiv = styled.div` + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; +`; + +const InputName = styled.span` + color: #cbc4b9; + font-size: 20px; + font-weight: 500; +`; + +const Input = styled.input` + width: 90%; + border: none; + outline: none; + padding: 15px 10px; + margin-top: 10px; + border-radius: 10px; +`; + +const InputErrorMessage = styled.span` + padding: 5px 0px; + color: red; +`; + +const SubmitButton = styled.button<{ isButtonActive: boolean }>` + width: 100px; + height: 50px; + background: none; + + padding: 15px 10px; + + border: 0; + outline: 0; + + text-align: center; + vertical-align: middle; + + border-radius: 10px; + background-color: ${(props) => (props.isButtonActive ? '#26a9ca' : 'gray')}; + cursor: pointer; + transition: all 0.3s; + + &:hover { + background-color: ${(props) => (props.isButtonActive ? '#2dc2e6' : 'gray')}; + transition: all 0.3s; + } +`; + +const ModalCloseButton = styled.div` + width: 32px; + height: 32px; + display: flex; + flex-direction: center; + align-items: center; + + cursor: pointer; + margin-right: 25px; +`; + +const CloseIcon = styled(Close)` + width: 20px; + height: 20px; + fill: #a69c96; +`; + +type CreateModalForm = { + name: string; + description: string; +}; + +function CreateCamModal(): JSX.Element { + const { + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm(); + const { selectedServer, setIsCreateCamModalOpen } = useContext(MainStoreContext); + const [isButtonActive, setIsButtonActive] = useState(false); + + const onSubmitCreateCamModal = async (data: { name: string; description: string }) => { + const { name } = data; + await fetch('api/cams', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: name.trim(), + serverId: selectedServer.server.id, + }), + }); + setIsCreateCamModalOpen(false); + }; + + useEffect(() => { + const { name } = watch(); + const isActive = name.trim().length > 2; + setIsButtonActive(isActive); + }, [watch()]); + + /* eslint-disable react/jsx-props-no-spreading */ + return ( + + setIsCreateCamModalOpen(false)} /> + + + + Cam 생성 + setIsCreateCamModalOpen(false)}> + + + + 생성할 Cam의 이름을 작성해주세요 +

+ + 이름 + value.trim().length > 2 || '"이름" 칸은 3글자 이상 입력되어야합니다!', + })} + placeholder="Cam명을 입력해주세요" + /> + {errors.name && {errors.name.message}} + + + 생성 + + + + + + ); +} + +export default CreateCamModal; diff --git a/frontend/src/components/Main/CamList.tsx b/frontend/src/components/Main/CamList.tsx deleted file mode 100644 index b719be2..0000000 --- a/frontend/src/components/Main/CamList.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React, { useEffect } from 'react'; -import styled from 'styled-components'; - -import { BoostCamMainIcons } from '../../utils/SvgIcons'; - -const { Hash } = BoostCamMainIcons; - -const Container = styled.div` - width: 100%; - height: 100%; - background-color: #492148; - - margin-top: 10px; - - display: flex; - flex-direction: column; - justify-content: flex-start; - align-items: flex-start; -`; - -const CamListHeader = styled.div` - width: 100%; - height: 30px; - - margin-left: 15px; - color: #a69c96; - font-size: 17px; - - &:hover { - cursor: pointer; - } -`; - -const CamListBody = styled.div` - width: 100%; - display: flex; - flex-direction: column; - justify-content: flex-start; - align-items: flex-start; - - color: #a69c96; - font-size: 15px; -`; - -const CamNameBlock = styled.div` - width: 100%; - height: 25px; - - display: flex; - flex-direction: row; - justify-content: flex-start; - align-items: center; - - box-sizing: border-box; - margin-top: 5px; - padding-left: 25px; - &:hover { - cursor: pointer; - background-color: #321832; - } -`; - -const CamNameSpan = styled.span` - padding: 5px 0px 5px 5px; -`; - -const HashIcon = styled(Hash)` - width: 15px; - height: 15px; - fill: #a69c96; -`; - -function CamList(): JSX.Element { - useEffect(() => {}, []); - - return ( - - Cam - - - - Cam 1 - - - - Cam 2 - - - - Cam 3 - - - - Cam 4 - - - - Cam 5 - - - - ); -} - -export default CamList; diff --git a/frontend/src/components/Main/ChannelModal/CreateChannelModal.tsx b/frontend/src/components/Main/ChannelModal/CreateChannelModal.tsx index f9bab44..1b39487 100644 --- a/frontend/src/components/Main/ChannelModal/CreateChannelModal.tsx +++ b/frontend/src/components/Main/ChannelModal/CreateChannelModal.tsx @@ -195,19 +195,6 @@ function CreateChannelModal(): JSX.Element { setIsCreateChannelModalOpen(false); }; - const createCams = async () => { - await fetch('api/cams', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - name: 'test', - serverId: selectedServer.server.id, - }), - }); - }; - useEffect(() => { const { name, description } = watch(); const isActive = name.trim().length > 2 && description.trim().length > 0; @@ -252,9 +239,6 @@ function CreateChannelModal(): JSX.Element { 생성 - diff --git a/frontend/src/components/Main/MainPage.tsx b/frontend/src/components/Main/MainPage.tsx index 1cb16fb..aac1231 100644 --- a/frontend/src/components/Main/MainPage.tsx +++ b/frontend/src/components/Main/MainPage.tsx @@ -13,6 +13,7 @@ import ServerInfoModal from './ServerModal/ServerInfoModal'; import QuitServerModal from './ServerModal/QuitServerModal'; import UpdateChannelModal from './ChannelModal/UpdateChannelModal'; import QuitChannelModal from './ChannelModal/QuitChannelModal '; +import CreateCamModal from './Cam/CreateCamModal'; const Container = styled.div` width: 100vw; @@ -35,6 +36,7 @@ function MainPage(): JSX.Element { isServerInfoModalOpen, isServerSettingModalOpen, isQuitServerModalOpen, + isCreateCamModalOpen, } = useContext(MainStoreContext); useEffect(() => {}, []); @@ -49,6 +51,7 @@ function MainPage(): JSX.Element { {isServerSettingModalOpen && } {isServerInfoModalOpen && } {isQuitServerModalOpen && } + {isCreateCamModalOpen && } diff --git a/frontend/src/components/Main/MainStore.tsx b/frontend/src/components/Main/MainStore.tsx index 98bd301..802e51d 100644 --- a/frontend/src/components/Main/MainStore.tsx +++ b/frontend/src/components/Main/MainStore.tsx @@ -28,6 +28,8 @@ function MainStore(props: MainStoreProps): JSX.Element { const [serverList, setServerList] = useState([]); + const [isCreateCamModalOpen, setIsCreateCamModalOpen] = useState(false); + const getServerChannelList = async (): Promise => { const response = await fetch(`/api/user/servers/${selectedServer?.server.id}/channels/joined/`); const list = await response.json(); @@ -49,6 +51,10 @@ function MainStore(props: MainStoreProps): JSX.Element { } }; + // const getServerCamList = async (): Promise => { + // const response = await fetch(``); API를 만든 다음에 연결하기 + // }; + useEffect(() => { if (selectedServer) getServerChannelList(); }, [selectedServer]); @@ -70,6 +76,7 @@ function MainStore(props: MainStoreProps): JSX.Element { isServerInfoModalOpen, isServerSettingModalOpen, isQuitServerModalOpen, + isCreateCamModalOpen, serverList, setSelectedServer, setSelectedChannel, @@ -86,6 +93,7 @@ function MainStore(props: MainStoreProps): JSX.Element { setIsServerInfoModalOpen, setIsServerSettingModalOpen, setIsQuitServerModalOpen, + setIsCreateCamModalOpen, setServerList, getUserServerList, }} diff --git a/frontend/src/components/Main/RoomListSection.tsx b/frontend/src/components/Main/RoomListSection.tsx index 9ef9890..3d4c7f2 100644 --- a/frontend/src/components/Main/RoomListSection.tsx +++ b/frontend/src/components/Main/RoomListSection.tsx @@ -1,8 +1,8 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import styled from 'styled-components'; import ChannelList from './ChannelList'; -import CamList from './CamList'; +import CamList from './Cam/CamList'; const Container = styled.div` width: 180px; @@ -11,8 +11,6 @@ const Container = styled.div` `; function RoomListSection(): JSX.Element { - useEffect(() => {}, []); - return ( From bf670a4f5935e2b99f7ed3a29ccfec2215ad6d4f Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Tue, 23 Nov 2021 17:04:25 +0900 Subject: [PATCH 088/172] =?UTF-8?q?Feat=20:=20UserServerListDto=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 목록을 불러올 때 UserServerListDto를 거쳐서 리턴하도록 수정하였습니다. - RequestServerDto 파일네임을 통일하였습니다. --- ...{RequestServerDto.ts => request-server.dto.ts} | 0 backend/src/server/server.controller.ts | 2 +- backend/src/server/server.service.spec.ts | 2 +- backend/src/server/server.service.ts | 2 +- .../src/user-server/dto/user-server-list.dto.ts | 13 +++++++++++++ backend/src/user-server/user-server.controller.ts | 3 +-- .../src/user-server/user-server.service.spec.ts | 3 ++- backend/src/user-server/user-server.service.ts | 15 +++++++++++++-- backend/src/user/user.controller.ts | 4 ++-- 9 files changed, 34 insertions(+), 10 deletions(-) rename backend/src/server/dto/{RequestServerDto.ts => request-server.dto.ts} (100%) create mode 100644 backend/src/user-server/dto/user-server-list.dto.ts diff --git a/backend/src/server/dto/RequestServerDto.ts b/backend/src/server/dto/request-server.dto.ts similarity index 100% rename from backend/src/server/dto/RequestServerDto.ts rename to backend/src/server/dto/request-server.dto.ts diff --git a/backend/src/server/server.controller.ts b/backend/src/server/server.controller.ts index c7baa57..ddf9d51 100644 --- a/backend/src/server/server.controller.ts +++ b/backend/src/server/server.controller.ts @@ -18,7 +18,7 @@ import { import { ServerService } from './server.service'; import { Server } from './server.entity'; import { LoginGuard } from '../login/login.guard'; -import RequestServerDto from './dto/RequestServerDto'; +import RequestServerDto from './dto/request-server.dto'; import { ExpressSession } from '../types/session'; import ResponseEntity from '../common/response-entity'; import { FileInterceptor } from '@nestjs/platform-express'; diff --git a/backend/src/server/server.service.spec.ts b/backend/src/server/server.service.spec.ts index 03e45bc..4e3f93b 100644 --- a/backend/src/server/server.service.spec.ts +++ b/backend/src/server/server.service.spec.ts @@ -6,7 +6,7 @@ import { UserServer } from '../user-server/user-server.entity'; import { UserServerRepository } from '../user-server/user-server.repository'; import { UserServerService } from '../user-server/user-server.service'; import { User } from '../user/user.entity'; -import RequestServerDto from './dto/RequestServerDto'; +import RequestServerDto from './dto/request-server.dto'; import { Server } from './server.entity'; import { ServerRepository } from './server.repository'; import { ServerService } from './server.service'; diff --git a/backend/src/server/server.service.ts b/backend/src/server/server.service.ts index ef2e506..cc94248 100644 --- a/backend/src/server/server.service.ts +++ b/backend/src/server/server.service.ts @@ -9,7 +9,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { User } from '../user/user.entity'; import { Server } from './server.entity'; -import RequestServerDto from './dto/RequestServerDto'; +import RequestServerDto from './dto/request-server.dto'; import { UserServerService } from '../user-server/user-server.service'; import { ServerRepository } from './server.repository'; diff --git a/backend/src/user-server/dto/user-server-list.dto.ts b/backend/src/user-server/dto/user-server-list.dto.ts new file mode 100644 index 0000000..5f2b762 --- /dev/null +++ b/backend/src/user-server/dto/user-server-list.dto.ts @@ -0,0 +1,13 @@ +import { Server } from '../../server/server.entity'; + +class UserServerListDto { + id: number; + server: Server; + + constructor(id: number, server: Server) { + this.id = id; + this.server = server; + } +} + +export default UserServerListDto; diff --git a/backend/src/user-server/user-server.controller.ts b/backend/src/user-server/user-server.controller.ts index 4037c34..4583f6f 100644 --- a/backend/src/user-server/user-server.controller.ts +++ b/backend/src/user-server/user-server.controller.ts @@ -15,10 +15,9 @@ import { LoginGuard } from '../login/login.guard'; import { ExpressSession } from '../types/session'; import { UserServerService } from './user-server.service'; import ResponseEntity from '../common/response-entity'; -import { User } from '../user/user.entity'; @Controller('/api/users/servers') -// @UseGuards(LoginGuard) +@UseGuards(LoginGuard) export class UserServerController { constructor(private userServerService: UserServerService) {} diff --git a/backend/src/user-server/user-server.service.spec.ts b/backend/src/user-server/user-server.service.spec.ts index 23cd676..f6a3d9c 100644 --- a/backend/src/user-server/user-server.service.spec.ts +++ b/backend/src/user-server/user-server.service.spec.ts @@ -170,7 +170,8 @@ describe('UserServerService', () => { const userServerList = await service.getServerListByUserId(userId); - expect(userServerList[0]).toBe(existUserServer); + expect(userServerList[0].id).toBe(existUserServer.id); + expect(userServerList[0].server).toBe(existUserServer.server); }); }); diff --git a/backend/src/user-server/user-server.service.ts b/backend/src/user-server/user-server.service.ts index 4d549c9..0a7d9af 100644 --- a/backend/src/user-server/user-server.service.ts +++ b/backend/src/user-server/user-server.service.ts @@ -10,6 +10,7 @@ import { UserServer } from './user-server.entity'; import { DeleteQueryBuilder, DeleteResult } from 'typeorm'; import { User } from '../user/user.entity'; import { ServerService } from '../server/server.service'; +import UserServerListDto from './dto/user-server-list.dto'; @Injectable() export class UserServerService { @@ -62,7 +63,17 @@ export class UserServerService { ); } - getServerListByUserId(userId: number): Promise { - return this.userServerRepository.getServerListByUserId(userId); + async getServerListByUserId(userId: number): Promise { + const userServerListDto = []; + + const userServerList = + await this.userServerRepository.getServerListByUserId(userId); + userServerList.map((userServer) => { + userServerListDto.push( + new UserServerListDto(userServer.id, userServer.server), + ); + }); + + return userServerListDto; } } diff --git a/backend/src/user/user.controller.ts b/backend/src/user/user.controller.ts index 32728ce..10e370e 100644 --- a/backend/src/user/user.controller.ts +++ b/backend/src/user/user.controller.ts @@ -1,9 +1,9 @@ import { Controller, Get, UseGuards, Session } from '@nestjs/common'; -import { UserServer } from '../user-server/user-server.entity'; import { UserServerService } from '../user-server/user-server.service'; import { LoginGuard } from '../login/login.guard'; import { ExpressSession } from '../types/session'; import ResponseEntity from '../common/response-entity'; +import UserServerListDto from '../user-server/dto/user-server-list.dto'; @Controller('/api/user') @UseGuards(LoginGuard) @@ -14,7 +14,7 @@ export class UserController { async getServersByUserId( @Session() session: ExpressSession, - ): Promise> { + ): Promise> { const userId = session.user.id; const data = await this.userServerService.getServerListByUserId(userId); From ea20502e50f42df00e4aa17c71e4f4cc1abd92c5 Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Tue, 23 Nov 2021 17:42:00 +0900 Subject: [PATCH 089/172] =?UTF-8?q?Refactor=20:=20Server=20Entity=EB=A5=BC?= =?UTF-8?q?=20=EB=A7=8C=EB=93=9C=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Server Entity를 반환하는 함수를 Server에 추가하였습니다. - ReqeustServerDto에 ServerEntity를 반환하는 함수를 추가하였습니다. --- backend/src/server/dto/request-server.dto.ts | 6 ++++++ backend/src/server/server.controller.ts | 4 ++++ backend/src/server/server.entity.ts | 7 +++++++ backend/src/server/server.service.ts | 11 +++++------ 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/backend/src/server/dto/request-server.dto.ts b/backend/src/server/dto/request-server.dto.ts index 5733a56..8a05587 100644 --- a/backend/src/server/dto/request-server.dto.ts +++ b/backend/src/server/dto/request-server.dto.ts @@ -1,3 +1,5 @@ +import { Server } from '../server.entity'; + class RequestServerDto { name: string; description: string; @@ -6,6 +8,10 @@ class RequestServerDto { this.name = name; this.description = description; } + + toServerEntity = () => { + return Server.newInstance(this.name, this.description); + }; } export default RequestServerDto; diff --git a/backend/src/server/server.controller.ts b/backend/src/server/server.controller.ts index ddf9d51..2690a8c 100644 --- a/backend/src/server/server.controller.ts +++ b/backend/src/server/server.controller.ts @@ -66,6 +66,10 @@ export class ServerController { @UploadedFile() icon: Express.Multer.File, ): Promise> { try { + requestServerDto = new RequestServerDto( + requestServerDto.name, + requestServerDto.description, + ); let imgUrl: string; if (icon !== undefined && icon.mimetype.substring(0, 5) === 'image') { diff --git a/backend/src/server/server.entity.ts b/backend/src/server/server.entity.ts index 20930e2..229f983 100644 --- a/backend/src/server/server.entity.ts +++ b/backend/src/server/server.entity.ts @@ -29,4 +29,11 @@ export class Server { @OneToMany(() => UserServer, (userServer) => userServer.server) userServer: UserServer[]; + + static newInstance(description: string, name: string): Server { + const server = new Server(); + server.description = description; + server.name = name; + return server; + } } diff --git a/backend/src/server/server.service.ts b/backend/src/server/server.service.ts index cc94248..8a9016a 100644 --- a/backend/src/server/server.service.ts +++ b/backend/src/server/server.service.ts @@ -12,6 +12,7 @@ import { Server } from './server.entity'; import RequestServerDto from './dto/request-server.dto'; import { UserServerService } from '../user-server/user-server.service'; import { ServerRepository } from './server.repository'; +import { request } from 'http'; @Injectable() export class ServerService { @@ -35,13 +36,11 @@ export class ServerService { requestServerDto: RequestServerDto, imgUrl: string | undefined, ): Promise { - const newServer = new Server(); - newServer.name = requestServerDto.name; - newServer.description = requestServerDto.description; - newServer.owner = user; - newServer.imgUrl = imgUrl || ''; + const server = requestServerDto.toServerEntity(); + server.owner = user; + server.imgUrl = imgUrl || ''; - const createdServer = await this.serverRepository.save(newServer); + const createdServer = await this.serverRepository.save(server); this.userServerService.create(user, createdServer.id); return createdServer; From da3e34d81c40b7b1cd0a0afc27220325589954f7 Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Tue, 23 Nov 2021 17:52:47 +0900 Subject: [PATCH 090/172] =?UTF-8?q?Refactor=20:=20UserServerDto=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=ED=95=A8=EC=88=98=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UserServer Entity로 UserServerDto를 반환하는 함수를 분리하였습니다. --- backend/src/user-server/dto/user-server-list.dto.ts | 8 ++++++-- backend/src/user-server/user-server.service.ts | 12 +++++------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/backend/src/user-server/dto/user-server-list.dto.ts b/backend/src/user-server/dto/user-server-list.dto.ts index 5f2b762..0bada27 100644 --- a/backend/src/user-server/dto/user-server-list.dto.ts +++ b/backend/src/user-server/dto/user-server-list.dto.ts @@ -1,6 +1,6 @@ import { Server } from '../../server/server.entity'; -class UserServerListDto { +class UserServerDto { id: number; server: Server; @@ -8,6 +8,10 @@ class UserServerListDto { this.id = id; this.server = server; } + + static fromEntity(userServerEntity: UserServerDto) { + return new UserServerDto(userServerEntity.id, userServerEntity.server); + } } -export default UserServerListDto; +export default UserServerDto; diff --git a/backend/src/user-server/user-server.service.ts b/backend/src/user-server/user-server.service.ts index 0a7d9af..6291882 100644 --- a/backend/src/user-server/user-server.service.ts +++ b/backend/src/user-server/user-server.service.ts @@ -10,7 +10,7 @@ import { UserServer } from './user-server.entity'; import { DeleteQueryBuilder, DeleteResult } from 'typeorm'; import { User } from '../user/user.entity'; import { ServerService } from '../server/server.service'; -import UserServerListDto from './dto/user-server-list.dto'; +import UserServerDto from './dto/user-server-list.dto'; @Injectable() export class UserServerService { @@ -63,17 +63,15 @@ export class UserServerService { ); } - async getServerListByUserId(userId: number): Promise { - const userServerListDto = []; + async getServerListByUserId(userId: number): Promise { + const userServerDtoList = []; const userServerList = await this.userServerRepository.getServerListByUserId(userId); userServerList.map((userServer) => { - userServerListDto.push( - new UserServerListDto(userServer.id, userServer.server), - ); + userServerDtoList.push(UserServerDto.fromEntity(userServer)); }); - return userServerListDto; + return userServerDtoList; } } From 2bd2cfdddfff5aef2c2cb509d10796be99cd497d Mon Sep 17 00:00:00 2001 From: Suppplier <49611158+Suppplier@users.noreply.github.com> Date: Tue, 23 Nov 2021 18:53:13 +0900 Subject: [PATCH 091/172] =?UTF-8?q?Feat=20:=20ChattingSection=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chatting 기능 및 출력을 위한 frontend 컴포넌트를 구현 중 입니다. --- .../Main/ContentsSection/ChattingSection.tsx | 157 +++++++++++++++++- 1 file changed, 155 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Main/ContentsSection/ChattingSection.tsx b/frontend/src/components/Main/ContentsSection/ChattingSection.tsx index c463fed..2f99ddc 100644 --- a/frontend/src/components/Main/ContentsSection/ChattingSection.tsx +++ b/frontend/src/components/Main/ContentsSection/ChattingSection.tsx @@ -1,13 +1,166 @@ import React from 'react'; import styled from 'styled-components'; +import { BoostCamMainIcons } from '../../../utils/SvgIcons'; + +const { ListArrow } = BoostCamMainIcons; + const Container = styled.div` - flex: 1; + width: 100%; height: 100%; + + background-color: white; + + display: flex; + flex-direction: column; + justify-content: flex-start; +`; + +const ChattingSectionHeader = styled.div` + width: 100%; + height: 25px; + flex: 0.5; + + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +`; + +const ChannelName = styled.div` + margin-left: 15px; + padding: 3px 5px; + border-radius: 10px; + + cursor: pointer; + + &:hover { + background-color: #f0e7e7; + } +`; + +const ChannelUserButton = styled.div` + margin-right: 15px; + padding: 3px 5px; + border: 1px solid gray; + border-radius: 10px; + + cursor: pointer; + + &:hover { + background-color: #f0e7e7; + } +`; + +const ChattingSectionBody = styled.div` + width: 100%; + flex: 5; + + overflow-y: auto; + + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; +`; + +const ChattingItemBlock = styled.div` + width: 100%; + display: flex; + flex-direction: row; + align-items: flex-start; + + &:hover { + background-color: #f0e7e7; + } +`; + +const ChattingItemIcon = styled.div` + width: 36px; + height: 36px; + margin: 10px; + background-color: indigo; + border-radius: 8px; +`; + +const ChattingItem = styled.div` + width: 90%; + padding: 8px 0px; + + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; +`; + +const ChattingItemHeader = styled.div` + min-width: 150px; + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; +`; + +const ChattingSender = styled.span` + font-weight: 600; + font-size: 15px; +`; + +const ChattingTimelog = styled.span` + font-size: 10px; +`; + +const ChattingContents = styled.span` + font-size: 13px; +`; + +const ChattingTextarea = styled.textarea` + width: 100%; + flex: 1; +`; + +const ListArrowIcon = styled(ListArrow)` + width: 20px; + height: 20px; + fill: #a69c96; + transform: rotate(90deg); `; function ChattingSection(): JSX.Element { - return ChattingSection; + const tmpAry = new Array(30).fill('value'); + + const tmpChattingItems = tmpAry.map((val: string, idx: number): JSX.Element => { + const key = `${val}-${idx}`; + const tmp = new Array(idx).fill('chatting'); + const contents = tmp.reduce((acc, va) => { + return `${acc}-${va}`; + }, ''); + return ( + + + + + Sender {idx} + Timestamp + + + `${contents}-${idx}` + + + + ); + }); + + return ( + + + # ChannelName + Users 5 + + {tmpChattingItems} + + + ); } export default ChattingSection; From 521d96929ebe8f8deccf7b47a2e56d2c6913b1b9 Mon Sep 17 00:00:00 2001 From: k Date: Tue, 23 Nov 2021 18:55:20 +0900 Subject: [PATCH 092/172] =?UTF-8?q?Feat=20:=20MessageDto=20=EC=99=80=20Use?= =?UTF-8?q?rDto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 레이어 사이에 객체 교환을 위한 DTO 객체입니다. --- backend/src/message/message.dto.ts | 36 ++++++++++++++++++++++++++++++ backend/src/user/user.dto.ts | 19 ++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 backend/src/message/message.dto.ts create mode 100644 backend/src/user/user.dto.ts diff --git a/backend/src/message/message.dto.ts b/backend/src/message/message.dto.ts new file mode 100644 index 0000000..e81a9ab --- /dev/null +++ b/backend/src/message/message.dto.ts @@ -0,0 +1,36 @@ +import { UserDto } from '../user/user.dto'; +import { Message } from './message.entity'; + +export class MessageDto { + id: number; + contents: string; + channelId: number; + createdAt: Date; + sender: UserDto; + + static newInstance( + id: number, + contents: string, + channelId: number, + createdAt: Date, + sender: UserDto, + ) { + const newInstance = new MessageDto(); + newInstance.id = id; + newInstance.contents = contents; + newInstance.channelId = channelId; + newInstance.createdAt = createdAt; + newInstance.sender = sender; + return newInstance; + } + + static fromEntity(message: Message) { + return MessageDto.newInstance( + message.id, + message.contents, + message.channel.id, + message.createdAt, + UserDto.fromEntity(message.sender), + ); + } +} diff --git a/backend/src/user/user.dto.ts b/backend/src/user/user.dto.ts new file mode 100644 index 0000000..8c07a60 --- /dev/null +++ b/backend/src/user/user.dto.ts @@ -0,0 +1,19 @@ +import { User } from './user.entity'; + +export class UserDto { + id: number; + nickname: string; + profile: string; + + static newInstance(id: number, nickname: string, profile: string) { + const newInstance = new UserDto(); + newInstance.id = id; + newInstance.nickname = nickname; + newInstance.profile = profile; + return newInstance; + } + + static fromEntity(user: User): UserDto { + return UserDto.newInstance(user.id, user.nickname, user.profile); + } +} From d01f275d238b1e47e5be91f15679130636ae84e6 Mon Sep 17 00:00:00 2001 From: k Date: Tue, 23 Nov 2021 18:56:24 +0900 Subject: [PATCH 093/172] =?UTF-8?q?Feat=20:=20MessageController=EC=97=90?= =?UTF-8?q?=EC=84=9C=20DTO=EC=99=80=20ResponseEntity=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/message/message.controller.ts | 8 ++++++-- backend/src/message/message.service.ts | 5 +++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/backend/src/message/message.controller.ts b/backend/src/message/message.controller.ts index 5ad44b8..d9c1eb9 100644 --- a/backend/src/message/message.controller.ts +++ b/backend/src/message/message.controller.ts @@ -1,6 +1,8 @@ import { Body, Controller, Post, Session, UseGuards } from '@nestjs/common'; +import ResponseEntity from '../common/response-entity'; import { LoginGuard } from '../login/login.guard'; import { ExpressSession } from '../types/session'; +import { MessageDto } from './message.dto'; import { MessageService } from './message.service'; @Controller('/api/messages') @@ -13,12 +15,14 @@ export class MessageController { @Session() session: ExpressSession, @Body('channelId') channelId: number, @Body('contents') contents: string, - ) { + ): Promise> { const sender = session.user; - return await this.messageService.sendMessage( + const newMessage = await this.messageService.sendMessage( sender.id, channelId, contents, ); + + return ResponseEntity.ok(newMessage); } } diff --git a/backend/src/message/message.service.ts b/backend/src/message/message.service.ts index 1a98773..a21d3a4 100644 --- a/backend/src/message/message.service.ts +++ b/backend/src/message/message.service.ts @@ -4,6 +4,7 @@ import { Repository } from 'typeorm'; import { Channel } from '../channel/channel.entity'; import { UserServer } from '../user-server/user-server.entity'; import { User } from '../user/user.entity'; +import { MessageDto } from './message.dto'; import { Message } from './message.entity'; @Injectable() @@ -20,7 +21,7 @@ export class MessageService { senderId: number, channelId: number, contents: string, - ): Promise { + ): Promise { let newMessage; const userServer = await this.userServerRepository @@ -39,6 +40,6 @@ export class MessageService { newMessage = Message.newInstace(contents, channel, sender); newMessage = await this.messageRepository.save(newMessage); - return newMessage; + return MessageDto.fromEntity(newMessage); } } From 6a0b7c0a1889ece0059acaa9d61b23c7a5f0817b Mon Sep 17 00:00:00 2001 From: k Date: Tue, 23 Nov 2021 18:57:08 +0900 Subject: [PATCH 094/172] =?UTF-8?q?Feat=20:=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=20=EB=B3=B4=EB=82=B4=EB=8A=94=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST 요청을 보내는 컴포넌트 입니다 --- .../src/components/Main/ContentsSection.tsx | 7 ++- .../src/components/Main/Message/Message.tsx | 52 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/Main/Message/Message.tsx diff --git a/frontend/src/components/Main/ContentsSection.tsx b/frontend/src/components/Main/ContentsSection.tsx index 8f91301..da74181 100644 --- a/frontend/src/components/Main/ContentsSection.tsx +++ b/frontend/src/components/Main/ContentsSection.tsx @@ -1,5 +1,6 @@ import React, { useEffect } from 'react'; import styled from 'styled-components'; +import Message from './Message/Message'; const Container = styled.div` flex: 1; @@ -9,7 +10,11 @@ const Container = styled.div` function ContentsSection(): JSX.Element { useEffect(() => {}, []); - return ContentsSection; + return ( + + + + ); } export default ContentsSection; diff --git a/frontend/src/components/Main/Message/Message.tsx b/frontend/src/components/Main/Message/Message.tsx new file mode 100644 index 0000000..1568ce3 --- /dev/null +++ b/frontend/src/components/Main/Message/Message.tsx @@ -0,0 +1,52 @@ +import React, { useState } from 'react'; + +const sendMessage = async (channelId: number, contents: string) => { + const requestBody = { + channelId, + contents, + }; + + const response = await fetch('/api/messages', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + const responseBody = await response.json(); + return responseBody; +}; + +function Message(): JSX.Element { + const [contents, setContents] = useState(''); + + const onInputChange = (e: React.ChangeEvent) => { + setContents(e.target.value); + }; + + const onInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key !== 'Enter') { + return; + } + sendMessage(1, contents); + setContents(''); + }; + + const onButtonClick = () => { + sendMessage(1, contents); + setContents(''); + }; + + return ( +
+ + +
+ ); +} + +export default Message; From 6660e169a2021e68745d7e715205c728cd6f79db Mon Sep 17 00:00:00 2001 From: K Date: Wed, 24 Nov 2021 11:51:00 +0900 Subject: [PATCH 095/172] =?UTF-8?q?Feat=20:=20UserServer=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=ED=8A=B9=EC=A0=95=20=EC=B1=84=EB=84=90=EC=9D=B4=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=EC=97=90=20=EC=86=8D=ED=95=98=EB=8A=94=EC=A7=80=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Service와 Repository에서 이를 정의해서 사용합니다. --- backend/src/user-server/user-server.repository.ts | 11 +++++++++++ backend/src/user-server/user-server.service.ts | 7 +++++++ 2 files changed, 18 insertions(+) diff --git a/backend/src/user-server/user-server.repository.ts b/backend/src/user-server/user-server.repository.ts index 7317340..d7bff8b 100644 --- a/backend/src/user-server/user-server.repository.ts +++ b/backend/src/user-server/user-server.repository.ts @@ -1,4 +1,5 @@ import { EntityRepository, Repository } from 'typeorm'; +import { Channel } from '../channel/channel.entity'; import { UserServer } from './user-server.entity'; @EntityRepository(UserServer) @@ -23,4 +24,14 @@ export class UserServerRepository extends Repository { .andWhere('user_server.server = :serverId', { serverId: serverId }) .getOne(); } + + async userCanAccessChannel(userId: number, channelId: number) { + const userServer = await this.createQueryBuilder('userServer') + .innerJoin(Channel, 'channel', 'channel.serverId = userServer.serverId') + .where('channel.id = :channelId', { channelId }) + .andWhere('userServer.userId = :userId', { userId }) + .getOne(); + + return !!userServer; + } } diff --git a/backend/src/user-server/user-server.service.ts b/backend/src/user-server/user-server.service.ts index c3b5bd8..b22e725 100644 --- a/backend/src/user-server/user-server.service.ts +++ b/backend/src/user-server/user-server.service.ts @@ -56,4 +56,11 @@ export class UserServerService { getServerListByUserId(userId: number): Promise { return this.userServerRepository.getServerListByUserId(userId); } + + async userCanAccessChannel(userId: number, channelId: number) { + return await this.userServerRepository.userCanAccessChannel( + userId, + channelId, + ); + } } From 3406eed22071fdeaec50a5f07c8d3361a5550482 Mon Sep 17 00:00:00 2001 From: K Date: Wed, 24 Nov 2021 11:52:04 +0900 Subject: [PATCH 096/172] =?UTF-8?q?Feat=20:=20Message=EC=97=90=EC=84=9C=20?= =?UTF-8?q?UserServerService=EB=A5=BC=20=EC=9D=B4=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EC=B1=84=EB=84=90=EC=9D=B4=20=EC=86=8D=ED=95=B4?= =?UTF-8?q?=EC=9E=88=EB=8A=94=EC=A7=80=20=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Message에 있던 로직을 옮기고 UserServerService를 사용합니다. --- backend/src/message/message.module.ts | 6 +++++- backend/src/message/message.service.ts | 15 ++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/backend/src/message/message.module.ts b/backend/src/message/message.module.ts index 079b3da..0271e13 100644 --- a/backend/src/message/message.module.ts +++ b/backend/src/message/message.module.ts @@ -7,9 +7,13 @@ import { MessageService } from './message.service'; import { User } from '../user/user.entity'; import { Channel } from '../channel/channel.entity'; import { UserServer } from '../user-server/user-server.entity'; +import { UserServerModule } from '../user-server/user-server.module'; @Module({ - imports: [TypeOrmModule.forFeature([Message, User, Channel, UserServer])], + imports: [ + TypeOrmModule.forFeature([Message, User, Channel, UserServer]), + UserServerModule, + ], controllers: [MessageController], providers: [MessageService], }) diff --git a/backend/src/message/message.service.ts b/backend/src/message/message.service.ts index a21d3a4..35d4571 100644 --- a/backend/src/message/message.service.ts +++ b/backend/src/message/message.service.ts @@ -2,7 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Channel } from '../channel/channel.entity'; -import { UserServer } from '../user-server/user-server.entity'; +import { UserServerService } from '../user-server/user-server.service'; import { User } from '../user/user.entity'; import { MessageDto } from './message.dto'; import { Message } from './message.entity'; @@ -11,8 +11,7 @@ import { Message } from './message.entity'; export class MessageService { constructor( @InjectRepository(User) private userRepository: Repository, - @InjectRepository(UserServer) - private userServerRepository: Repository, + private readonly userServerService: UserServerService, @InjectRepository(Channel) private channelReposiotry: Repository, @InjectRepository(Message) private messageRepository: Repository, ) {} @@ -24,12 +23,10 @@ export class MessageService { ): Promise { let newMessage; - const userServer = await this.userServerRepository - .createQueryBuilder('userServer') - .innerJoin(Channel, 'channel', 'channel.serverId = userServer.serverId') - .where('channel.id = :channelId', { channelId }) - .andWhere('userServer.userId = :senderId', { senderId }) - .getOne(); + const userServer = await this.userServerService.userCanAccessChannel( + senderId, + channelId, + ); if (!userServer) { throw new BadRequestException('잘못된 요청'); From 1313371742719bc7723fd53fe1ce338c103e3604 Mon Sep 17 00:00:00 2001 From: K Date: Wed, 24 Nov 2021 11:54:48 +0900 Subject: [PATCH 097/172] =?UTF-8?q?Feat=20:=20url=20query=EC=97=90?= =?UTF-8?q?=EC=84=9C=20channelId=EB=A5=BC=20=EA=B0=80=EC=A0=B8=EC=99=80?= =?UTF-8?q?=EC=84=9C=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Main/Message/Message.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/Main/Message/Message.tsx b/frontend/src/components/Main/Message/Message.tsx index 1568ce3..59b5b43 100644 --- a/frontend/src/components/Main/Message/Message.tsx +++ b/frontend/src/components/Main/Message/Message.tsx @@ -22,6 +22,16 @@ const sendMessage = async (channelId: number, contents: string) => { function Message(): JSX.Element { const [contents, setContents] = useState(''); + const sendMessageToChannel = () => { + const channelIdParam = new URLSearchParams(window.location.search).get('channelId'); + if (!channelIdParam) { + return; + } + const channelId = parseInt(channelIdParam, 10); + sendMessage(channelId, contents); + setContents(''); + }; + const onInputChange = (e: React.ChangeEvent) => { setContents(e.target.value); }; @@ -30,13 +40,11 @@ function Message(): JSX.Element { if (e.key !== 'Enter') { return; } - sendMessage(1, contents); - setContents(''); + sendMessageToChannel(); }; const onButtonClick = () => { - sendMessage(1, contents); - setContents(''); + sendMessageToChannel(); }; return ( From 1b8384cf853e3a27c42a69cd1514ea44783cec84 Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Wed, 24 Nov 2021 14:36:27 +0900 Subject: [PATCH 098/172] =?UTF-8?q?Refactor=20:=20Server=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 참가한 user와 함께 server정보를 조회하는 기능을 ServerWithUsersDto를 거쳐서 조회하도록 변경하였습니다. --- .../server/dto/response-server-users.dto.ts | 41 +++++++++++++++++++ backend/src/server/server.controller.ts | 21 +--------- backend/src/server/server.service.ts | 11 +++-- .../src/user-server/user-server.service.ts | 9 +--- .../Main/ServerModal/ServerInfoModal.tsx | 18 +++----- 5 files changed, 58 insertions(+), 42 deletions(-) create mode 100644 backend/src/server/dto/response-server-users.dto.ts diff --git a/backend/src/server/dto/response-server-users.dto.ts b/backend/src/server/dto/response-server-users.dto.ts new file mode 100644 index 0000000..9e349c8 --- /dev/null +++ b/backend/src/server/dto/response-server-users.dto.ts @@ -0,0 +1,41 @@ +import { Server } from '../../server/server.entity'; + +type UserInfo = { + nickname: string; + profile: string; +}; + +class ServerWithUsersDto { + description: string; + name: string; + imgurl: string; + users: UserInfo[]; + + constructor( + description: string, + name: string, + imgUrl: string, + users: UserInfo[], + ) { + this.description = description; + this.name = name; + this.imgurl = imgUrl; + this.users = users; + } + + static fromEntity(server: Server) { + return new ServerWithUsersDto( + server.description, + server.name, + server.imgUrl, + server.userServer.map((userServer) => { + return { + nickname: userServer.user.nickname, + profile: userServer.user.profile, + }; + }), + ); + } +} + +export default ServerWithUsersDto; diff --git a/backend/src/server/server.controller.ts b/backend/src/server/server.controller.ts index 2690a8c..9f4bd05 100644 --- a/backend/src/server/server.controller.ts +++ b/backend/src/server/server.controller.ts @@ -23,6 +23,7 @@ import { ExpressSession } from '../types/session'; import ResponseEntity from '../common/response-entity'; import { FileInterceptor } from '@nestjs/platform-express'; import { ImageService } from '../image/image.service'; +import ServerWithUsersDto from './dto/response-server-users.dto'; @Controller('/api/servers') export class ServerController { @@ -31,27 +32,9 @@ export class ServerController { private imageService: ImageService, ) {} - @Get('list') async findAll(): Promise { - const serverList = await this.serverService.findAll(); - return Object.assign({ - data: serverList, - statusCode: 200, - statusMsg: `데이터 조회가 성공적으로 완료되었습니다.`, - }); - } - - @Get('/:id') async findOne(@Param('id') id: number): Promise { - const foundServer = await this.serverService.findOne(id); - return Object.assign({ - data: foundServer, - statusCode: 200, - statusMsg: `데이터 조회가 성공적으로 완료되었습니다.`, - }); - } - @Get('/:id/users') async findOneWithUsers( @Param('id') id: number, - ): Promise> { + ): Promise> { const serverWithUsers = await this.serverService.findOneWithUsers(id); return ResponseEntity.ok(serverWithUsers); } diff --git a/backend/src/server/server.service.ts b/backend/src/server/server.service.ts index 8a9016a..58742f2 100644 --- a/backend/src/server/server.service.ts +++ b/backend/src/server/server.service.ts @@ -12,7 +12,7 @@ import { Server } from './server.entity'; import RequestServerDto from './dto/request-server.dto'; import { UserServerService } from '../user-server/user-server.service'; import { ServerRepository } from './server.repository'; -import { request } from 'http'; +import ServerWithUsersDto from './dto/response-server-users.dto'; @Injectable() export class ServerService { @@ -22,7 +22,6 @@ export class ServerService { @InjectRepository(ServerRepository) private serverRepository: ServerRepository, ) {} - findAll(): Promise { return this.serverRepository.find({ relations: ['owner'] }); } @@ -63,7 +62,11 @@ export class ServerService { return this.serverRepository.delete({ id: id }); } - findOneWithUsers(serverId: number): Promise { - return this.serverRepository.findOneWithUsers(serverId); + async findOneWithUsers(serverId: number): Promise { + const serverWithUsers = await this.serverRepository.findOneWithUsers( + serverId, + ); + + return ServerWithUsersDto.fromEntity(serverWithUsers); } } diff --git a/backend/src/user-server/user-server.service.ts b/backend/src/user-server/user-server.service.ts index 6291882..2e5d93e 100644 --- a/backend/src/user-server/user-server.service.ts +++ b/backend/src/user-server/user-server.service.ts @@ -64,14 +64,9 @@ export class UserServerService { } async getServerListByUserId(userId: number): Promise { - const userServerDtoList = []; - const userServerList = await this.userServerRepository.getServerListByUserId(userId); - userServerList.map((userServer) => { - userServerDtoList.push(UserServerDto.fromEntity(userServer)); - }); - - return userServerDtoList; + userServerList.map((userServer) => UserServerDto.fromEntity(userServer)); + return userServerList; } } diff --git a/frontend/src/components/Main/ServerModal/ServerInfoModal.tsx b/frontend/src/components/Main/ServerModal/ServerInfoModal.tsx index f9cb9cd..5bd55be 100644 --- a/frontend/src/components/Main/ServerModal/ServerInfoModal.tsx +++ b/frontend/src/components/Main/ServerModal/ServerInfoModal.tsx @@ -170,19 +170,14 @@ const InfoSpan = styled.span` display: none; } `; -type User = { - id: number; - githubId: number; +type UserInfo = { nickname: string; profile: string; }; -type UserServer = { - id: number; - user: User; -}; + function ServerInfoModal(): JSX.Element { const { setIsServerInfoModalOpen, selectedServer } = useContext(MainStoreContext); - const [joinedUserList, setJoinedUserList] = useState(); + const [joinedUserList, setJoinedUserList] = useState(); const [serverDescription, setServerDescription] = useState(); const [serverName, setServerName] = useState(''); const [serverIconUrl, setServerIconUrl] = useState(); @@ -193,9 +188,9 @@ function ServerInfoModal(): JSX.Element { const serverInfo = await response.json(); if (response.status === 200) { - const { name, description, userServer, imgUrl } = serverInfo.data; + const { name, description, users, imgUrl } = serverInfo.data; - setJoinedUserList(userServer); + setJoinedUserList(users); setServerDescription(description); setServerName(name); if (imgUrl) { @@ -234,8 +229,7 @@ function ServerInfoModal(): JSX.Element { {joinedUserList ?.map((joinedUser) => { - const { user } = joinedUser; - const { nickname } = user; + const { nickname } = joinedUser; return nickname; }) .join('\n')} From a02a21debfe0870a3b7b308129deef684adf7ad8 Mon Sep 17 00:00:00 2001 From: korung3195 Date: Wed, 24 Nov 2021 14:36:30 +0900 Subject: [PATCH 099/172] =?UTF-8?q?Feat=20:=20=EC=84=9C=EB=B2=84=EC=97=90?= =?UTF-8?q?=20=EC=86=8D=ED=95=9C=20cam=20=EB=A6=AC=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EB=B0=9B=EC=95=84=EC=98=A4=EB=8A=94=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server controller에서 /api/servers/:id/cams 로 get 요청을 보낼 경우 해당 서버에 포함된 cam 목록을 응답하는 로직을 구현했습니다. --- backend/src/cams/cams.dto.ts | 13 +++++++++++++ backend/src/cams/cams.module.ts | 1 + backend/src/cams/cams.repository.ts | 9 ++++++++- backend/src/cams/cams.service.ts | 17 ++++++++++------- backend/src/server/server.controller.ts | 10 ++++++++++ backend/src/server/server.module.ts | 6 ++++-- frontend/src/components/Main/MainStore.tsx | 21 ++++++++++++++++----- frontend/src/types/main.ts | 7 ++++++- 8 files changed, 68 insertions(+), 16 deletions(-) diff --git a/backend/src/cams/cams.dto.ts b/backend/src/cams/cams.dto.ts index c3075ec..94477e3 100644 --- a/backend/src/cams/cams.dto.ts +++ b/backend/src/cams/cams.dto.ts @@ -2,3 +2,16 @@ export type CreateCamsDto = { name: string; serverId: number; }; + +export class RequestCamsDto { + name: string; + url: string; + constructor(name: string, url: string) { + this.name = name; + this.url = url; + } + + static fromEntry(name: string, url: string) { + return new RequestCamsDto(name, url); + } +} diff --git a/backend/src/cams/cams.module.ts b/backend/src/cams/cams.module.ts index ffb49d9..54b7caf 100644 --- a/backend/src/cams/cams.module.ts +++ b/backend/src/cams/cams.module.ts @@ -15,5 +15,6 @@ import { CamsService } from './cams.service'; ], providers: [CamsService], controllers: [CamsController], + exports: [CamsService], }) export class CamsModule {} diff --git a/backend/src/cams/cams.repository.ts b/backend/src/cams/cams.repository.ts index 9dba229..0586c46 100644 --- a/backend/src/cams/cams.repository.ts +++ b/backend/src/cams/cams.repository.ts @@ -2,4 +2,11 @@ import { EntityRepository, Repository } from 'typeorm'; import { Cams } from './cams.entity'; @EntityRepository(Cams) -export class CamsRepository extends Repository {} +export class CamsRepository extends Repository { + findWithServerId(serverId: number) { + return this.find({ + where: { server: { id: serverId } }, + relations: ['server'], + }); + } +} diff --git a/backend/src/cams/cams.service.ts b/backend/src/cams/cams.service.ts index e8cdc0a..4985cc8 100644 --- a/backend/src/cams/cams.service.ts +++ b/backend/src/cams/cams.service.ts @@ -1,8 +1,6 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Server } from '../server/server.entity'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { ServerRepository } from '../server/server.repository'; -import { CreateCamsDto } from './cams.dto'; +import { CreateCamsDto, RequestCamsDto } from './cams.dto'; import { Cams } from './cams.entity'; import { CamsRepository } from './cams.repository'; import { v4 } from 'uuid'; @@ -11,9 +9,9 @@ import { CamService } from '../cam/cam.service'; @Injectable() export class CamsService { constructor( - @InjectRepository(Cams) private camsRepository: CamsRepository, - @InjectRepository(Server) private serverRepository: ServerRepository, - @Inject(CamService) private readonly camService: CamService, + private camsRepository: CamsRepository, + private serverRepository: ServerRepository, + private readonly camService: CamService, ) {} findOne(id: number): Promise { @@ -44,4 +42,9 @@ export class CamsService { async deleteCams(id: number): Promise { await this.camsRepository.delete({ id: id }); } + + async getCams(serverId: number): Promise { + const res = await this.camsRepository.findWithServerId(serverId); + return res.map((entry) => RequestCamsDto.fromEntry(entry.name, entry.url)); + } } diff --git a/backend/src/server/server.controller.ts b/backend/src/server/server.controller.ts index c7baa57..e7d78a1 100644 --- a/backend/src/server/server.controller.ts +++ b/backend/src/server/server.controller.ts @@ -23,12 +23,15 @@ import { ExpressSession } from '../types/session'; import ResponseEntity from '../common/response-entity'; import { FileInterceptor } from '@nestjs/platform-express'; import { ImageService } from '../image/image.service'; +import { CamsService } from '../cams/cams.service'; +import { RequestCamsDto } from '../cams/cams.dto'; @Controller('/api/servers') export class ServerController { constructor( private serverService: ServerService, private imageService: ImageService, + private camsService: CamsService, ) {} @Get('list') async findAll(): Promise { @@ -56,6 +59,13 @@ export class ServerController { return ResponseEntity.ok(serverWithUsers); } + @Get('/:id/cams') async findCams( + @Param('id') id: number, + ): Promise> { + const cams = await this.camsService.getCams(id); + return ResponseEntity.ok(cams); + } + @Post() @UseGuards(LoginGuard) @UseInterceptors(FileInterceptor('icon')) diff --git a/backend/src/server/server.module.ts b/backend/src/server/server.module.ts index dca02d3..cb613c8 100644 --- a/backend/src/server/server.module.ts +++ b/backend/src/server/server.module.ts @@ -2,18 +2,20 @@ import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from '../user/user.entity'; -import { Server } from './server.entity'; import { ServerService } from './server.service'; import { ServerController } from './server.controller'; import { UserServerModule } from '../user-server/user-server.module'; import { ImageModule } from '../image/image.module'; import { ServerRepository } from './server.repository'; +import { CamsModule } from '../cams/cams.module'; +import { CamsRepository } from '../cams/cams.repository'; @Module({ imports: [ ImageModule, forwardRef(() => UserServerModule), - TypeOrmModule.forFeature([User, Server, ServerRepository]), + TypeOrmModule.forFeature([User, ServerRepository, CamsRepository]), + CamsModule, ], providers: [ServerService], controllers: [ServerController], diff --git a/frontend/src/components/Main/MainStore.tsx b/frontend/src/components/Main/MainStore.tsx index 802e51d..2b793b7 100644 --- a/frontend/src/components/Main/MainStore.tsx +++ b/frontend/src/components/Main/MainStore.tsx @@ -1,5 +1,5 @@ import { createContext, useEffect, useState } from 'react'; -import { ChannelData, MyServerData } from '../../types/main'; +import { CamData, ChannelData, MyServerData } from '../../types/main'; export const MainStoreContext = createContext(null); @@ -29,6 +29,7 @@ function MainStore(props: MainStoreProps): JSX.Element { const [serverList, setServerList] = useState([]); const [isCreateCamModalOpen, setIsCreateCamModalOpen] = useState(false); + const [serverCamList, setServerCamList] = useState([]); const getServerChannelList = async (): Promise => { const response = await fetch(`/api/user/servers/${selectedServer?.server.id}/channels/joined/`); @@ -51,12 +52,21 @@ function MainStore(props: MainStoreProps): JSX.Element { } }; - // const getServerCamList = async (): Promise => { - // const response = await fetch(``); API를 만든 다음에 연결하기 - // }; + const getServerCamList = async (): Promise => { + const response = await fetch(`/api/servers/${selectedServer?.server.id}/cams`); + const list = await response.json(); + const camList = list.data; + + if (response.status === 200) { + setServerCamList(camList); + } + }; useEffect(() => { - if (selectedServer) getServerChannelList(); + if (selectedServer) { + getServerChannelList(); + getServerCamList(); + } }, [selectedServer]); return ( @@ -78,6 +88,7 @@ function MainStore(props: MainStoreProps): JSX.Element { isQuitServerModalOpen, isCreateCamModalOpen, serverList, + serverCamList, setSelectedServer, setSelectedChannel, setRightClickedChannelId, diff --git a/frontend/src/types/main.ts b/frontend/src/types/main.ts index 325d831..26917b7 100644 --- a/frontend/src/types/main.ts +++ b/frontend/src/types/main.ts @@ -23,4 +23,9 @@ type ChannelData = { name: string; }; -export type { UserData, ServerData, MyServerData, ChannelData }; +type CamData = { + name: string; + url: string; +}; + +export type { UserData, ServerData, MyServerData, ChannelData, CamData }; From e77f4fb89b7fed1fe5e095505e7dde797f569877 Mon Sep 17 00:00:00 2001 From: K Date: Wed, 24 Nov 2021 14:45:02 +0900 Subject: [PATCH 100/172] =?UTF-8?q?Feat=20:=20MessageRepository=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit findByChannelId 를 추가하여 User 정보까지 함께 Join하여 가져오도록 합니다. --- backend/src/message/message.module.ts | 9 ++++++++- backend/src/message/message.repository.ts | 12 ++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 backend/src/message/message.repository.ts diff --git a/backend/src/message/message.module.ts b/backend/src/message/message.module.ts index 0271e13..9d1027a 100644 --- a/backend/src/message/message.module.ts +++ b/backend/src/message/message.module.ts @@ -8,10 +8,17 @@ import { User } from '../user/user.entity'; import { Channel } from '../channel/channel.entity'; import { UserServer } from '../user-server/user-server.entity'; import { UserServerModule } from '../user-server/user-server.module'; +import { MessageRepository } from './message.repository'; @Module({ imports: [ - TypeOrmModule.forFeature([Message, User, Channel, UserServer]), + TypeOrmModule.forFeature([ + Message, + User, + Channel, + UserServer, + MessageRepository, + ]), UserServerModule, ], controllers: [MessageController], diff --git a/backend/src/message/message.repository.ts b/backend/src/message/message.repository.ts new file mode 100644 index 0000000..492fd8c --- /dev/null +++ b/backend/src/message/message.repository.ts @@ -0,0 +1,12 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { Message } from './message.entity'; + +@EntityRepository(Message) +export class MessageRepository extends Repository { + async findByChannelId(channelId: number): Promise { + return this.createQueryBuilder('message') + .innerJoinAndSelect('message.sender', 'user') + .where('message.channelId = :channelId', { channelId }) + .getMany(); + } +} From 0c5711c67ac5a901d65c084c9675763f7e78e7fa Mon Sep 17 00:00:00 2001 From: K Date: Wed, 24 Nov 2021 14:46:45 +0900 Subject: [PATCH 101/172] =?UTF-8?q?Refactor=20:=20=EC=B1=84=EB=84=90=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=20=EA=B6=8C=ED=95=9C=20=ED=99=95=EC=9D=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=9D=84=20=ED=95=A8=EC=88=98=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이후 추가로 사용할 가능성이 높아 함수로 변경하였습니다. 그리고 Exception이 권한 없음이기 때문에 403 이거나 404이 더 적합하다고 판단하여 Exception 종류도 수정하였습니다. --- backend/src/message/message.service.ts | 28 +++++++++++++++----------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/backend/src/message/message.service.ts b/backend/src/message/message.service.ts index 35d4571..ae5e495 100644 --- a/backend/src/message/message.service.ts +++ b/backend/src/message/message.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { ForbiddenException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Channel } from '../channel/channel.entity'; @@ -6,14 +6,15 @@ import { UserServerService } from '../user-server/user-server.service'; import { User } from '../user/user.entity'; import { MessageDto } from './message.dto'; import { Message } from './message.entity'; +import { MessageRepository } from './message.repository'; @Injectable() export class MessageService { constructor( @InjectRepository(User) private userRepository: Repository, - private readonly userServerService: UserServerService, @InjectRepository(Channel) private channelReposiotry: Repository, - @InjectRepository(Message) private messageRepository: Repository, + private readonly userServerService: UserServerService, + private messageRepository: MessageRepository, ) {} async sendMessage( @@ -21,22 +22,25 @@ export class MessageService { channelId: number, contents: string, ): Promise { - let newMessage; + await this.checkUserChannelAccess(senderId, channelId); + + const sender = await this.userRepository.findOne(senderId); + const channel = await this.channelReposiotry.findOne(channelId); + + const newMessage = await this.messageRepository.save( + Message.newInstace(contents, channel, sender), + ); + return MessageDto.fromEntity(newMessage); + } + private async checkUserChannelAccess(senderId: number, channelId: number) { const userServer = await this.userServerService.userCanAccessChannel( senderId, channelId, ); if (!userServer) { - throw new BadRequestException('잘못된 요청'); + throw new ForbiddenException('서버나 채널에 참여하지 않았습니다.'); } - - const sender = await this.userRepository.findOne(senderId); - const channel = await this.channelReposiotry.findOne(channelId); - - newMessage = Message.newInstace(contents, channel, sender); - newMessage = await this.messageRepository.save(newMessage); - return MessageDto.fromEntity(newMessage); } } From 57e50102e493bfa52997c3da978aec9261c5d9f9 Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Wed, 24 Nov 2021 14:50:46 +0900 Subject: [PATCH 102/172] =?UTF-8?q?Test=20:=20ServerWthUsersDto=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=BD=94=EB=93=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit findOneWithUsers() 함수 테스트를 위해 mock데이터를 설정하였습니다. --- backend/src/server/server.service.spec.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/src/server/server.service.spec.ts b/backend/src/server/server.service.spec.ts index 4e3f93b..543c2b4 100644 --- a/backend/src/server/server.service.spec.ts +++ b/backend/src/server/server.service.spec.ts @@ -41,6 +41,7 @@ describe('ServerService', () => { let newServer: Server; let newUserServer: UserServer; let existsServer: Server; + let existsUserServer: UserServer; const existsServerId = 1; const userId = 1; @@ -99,7 +100,8 @@ describe('ServerService', () => { existsServerId, ); - expect(serverWithUseres).toBe(existsServer); + expect(serverWithUseres.name).toBe(existsServer.name); + expect(serverWithUseres.description).toBe(existsServer.description); }); }); @@ -155,8 +157,13 @@ describe('ServerService', () => { newServer.name = serverName; newServer.owner = user; + existsUserServer = new UserServer(); + existsUserServer.id = 1; + existsUserServer.user = user; + existsServer = new Server(); existsServer.id = existsServerId; existsServer.owner = user; + existsServer.userServer = [existsUserServer]; }; }); From cac92795f8c0b946e472f6af883ec67ef5fa3ad0 Mon Sep 17 00:00:00 2001 From: korung3195 Date: Wed, 24 Nov 2021 14:55:42 +0900 Subject: [PATCH 103/172] =?UTF-8?q?Refactor=20:=20cams=20entity=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존에 server를 leftJoin 했었는데, serverId만 필요하기 때문에 JoinColumn 대신 RelationId로 교체하였습니다. --- backend/src/cams/cams.entity.ts | 6 ++++-- backend/src/cams/cams.repository.ts | 11 ++++++----- backend/src/cams/cams.service.ts | 2 +- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/backend/src/cams/cams.entity.ts b/backend/src/cams/cams.entity.ts index bdc9fa7..27c5647 100644 --- a/backend/src/cams/cams.entity.ts +++ b/backend/src/cams/cams.entity.ts @@ -2,8 +2,8 @@ import { Entity, Column, PrimaryGeneratedColumn, - JoinColumn, ManyToOne, + RelationId, } from 'typeorm'; import { Server } from '../server/server.entity'; @@ -19,6 +19,8 @@ export class Cams { url: string; @ManyToOne(() => Server) - @JoinColumn({ referencedColumnName: 'id' }) server: Server; + + @RelationId((cams: Cams) => cams.server) + serverId: number; } diff --git a/backend/src/cams/cams.repository.ts b/backend/src/cams/cams.repository.ts index 0586c46..e894d3e 100644 --- a/backend/src/cams/cams.repository.ts +++ b/backend/src/cams/cams.repository.ts @@ -3,10 +3,11 @@ import { Cams } from './cams.entity'; @EntityRepository(Cams) export class CamsRepository extends Repository { - findWithServerId(serverId: number) { - return this.find({ - where: { server: { id: serverId } }, - relations: ['server'], - }); + findByServerId(serverId: number) { + return this.createQueryBuilder('cams') + .where('cams.serverId = :serverId', { + serverId, + }) + .getMany(); } } diff --git a/backend/src/cams/cams.service.ts b/backend/src/cams/cams.service.ts index 4985cc8..be799b5 100644 --- a/backend/src/cams/cams.service.ts +++ b/backend/src/cams/cams.service.ts @@ -44,7 +44,7 @@ export class CamsService { } async getCams(serverId: number): Promise { - const res = await this.camsRepository.findWithServerId(serverId); + const res = await this.camsRepository.findByServerId(serverId); return res.map((entry) => RequestCamsDto.fromEntry(entry.name, entry.url)); } } From 9c94669cc1e6e9925209765c0897a93da55efbe0 Mon Sep 17 00:00:00 2001 From: K Date: Wed, 24 Nov 2021 14:58:07 +0900 Subject: [PATCH 104/172] =?UTF-8?q?Feat=20:=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=ED=95=A8?= =?UTF-8?q?=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 채널을 이용하여 해당 채널에 속한 메시지 전체를 조회할 수 있습니다. --- backend/src/message/message.service.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/src/message/message.service.ts b/backend/src/message/message.service.ts index ae5e495..c7312f5 100644 --- a/backend/src/message/message.service.ts +++ b/backend/src/message/message.service.ts @@ -33,6 +33,12 @@ export class MessageService { return MessageDto.fromEntity(newMessage); } + async findMessagesByChannelId(senderId: number, channelId: number) { + await this.checkUserChannelAccess(senderId, channelId); + const messages = await this.messageRepository.findByChannelId(channelId); + return messages.map(MessageDto.fromEntity); + } + private async checkUserChannelAccess(senderId: number, channelId: number) { const userServer = await this.userServerService.userCanAccessChannel( senderId, From daddc562ed40e359bc4f0a17304db6324fcab61c Mon Sep 17 00:00:00 2001 From: K Date: Wed, 24 Nov 2021 14:59:45 +0900 Subject: [PATCH 105/172] =?UTF-8?q?Feat=20:=20Message=20Entity=EC=9D=98=20?= =?UTF-8?q?channelId=20=ED=95=84=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit channelId는 해당 Entity의 테이블에 이미 존재하는데, 기본으로 가져올 수 없 어 추가하였습니다. --- backend/src/message/message.dto.ts | 2 +- backend/src/message/message.entity.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/src/message/message.dto.ts b/backend/src/message/message.dto.ts index e81a9ab..29d4df3 100644 --- a/backend/src/message/message.dto.ts +++ b/backend/src/message/message.dto.ts @@ -28,7 +28,7 @@ export class MessageDto { return MessageDto.newInstance( message.id, message.contents, - message.channel.id, + message.channelId, message.createdAt, UserDto.fromEntity(message.sender), ); diff --git a/backend/src/message/message.entity.ts b/backend/src/message/message.entity.ts index 28f3f81..e73a7ec 100644 --- a/backend/src/message/message.entity.ts +++ b/backend/src/message/message.entity.ts @@ -6,6 +6,7 @@ import { Column, CreateDateColumn, ManyToOne, + RelationId, } from 'typeorm'; @Entity() @@ -19,6 +20,9 @@ export class Message { @ManyToOne(() => Channel) channel: Channel; + @RelationId((message: Message) => message.channel) + channelId: number; + @CreateDateColumn() createdAt: Date; From de449daaa74918d51d6d6ee8705879973beb4724 Mon Sep 17 00:00:00 2001 From: K Date: Wed, 24 Nov 2021 15:01:18 +0900 Subject: [PATCH 106/172] Feat : MessageController.findMessagesByChannelId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메시지를 ChannelId를 이용하여 찾는 GET /api/messages?channelId= 입니다. --- backend/src/message/message.controller.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/backend/src/message/message.controller.ts b/backend/src/message/message.controller.ts index d9c1eb9..3df8de8 100644 --- a/backend/src/message/message.controller.ts +++ b/backend/src/message/message.controller.ts @@ -1,4 +1,12 @@ -import { Body, Controller, Post, Session, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Post, + Query, + Session, + UseGuards, +} from '@nestjs/common'; import ResponseEntity from '../common/response-entity'; import { LoginGuard } from '../login/login.guard'; import { ExpressSession } from '../types/session'; @@ -25,4 +33,17 @@ export class MessageController { return ResponseEntity.ok(newMessage); } + + @Get() + async findMessagesByChannelId( + @Session() session: ExpressSession, + @Query('channelId') channelId: number, + ) { + const sender = session.user; + const channelMessages = await this.messageService.findMessagesByChannelId( + sender.id, + channelId, + ); + return ResponseEntity.ok(channelMessages); + } } From be7c494cc2a5dd439d8a6b4dea079b364308d2ce Mon Sep 17 00:00:00 2001 From: K Date: Wed, 24 Nov 2021 15:03:13 +0900 Subject: [PATCH 107/172] =?UTF-8?q?Feat=20:=20=EC=B1=84=EB=84=90=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EC=8B=9C=20=EA=B4=80=EB=A0=A8=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=EB=A5=BC=20=EA=B0=80=EC=A0=B8=EC=98=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 해당 api를 이용하여 가져온 결과를 console.log로 출력합니다. --- frontend/src/components/Main/MainStore.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/frontend/src/components/Main/MainStore.tsx b/frontend/src/components/Main/MainStore.tsx index 98bd301..4bee1d5 100644 --- a/frontend/src/components/Main/MainStore.tsx +++ b/frontend/src/components/Main/MainStore.tsx @@ -49,10 +49,24 @@ function MainStore(props: MainStoreProps): JSX.Element { } }; + const getMessageList = async (): Promise => { + const response = await fetch(`/api/messages?channelId=${selectedChannel}`); + const list = await response.json(); + const messageList = list.data; + // eslint-disable-next-line no-console + console.log(messageList); + }; + useEffect(() => { if (selectedServer) getServerChannelList(); }, [selectedServer]); + useEffect(() => { + if (selectedChannel) { + getMessageList(); + } + }, [selectedChannel]); + return ( Date: Wed, 24 Nov 2021 15:13:41 +0900 Subject: [PATCH 108/172] =?UTF-8?q?Feat=20:=20cam=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=9D=84=20=ED=91=9C=EC=8B=9C=ED=95=98=EB=8A=94=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit db에서 서버에 속한 cam 목록을 받아온 뒤 리스트에 출력하는 로직을 구현하였습니다. --- frontend/src/components/Main/Cam/CamList.tsx | 10 +++- .../src/components/Main/Cam/CamListItem.tsx | 59 +++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/Main/Cam/CamListItem.tsx diff --git a/frontend/src/components/Main/Cam/CamList.tsx b/frontend/src/components/Main/Cam/CamList.tsx index 749f8a2..92e0624 100644 --- a/frontend/src/components/Main/Cam/CamList.tsx +++ b/frontend/src/components/Main/Cam/CamList.tsx @@ -1,7 +1,9 @@ -import React, { useState } from 'react'; +import React, { useContext, useState } from 'react'; import styled from 'styled-components'; +import { MainStoreContext } from '../MainStore'; import CamListHeader from './CamListHeader'; +import CamListItem from './CamListItem'; const Container = styled.div` width: 100%; @@ -29,7 +31,11 @@ const CamListBody = styled.div` function CamList(): JSX.Element { const [isListOpen, setIsListOpen] = useState(false); - const listElements: Array = []; + const { serverCamList } = useContext(MainStoreContext); + + const listElements = serverCamList.map((cam: { name: string; url: string }) => ( + + )); return ( diff --git a/frontend/src/components/Main/Cam/CamListItem.tsx b/frontend/src/components/Main/Cam/CamListItem.tsx new file mode 100644 index 0000000..490f70e --- /dev/null +++ b/frontend/src/components/Main/Cam/CamListItem.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import styled from 'styled-components'; + +import { BoostCamMainIcons } from '../../../utils/SvgIcons'; + +const { Hash } = BoostCamMainIcons; + +type CamListItemProps = { + name: string; + url: string; +}; + +const Container = styled.div` + width: 100%; + height: 25px; + + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + + box-sizing: border-box; + padding: 15px 0px 15px 25px; + + &:hover { + cursor: pointer; + } +`; + +const HashIcon = styled(Hash)` + width: 15px; + min-width: 15px; + height: 15px; + min-height: 15px; + fill: #a69c96; +`; + +const CamNameSpan = styled.a` + padding: 5px 0px 5px 5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: inherit; + text-decoration: none; +`; + +function CamListItem(props: CamListItemProps): JSX.Element { + const { name, url } = props; + const camURL = `/cam?roomid=${url}`; + + return ( + + + {name} + + ); +} + +export default CamListItem; From 810b247664b3b63447c40f5bf8a699f6c77ad39a Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Wed, 24 Nov 2021 16:42:02 +0900 Subject: [PATCH 109/172] =?UTF-8?q?Feat=20:=20Server=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server update api를 연결하였습니다. --- backend/src/server/dto/request-server.dto.ts | 2 +- .../server/dto/response-server-users.dto.ts | 4 +- backend/src/server/server.controller.ts | 42 ++++++++++---- backend/src/server/server.service.ts | 21 ++++++- frontend/src/components/Main/MainStore.tsx | 11 +++- .../Main/ServerModal/CreateServerModal.tsx | 3 +- .../Main/ServerModal/JoinServerModal.tsx | 3 +- .../Main/ServerModal/ServerSettingModal.tsx | 56 +++++++++++++++---- 8 files changed, 108 insertions(+), 34 deletions(-) diff --git a/backend/src/server/dto/request-server.dto.ts b/backend/src/server/dto/request-server.dto.ts index 8a05587..37256c8 100644 --- a/backend/src/server/dto/request-server.dto.ts +++ b/backend/src/server/dto/request-server.dto.ts @@ -10,7 +10,7 @@ class RequestServerDto { } toServerEntity = () => { - return Server.newInstance(this.name, this.description); + return Server.newInstance(this.description, this.name); }; } diff --git a/backend/src/server/dto/response-server-users.dto.ts b/backend/src/server/dto/response-server-users.dto.ts index 9e349c8..ce32672 100644 --- a/backend/src/server/dto/response-server-users.dto.ts +++ b/backend/src/server/dto/response-server-users.dto.ts @@ -8,7 +8,7 @@ type UserInfo = { class ServerWithUsersDto { description: string; name: string; - imgurl: string; + imgUrl: string; users: UserInfo[]; constructor( @@ -19,7 +19,7 @@ class ServerWithUsersDto { ) { this.description = description; this.name = name; - this.imgurl = imgUrl; + this.imgUrl = imgUrl; this.users = users; } diff --git a/backend/src/server/server.controller.ts b/backend/src/server/server.controller.ts index 9f4bd05..6b6810e 100644 --- a/backend/src/server/server.controller.ts +++ b/backend/src/server/server.controller.ts @@ -55,7 +55,7 @@ export class ServerController { ); let imgUrl: string; - if (icon !== undefined && icon.mimetype.substring(0, 5) === 'image') { + if (icon && icon.mimetype.substring(0, 5) === 'image') { const uploadedFile = await this.imageService.uploadFile(icon); imgUrl = uploadedFile.Location; } @@ -74,16 +74,38 @@ export class ServerController { } } - @Patch('/:id') async updateServer( + @Patch('/:id') + @HttpCode(HttpStatus.NO_CONTENT) + @UseInterceptors(FileInterceptor('icon')) + async updateServer( + @Session() + session: ExpressSession, @Param('id') id: number, - @Body() server: Server, - ): Promise { - await this.serverService.updateServer(id, server); - return Object.assign({ - data: { ...server }, - statusCode: 200, - statusMsg: `updated successfully`, - }); + @Body() requestServerDto: RequestServerDto, + @UploadedFile() icon: Express.Multer.File, + ): Promise> { + try { + requestServerDto = new RequestServerDto( + requestServerDto.name, + requestServerDto.description, + ); + let imgUrl: string; + + if (icon && icon.mimetype.substring(0, 5) === 'image') { + const uploadedFile = await this.imageService.uploadFile(icon); + imgUrl = uploadedFile.Location; + } + const user = session.user; + + await this.serverService.updateServer(id, requestServerDto, user, imgUrl); + + return ResponseEntity.noContent(); + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + throw new HttpException(error.message, HttpStatus.BAD_REQUEST); + } } @Delete('/:id') diff --git a/backend/src/server/server.service.ts b/backend/src/server/server.service.ts index 58742f2..776a740 100644 --- a/backend/src/server/server.service.ts +++ b/backend/src/server/server.service.ts @@ -45,8 +45,25 @@ export class ServerService { return createdServer; } - async updateServer(id: number, server: Server): Promise { - await this.serverRepository.update(id, server); + async updateServer( + id: number, + requestServer: RequestServerDto, + user: User, + imgUrl: string | undefined, + ): Promise { + const server = await this.serverRepository.findOneWithOwner(id); + + if (server.owner.id !== user.id) { + throw new ForbiddenException('변경 권한이 없습니다.'); + } + + const newServer = requestServer.toServerEntity(); + + newServer.imgUrl = imgUrl || server.imgUrl; + newServer.name = newServer.name || server.name; + newServer.description = newServer.description || server.description; + + this.serverRepository.update(id, newServer); } async deleteServer(id: number, user: User) { diff --git a/frontend/src/components/Main/MainStore.tsx b/frontend/src/components/Main/MainStore.tsx index 98bd301..d0900a6 100644 --- a/frontend/src/components/Main/MainStore.tsx +++ b/frontend/src/components/Main/MainStore.tsx @@ -38,14 +38,19 @@ function MainStore(props: MainStoreProps): JSX.Element { } }; - const getUserServerList = async (isServerOrUserServerCreated: boolean): Promise => { + const getUserServerList = async (calledStatus: string | undefined): Promise => { const response = await fetch(`/api/user/servers`); const list = await response.json(); if (response.status === 200 && list.data.length !== 0) { - const selectedServerIndex = isServerOrUserServerCreated ? list.data.length - 1 : 0; setServerList(list.data); - setSelectedServer(list.data[selectedServerIndex]); + if (calledStatus === 'updated') { + const updatedServerId = selectedServer?.server.id; + setSelectedServer(list.data.filter((userServer: MyServerData) => userServer.server.id === updatedServerId)[0]); + } else { + const selectedServerIndex = calledStatus === 'created' ? list.data.length - 1 : 0; + setSelectedServer(list.data[selectedServerIndex]); + } } }; diff --git a/frontend/src/components/Main/ServerModal/CreateServerModal.tsx b/frontend/src/components/Main/ServerModal/CreateServerModal.tsx index e47ddc2..2967a25 100644 --- a/frontend/src/components/Main/ServerModal/CreateServerModal.tsx +++ b/frontend/src/components/Main/ServerModal/CreateServerModal.tsx @@ -211,8 +211,7 @@ function CreateServerModal(): JSX.Element { }); if (response.status === 201) { - const isServerOrUserServerCreated = true; - getUserServerList(isServerOrUserServerCreated); + getUserServerList('created'); setIsCreateServerModalOpen(false); } else { const body = await response.json(); diff --git a/frontend/src/components/Main/ServerModal/JoinServerModal.tsx b/frontend/src/components/Main/ServerModal/JoinServerModal.tsx index 09378d9..5e66061 100644 --- a/frontend/src/components/Main/ServerModal/JoinServerModal.tsx +++ b/frontend/src/components/Main/ServerModal/JoinServerModal.tsx @@ -196,8 +196,7 @@ function JoinServerModal(): JSX.Element { }); if (response.status === 201) { - const isServerOrUserServerCreated = true; - getUserServerList(isServerOrUserServerCreated); + getUserServerList('created'); setIsJoinServerModalOpen(false); } else { const body = await response.json(); diff --git a/frontend/src/components/Main/ServerModal/ServerSettingModal.tsx b/frontend/src/components/Main/ServerModal/ServerSettingModal.tsx index 22d40fe..ecb3e7d 100644 --- a/frontend/src/components/Main/ServerModal/ServerSettingModal.tsx +++ b/frontend/src/components/Main/ServerModal/ServerSettingModal.tsx @@ -169,25 +169,53 @@ function ServerSettingModal(): JSX.Element { const [imagePreview, setImagePreview] = useState(); const [messageFailToPost, setMessageFailToPost] = useState(''); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [files, setFiles] = useState(); + + const serverId = selectedServer?.server.id; + const onChangePreviewImage = (e: React.ChangeEvent & { target: HTMLInputElement }) => { - const file = e.target.files; + const iconFile = e.target.files; - if (file) { - setImagePreview(URL.createObjectURL(file[0])); + if (iconFile) { + setFiles(iconFile); + setImagePreview(URL.createObjectURL(iconFile[0])); } }; - const onClickDeleteServer = async () => { - const serverId = selectedServer?.server.id; + const onCliclUpdateServer = async () => { + if (serverId) { + const formData = new FormData(); + + formData.append('name', name); + formData.append('description', description); + if (files) formData.append('icon', files[0]); + + const response = await fetch(`api/servers/${serverId}`, { + method: 'PATCH', + body: formData, + }); + if (response.status === 204) { + getUserServerList('updated'); + setIsServerSettingModalOpen(false); + } else { + const body = await response.json(); + setMessageFailToPost(body.message); + } + } else { + setMessageFailToPost('선택된 서버가 없습니다.'); + } + }; + const onClickDeleteServer = async () => { if (serverId) { const response = await fetch(`api/servers/${serverId}`, { method: 'DELETE', }); if (response.status === 204) { - const isServerOrUserServerCreated = false; - getUserServerList(isServerOrUserServerCreated); + getUserServerList(); setIsServerSettingModalOpen(false); } else { const body = await response.json(); @@ -213,15 +241,19 @@ function ServerSettingModal(): JSX.Element {
서버 이름 변경 - - + setName(e.target.value)} /> + 제출 서버 설명 변경 - - + setDescription(e.target.value)} + /> + 제출 @@ -231,7 +263,7 @@ function ServerSettingModal(): JSX.Element { - + 제출 From c40a925ccb7be91d2397a5d426b0e6cf405a1350 Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Wed, 24 Nov 2021 16:45:04 +0900 Subject: [PATCH 110/172] =?UTF-8?q?Refactor=20:=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=20=ED=8C=8C=EC=9D=BC=20=EB=B0=8F=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/server/server.controller.ts | 1 - frontend/src/App.tsx | 2 - .../src/components/Server/NavigationBar.tsx | 22 -------- frontend/src/components/Server/ServerIcon.tsx | 29 ---------- .../src/components/Server/ServerStore.tsx | 33 ------------ frontend/src/components/Server/ServerTab.tsx | 54 ------------------- frontend/src/components/Server/TmpFrame.tsx | 30 ----------- 7 files changed, 171 deletions(-) delete mode 100644 frontend/src/components/Server/NavigationBar.tsx delete mode 100644 frontend/src/components/Server/ServerIcon.tsx delete mode 100644 frontend/src/components/Server/ServerStore.tsx delete mode 100644 frontend/src/components/Server/ServerTab.tsx delete mode 100644 frontend/src/components/Server/TmpFrame.tsx diff --git a/backend/src/server/server.controller.ts b/backend/src/server/server.controller.ts index 6b6810e..8c33bcb 100644 --- a/backend/src/server/server.controller.ts +++ b/backend/src/server/server.controller.ts @@ -16,7 +16,6 @@ import { } from '@nestjs/common'; import { ServerService } from './server.service'; -import { Server } from './server.entity'; import { LoginGuard } from '../login/login.guard'; import RequestServerDto from './dto/request-server.dto'; import { ExpressSession } from '../types/session'; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f4cfdb0..f2be6e3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,7 +5,6 @@ import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; import LoginMain from './components/LoginPage/LoginMain'; import Cam from './components/Cam/Cam'; import CamRooms from './components/Main/CamRooms'; -import TmpFrame from './components/Server/TmpFrame'; import BoostCamMain from './components/Main/BoostCamMain'; import LoginCallback from './components/LoginPage/LoginCallback'; @@ -18,7 +17,6 @@ function App(): JSX.Element { } /> } /> } /> - } /> } /> diff --git a/frontend/src/components/Server/NavigationBar.tsx b/frontend/src/components/Server/NavigationBar.tsx deleted file mode 100644 index 1d3454e..0000000 --- a/frontend/src/components/Server/NavigationBar.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React, { useContext } from 'react'; -import styled from 'styled-components'; -import { ServerStoreContext } from './ServerStore'; - -const Container = styled.div` - display: flex; - flex-direction: column; - justify-content: start; - align-items: start; - margin-left: 40px; - margin-top: 15px; - width: 100%; - background-color: yellow; - height: 5%; -`; - -function NavigationBar(): JSX.Element { - const { currentServer } = useContext(ServerStoreContext); - return {currentServer.name}; -} - -export default NavigationBar; diff --git a/frontend/src/components/Server/ServerIcon.tsx b/frontend/src/components/Server/ServerIcon.tsx deleted file mode 100644 index bf1fb7d..0000000 --- a/frontend/src/components/Server/ServerIcon.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React, { useContext } from 'react'; -import styled from 'styled-components'; -import { Server } from '../../types/server'; -import { ServerStoreContext } from './ServerStore'; - -const Container = styled.button` - flex-direction: column; - justify-content: start; - margin-left: 40px; - margin-top: 15px; - width: 90%; -`; - -type ServerIconProps = { - server: Server; -}; - -function ServerIcon(props: ServerIconProps): JSX.Element { - const { server } = props; - const { setCurrentServer } = useContext(ServerStoreContext); - - const onClickChangeCurrentServer = () => { - setCurrentServer(server); - }; - - return {server.name}; -} - -export default ServerIcon; diff --git a/frontend/src/components/Server/ServerStore.tsx b/frontend/src/components/Server/ServerStore.tsx deleted file mode 100644 index 485ccd0..0000000 --- a/frontend/src/components/Server/ServerStore.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React, { createContext, useState, useEffect } from 'react'; -import { Server, ServerInfo } from '../../types/server'; - -type ServerStoreProps = { - children: React.ReactChild[] | React.ReactChild; -}; - -export const ServerStoreContext = createContext(null); - -function ServerStore(props: ServerStoreProps): JSX.Element { - const { children } = props; - const [serverList, setServerList] = useState([]); - const [currentServer, setCurrentServer] = useState(''); - - useEffect(() => { - const setServerListByUserId = async (userId: number) => { - const response = await window.fetch(`/api/user-servers/users/${userId}`); - const data = await response.json(); - setServerList(data); - }; - - const userId = 1; - setServerListByUserId(userId); - }, []); - - return ( - - {children} - - ); -} - -export default ServerStore; diff --git a/frontend/src/components/Server/ServerTab.tsx b/frontend/src/components/Server/ServerTab.tsx deleted file mode 100644 index 53aa71c..0000000 --- a/frontend/src/components/Server/ServerTab.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React, { useContext, useState, useEffect } from 'react'; -import styled from 'styled-components'; - -import { ServerInfo } from '../../types/server'; -import ServerIcon from './ServerIcon'; -import { ServerStoreContext } from './ServerStore'; - -const Container = styled.div` - display: flex; - flex-direction: column; - justify-content: start; - margin-left: 40px; - margin-top: 15px; - width: 10%; - background-color: blue; - height: 50%; -`; - -const ModalDisplayButton = styled.button` - flex-direction: column; - justify-content: start; - margin-left: 40px; - margin-top: 15px; - width: 90%; -`; - -function ServerTab(): JSX.Element { - const { serverList } = useContext(ServerStoreContext); - const [isDropdownActivated, setDropdownActivated] = useState(false); - - const onClickToggleNewServerModal = () => { - setDropdownActivated(!isDropdownActivated); - }; - - useEffect(() => { - if (isDropdownActivated) { - window.addEventListener('click', onClickToggleNewServerModal); - } - return () => { - window.removeEventListener('click', onClickToggleNewServerModal); - }; - }, [isDropdownActivated]); - - return ( - - {serverList.map((server: ServerInfo) => { - return ; - })} - + - - ); -} - -export default ServerTab; diff --git a/frontend/src/components/Server/TmpFrame.tsx b/frontend/src/components/Server/TmpFrame.tsx deleted file mode 100644 index 09353b6..0000000 --- a/frontend/src/components/Server/TmpFrame.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react'; -import styled from 'styled-components'; - -import ServerTab from './ServerTab'; -import NavigationBar from './NavigationBar'; -import ServerStore from './ServerStore'; - -const Container = styled.div` - width: 100vw; - height: 100vh; - display: flex; - flex-direction: column; - justify-content: flex-start; - align-items: start; - overflow-x: hidden; - overflow-y: hidden; -`; - -function TmpFrame(): JSX.Element { - return ( - - - - - - - ); -} - -export default TmpFrame; From f08c6a21c3313ed1919b717580c37b35b5124eb7 Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Wed, 24 Nov 2021 16:51:52 +0900 Subject: [PATCH 111/172] =?UTF-8?q?Feat=20:=20ChattingSection=20front=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Main/ContentsSection/ChattingSection.tsx | 114 ++++++++++++++---- .../Main/ContentsSection/ContentsSection.tsx | 2 +- frontend/src/components/Main/MainSection.tsx | 5 +- 3 files changed, 95 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/Main/ContentsSection/ChattingSection.tsx b/frontend/src/components/Main/ContentsSection/ChattingSection.tsx index 2f99ddc..7bb6b39 100644 --- a/frontend/src/components/Main/ContentsSection/ChattingSection.tsx +++ b/frontend/src/components/Main/ContentsSection/ChattingSection.tsx @@ -1,10 +1,6 @@ -import React from 'react'; +import React, { useRef } from 'react'; import styled from 'styled-components'; -import { BoostCamMainIcons } from '../../../utils/SvgIcons'; - -const { ListArrow } = BoostCamMainIcons; - const Container = styled.div` width: 100%; height: 100%; @@ -18,18 +14,20 @@ const Container = styled.div` const ChattingSectionHeader = styled.div` width: 100%; - height: 25px; - flex: 0.5; + flex: 1 1 0; + max-height: 50px; display: flex; flex-direction: row; justify-content: space-between; align-items: center; + + border-bottom: 1px solid gray; `; const ChannelName = styled.div` margin-left: 15px; - padding: 3px 5px; + padding: 8px 12px; border-radius: 10px; cursor: pointer; @@ -54,14 +52,25 @@ const ChannelUserButton = styled.div` const ChattingSectionBody = styled.div` width: 100%; - flex: 5; - + flex: 5 1 0; overflow-y: auto; display: flex; flex-direction: column; justify-content: flex-start; align-items: flex-start; + + &::-webkit-scrollbar { + width: 10px; + } + &::-webkit-scrollbar-thumb { + background-color: #999999; + border-radius: 10px; + } + &::-webkit-scrollbar-track { + background-color: #cccccc; + border-radius: 10px; + } `; const ChattingItemBlock = styled.div` @@ -94,7 +103,6 @@ const ChattingItem = styled.div` `; const ChattingItemHeader = styled.div` - min-width: 150px; display: flex; flex-direction: row; justify-content: flex-start; @@ -107,27 +115,83 @@ const ChattingSender = styled.span` `; const ChattingTimelog = styled.span` - font-size: 10px; + font-size: 12px; + margin-left: 15px; `; const ChattingContents = styled.span` - font-size: 13px; + font-size: 15px; `; -const ChattingTextarea = styled.textarea` - width: 100%; - flex: 1; +const TextareaDiv = styled.div` + min-height: 105px; + max-height: 250px; + background-color: #ece9e9; + + display: flex; + flex-direction: column; + justify-content: space-around; + align-items: center; `; -const ListArrowIcon = styled(ListArrow)` - width: 20px; - height: 20px; - fill: #a69c96; - transform: rotate(90deg); +const ChatTextarea = styled.textarea` + width: 90%; + height: 22px; + max-height: 200px; + border: none; + outline: none; + resize: none; + background: none; + + font-size: 15px; + + padding: 10px; + border: 1px solid gray; + border-radius: 5px; + + background-color: white; + + &::-webkit-scrollbar { + width: 10px; + } + &::-webkit-scrollbar-thumb { + background-color: #999999; + border-radius: 10px; + } + &::-webkit-scrollbar-track { + background-color: #cccccc; + border-radius: 10px; + } `; function ChattingSection(): JSX.Element { - const tmpAry = new Array(30).fill('value'); + const tmpAry = new Array(15).fill('value'); + const textDivRef = useRef(null); + const tmpChannelName = '# ChannelName'; + + const onKeyDownChatTextarea = (e: React.KeyboardEvent) => { + const { key, currentTarget, shiftKey } = e; + const msg = currentTarget.value.trim(); + const divRef = textDivRef.current; + + currentTarget.style.height = '15px'; + currentTarget.style.height = `${currentTarget.scrollHeight - 15}px`; + if (divRef) { + divRef.style.height = `105px`; + divRef.style.height = `${90 + currentTarget.scrollHeight - 27}px`; + } + + if (!shiftKey && key === 'Enter') { + e.preventDefault(); + if (!msg.length) currentTarget.value = ''; + else { + console.log(msg); + currentTarget.value = ''; + } + currentTarget.style.height = '21px'; + if (divRef) divRef.style.height = `105px`; + } + }; const tmpChattingItems = tmpAry.map((val: string, idx: number): JSX.Element => { const key = `${val}-${idx}`; @@ -154,11 +218,13 @@ function ChattingSection(): JSX.Element { return ( - # ChannelName + {tmpChannelName} Users 5 {tmpChattingItems} - + + + ); } diff --git a/frontend/src/components/Main/ContentsSection/ContentsSection.tsx b/frontend/src/components/Main/ContentsSection/ContentsSection.tsx index 8533bb6..adf236b 100644 --- a/frontend/src/components/Main/ContentsSection/ContentsSection.tsx +++ b/frontend/src/components/Main/ContentsSection/ContentsSection.tsx @@ -12,7 +12,7 @@ function ContentsSection(): JSX.Element { return ( - + {/* */} ); } diff --git a/frontend/src/components/Main/MainSection.tsx b/frontend/src/components/Main/MainSection.tsx index 5a92ebc..cf818bf 100644 --- a/frontend/src/components/Main/MainSection.tsx +++ b/frontend/src/components/Main/MainSection.tsx @@ -8,11 +8,14 @@ import MainHeader from './MainHeader'; const Container = styled.div` width: 100%; height: 100%; + + display: flex; + flex-direction: column; `; const MainBody = styled.div` width: 100%; - height: 100%; + flex: 1; background-color: #222323; display: flex; From 668579315cda7e21a89b2bd42247d9c059ca8eab Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Wed, 24 Nov 2021 16:56:52 +0900 Subject: [PATCH 112/172] =?UTF-8?q?Fix=20:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=B0=B8=EC=A1=B0,=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/Main/ContentsSection/ChattingSection.tsx | 1 - .../src/components/Main/ContentsSection/ContentsSection.tsx | 2 -- 2 files changed, 3 deletions(-) diff --git a/frontend/src/components/Main/ContentsSection/ChattingSection.tsx b/frontend/src/components/Main/ContentsSection/ChattingSection.tsx index 7bb6b39..c9d4632 100644 --- a/frontend/src/components/Main/ContentsSection/ChattingSection.tsx +++ b/frontend/src/components/Main/ContentsSection/ChattingSection.tsx @@ -185,7 +185,6 @@ function ChattingSection(): JSX.Element { e.preventDefault(); if (!msg.length) currentTarget.value = ''; else { - console.log(msg); currentTarget.value = ''; } currentTarget.style.height = '21px'; diff --git a/frontend/src/components/Main/ContentsSection/ContentsSection.tsx b/frontend/src/components/Main/ContentsSection/ContentsSection.tsx index adf236b..f9cdcc4 100644 --- a/frontend/src/components/Main/ContentsSection/ContentsSection.tsx +++ b/frontend/src/components/Main/ContentsSection/ContentsSection.tsx @@ -1,7 +1,6 @@ import React from 'react'; import styled from 'styled-components'; import ChattingSection from './ChattingSection'; -import ThreadSection from './ThreadSection'; const Container = styled.div` flex: 1; @@ -12,7 +11,6 @@ function ContentsSection(): JSX.Element { return ( - {/* */} ); } From efa5b10a4a8869fe3128a9e5cede1921f7adca53 Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Wed, 24 Nov 2021 17:26:06 +0900 Subject: [PATCH 113/172] =?UTF-8?q?Feat=20:=20ContentsSection=EC=9D=98=20C?= =?UTF-8?q?hatting=20=EB=84=A4=EC=9D=B4=EB=B0=8D=EC=9D=84=20Message?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Main/ContentsSection/ContentsSection.tsx | 4 +- ...ChattingSection.tsx => MessageSection.tsx} | 60 +++++++++---------- 2 files changed, 32 insertions(+), 32 deletions(-) rename frontend/src/components/Main/ContentsSection/{ChattingSection.tsx => MessageSection.tsx} (74%) diff --git a/frontend/src/components/Main/ContentsSection/ContentsSection.tsx b/frontend/src/components/Main/ContentsSection/ContentsSection.tsx index f9cdcc4..f3b4304 100644 --- a/frontend/src/components/Main/ContentsSection/ContentsSection.tsx +++ b/frontend/src/components/Main/ContentsSection/ContentsSection.tsx @@ -1,6 +1,6 @@ import React from 'react'; import styled from 'styled-components'; -import ChattingSection from './ChattingSection'; +import MessageSection from './MessageSection'; const Container = styled.div` flex: 1; @@ -10,7 +10,7 @@ const Container = styled.div` function ContentsSection(): JSX.Element { return ( - + ); } diff --git a/frontend/src/components/Main/ContentsSection/ChattingSection.tsx b/frontend/src/components/Main/ContentsSection/MessageSection.tsx similarity index 74% rename from frontend/src/components/Main/ContentsSection/ChattingSection.tsx rename to frontend/src/components/Main/ContentsSection/MessageSection.tsx index c9d4632..fb8f2af 100644 --- a/frontend/src/components/Main/ContentsSection/ChattingSection.tsx +++ b/frontend/src/components/Main/ContentsSection/MessageSection.tsx @@ -12,7 +12,7 @@ const Container = styled.div` justify-content: flex-start; `; -const ChattingSectionHeader = styled.div` +const MessageSectionHeader = styled.div` width: 100%; flex: 1 1 0; max-height: 50px; @@ -50,7 +50,7 @@ const ChannelUserButton = styled.div` } `; -const ChattingSectionBody = styled.div` +const MessageSectionBody = styled.div` width: 100%; flex: 5 1 0; overflow-y: auto; @@ -73,7 +73,7 @@ const ChattingSectionBody = styled.div` } `; -const ChattingItemBlock = styled.div` +const MessageItemBlock = styled.div` width: 100%; display: flex; flex-direction: row; @@ -84,7 +84,7 @@ const ChattingItemBlock = styled.div` } `; -const ChattingItemIcon = styled.div` +const MessageItemIcon = styled.div` width: 36px; height: 36px; margin: 10px; @@ -92,7 +92,7 @@ const ChattingItemIcon = styled.div` border-radius: 8px; `; -const ChattingItem = styled.div` +const MessageItem = styled.div` width: 90%; padding: 8px 0px; @@ -102,24 +102,24 @@ const ChattingItem = styled.div` align-items: flex-start; `; -const ChattingItemHeader = styled.div` +const MessageItemHeader = styled.div` display: flex; flex-direction: row; justify-content: flex-start; align-items: center; `; -const ChattingSender = styled.span` +const MessageSender = styled.span` font-weight: 600; font-size: 15px; `; -const ChattingTimelog = styled.span` +const MessageTimelog = styled.span` font-size: 12px; margin-left: 15px; `; -const ChattingContents = styled.span` +const MessageContents = styled.span` font-size: 15px; `; @@ -134,7 +134,7 @@ const TextareaDiv = styled.div` align-items: center; `; -const ChatTextarea = styled.textarea` +const MessageTextarea = styled.textarea` width: 90%; height: 22px; max-height: 200px; @@ -164,12 +164,12 @@ const ChatTextarea = styled.textarea` } `; -function ChattingSection(): JSX.Element { +function MessageSection(): JSX.Element { const tmpAry = new Array(15).fill('value'); const textDivRef = useRef(null); const tmpChannelName = '# ChannelName'; - const onKeyDownChatTextarea = (e: React.KeyboardEvent) => { + const onKeyDownMessageTextarea = (e: React.KeyboardEvent) => { const { key, currentTarget, shiftKey } = e; const msg = currentTarget.value.trim(); const divRef = textDivRef.current; @@ -192,40 +192,40 @@ function ChattingSection(): JSX.Element { } }; - const tmpChattingItems = tmpAry.map((val: string, idx: number): JSX.Element => { + const tmpMessageItems = tmpAry.map((val: string, idx: number): JSX.Element => { const key = `${val}-${idx}`; - const tmp = new Array(idx).fill('chatting'); + const tmp = new Array(idx).fill('Message'); const contents = tmp.reduce((acc, va) => { return `${acc}-${va}`; }, ''); return ( - - - - - Sender {idx} - Timestamp - - + + + + + Sender {idx} + Timestamp + + `${contents}-${idx}` - - - + + + ); }); return ( - + {tmpChannelName} Users 5 - - {tmpChattingItems} + + {tmpMessageItems} - + ); } -export default ChattingSection; +export default MessageSection; From 5fafebf9032e25a01f7f0040a257947057794a7b Mon Sep 17 00:00:00 2001 From: K Date: Wed, 24 Nov 2021 17:41:24 +0900 Subject: [PATCH 114/172] =?UTF-8?q?Fix=20:=20Merge=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 중괄호 빠진 부분 수정 --- frontend/src/components/Main/MainStore.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/components/Main/MainStore.tsx b/frontend/src/components/Main/MainStore.tsx index 9363b88..edc0f84 100644 --- a/frontend/src/components/Main/MainStore.tsx +++ b/frontend/src/components/Main/MainStore.tsx @@ -58,6 +58,7 @@ function MainStore(props: MainStoreProps): JSX.Element { const messageList = list.data; // eslint-disable-next-line no-console console.log(messageList); + }; const getServerCamList = async (): Promise => { const response = await fetch(`/api/servers/${selectedServer?.server.id}/cams`); From 22889af70a3fbdf30a682f93e34664108dc6a5e9 Mon Sep 17 00:00:00 2001 From: K Date: Wed, 24 Nov 2021 18:58:55 +0900 Subject: [PATCH 115/172] =?UTF-8?q?Feat=20:=20Comment=20Entity=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RelationId 추가, 객체 생성용 factory method 추가 --- backend/src/comment/comment.entity.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/backend/src/comment/comment.entity.ts b/backend/src/comment/comment.entity.ts index 73225ba..149b8f5 100644 --- a/backend/src/comment/comment.entity.ts +++ b/backend/src/comment/comment.entity.ts @@ -4,6 +4,7 @@ import { PrimaryGeneratedColumn, ManyToOne, CreateDateColumn, + RelationId, } from 'typeorm'; import { Message } from '../message/message.entity'; import { User } from '../user/user.entity'; @@ -22,6 +23,20 @@ export class Comment { @ManyToOne(() => User) sender: User; + @RelationId((comment: Comment) => comment.sender) + senderId: number; + @ManyToOne(() => Message) message: Message; + + @RelationId((comment: Comment) => comment.message) + messageId: number; + + static newInstance(sender: User, message: Message, contents: string) { + const newInstance = new Comment(); + newInstance.contents = contents; + newInstance.sender = sender; + newInstance.message = message; + return newInstance; + } } From d21eb883d148c9e039c44764110a0fb6163a4060 Mon Sep 17 00:00:00 2001 From: korung3195 Date: Wed, 24 Nov 2021 16:21:01 +0900 Subject: [PATCH 116/172] =?UTF-8?q?Refactor=20:=20cam=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EA=B3=BC=20cams=20=EB=AA=A8=EB=93=88=20=EB=B3=91=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서로 의존성이 매우 크고, 비슷한 네이밍으로 혼란을 줄 수 있기 때문에 두 모듈을 병합하였습니다. --- backend/src/app.module.ts | 2 - backend/src/cam/cam-inner.service.ts | 104 +++++++++++++++ backend/src/cam/cam.controller.spec.ts | 4 +- backend/src/cam/cam.controller.ts | 37 ++---- .../src/{cams/cams.dto.ts => cam/cam.dto.ts} | 6 +- .../cams.entity.ts => cam/cam.entity.ts} | 4 +- backend/src/cam/cam.gateway.spec.ts | 8 +- backend/src/cam/cam.gateway.ts | 36 +++--- backend/src/cam/cam.module.ts | 13 +- backend/src/cam/cam.repository.ts | 13 ++ backend/src/cam/cam.service.ts | 120 +++++------------- backend/src/cams/cams.controller.ts | 21 --- backend/src/cams/cams.module.ts | 20 --- backend/src/cams/cams.repository.ts | 13 -- backend/src/cams/cams.service.ts | 50 -------- backend/src/server/server.controller.ts | 14 +- backend/src/server/server.module.ts | 7 +- .../components/Main/Cam/CreateCamModal.tsx | 2 +- frontend/src/components/Main/MainStore.tsx | 2 +- 19 files changed, 213 insertions(+), 263 deletions(-) create mode 100644 backend/src/cam/cam-inner.service.ts rename backend/src/{cams/cams.dto.ts => cam/cam.dto.ts} (67%) rename backend/src/{cams/cams.entity.ts => cam/cam.entity.ts} (84%) create mode 100644 backend/src/cam/cam.repository.ts delete mode 100644 backend/src/cams/cams.controller.ts delete mode 100644 backend/src/cams/cams.module.ts delete mode 100644 backend/src/cams/cams.repository.ts delete mode 100644 backend/src/cams/cams.service.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index aa00509..853bf75 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -12,7 +12,6 @@ import { ChannelModule } from './channel/channel.module'; import { MessageModule } from './message/message.module'; import { EmoticonModule } from './emoticon/emoticon.module'; import { ServerModule } from './server/server.module'; -import { CamsModule } from './cams/cams.module'; import { UserServerModule } from './user-server/user-server.module'; import { LoginModule } from './login/login.module'; import { UserChannelModule } from './user-channel/user-channel.module'; @@ -34,7 +33,6 @@ import githubConfig from './config/github.config'; MessageModule, EmoticonModule, ServerModule, - CamsModule, UserServerModule, LoginModule, UserChannelModule, diff --git a/backend/src/cam/cam-inner.service.ts b/backend/src/cam/cam-inner.service.ts new file mode 100644 index 0000000..22995cf --- /dev/null +++ b/backend/src/cam/cam-inner.service.ts @@ -0,0 +1,104 @@ +import { Injectable } from '@nestjs/common'; +import { Status, CamMap } from '../types/cam'; + +type RoomId = string; +type SocketId = string; +type ScreenSharingUserId = SocketId; +type RoomInfo = { + socketId: string; + userNickname: string; +}; + +@Injectable() +export class CamInnerService { + private map: Map>; + private sharedScreen: Map; + + constructor() { + this.map = new Map(); + this.sharedScreen = new Map(); + } + + getRoomList() { + return this.map; + } + + getRoomNicknameList(roomId: string): RoomInfo[] { + const roomInfo: CamMap[] = this.map.get(roomId); + return roomInfo.map((data) => { + const { socketId, userNickname } = data; + return { socketId, userNickname }; + }); + } + + isRoomExist(roomId: string): boolean { + return this.map.has(roomId); + } + + createRoom(roomId: string): boolean { + if (this.map.get(roomId)) return false; + this.map.set(roomId, []); + this.sharedScreen.set(roomId, { userId: null }); + return true; + } + + joinRoom( + roomId: string, + userId: string, + socketId: string, + userNickname: string, + status: Status, + ): boolean { + if (!this.map.get(roomId)) return false; + this.map.get(roomId).push({ userId, socketId, userNickname, status }); + return true; + } + + exitRoom(roomId: string, userId: string) { + if (!this.map.get(roomId)) return false; + const room = this.map.get(roomId).filter((user) => user.userId !== userId); + if (!room.length) this.map.delete(roomId); + else this.map.set(roomId, room); + } + + updateStatus(roomId: string, userId: string, status: Status) { + if (!this.map.get(roomId)) return false; + const user = this.map.get(roomId).find((user) => user.userId === userId); + user.status = status; + } + + getStatus(roomId: string, userId: string) { + if (!this.map.get(roomId)) return false; + return this.map.get(roomId).find((user) => user.userId === userId)?.status; + } + + getNickname(roomId: string, userId: string) { + if (!this.map.get(roomId)) return false; + return this.map.get(roomId).find((user) => user.userId === userId) + ?.userNickname; + } + + changeNickname(roomId: string, socketId: string, userNickname: string) { + if (!this.map.get(roomId)) return false; + const user = this.map + .get(roomId) + .find((user) => user.socketId === socketId); + user.userNickname = userNickname; + } + + setScreenSharingUser(roomId: RoomId, userId: ScreenSharingUserId) { + this.sharedScreen.set(roomId, { userId }); + } + + endSharingScreen(roomId: RoomId) { + this.sharedScreen.set(roomId, { userId: null }); + } + + getScreenSharingUserInfo(roomId: RoomId) { + if (this.sharedScreen.has(roomId)) { + return this.sharedScreen.get(roomId); + } + + return null; + } +} diff --git a/backend/src/cam/cam.controller.spec.ts b/backend/src/cam/cam.controller.spec.ts index ebf8069..4bffdc8 100644 --- a/backend/src/cam/cam.controller.spec.ts +++ b/backend/src/cam/cam.controller.spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { CamController } from './cam.controller'; -import { CamService } from './cam.service'; +import { CamInnerService } from './cam-inner.service'; describe('CamController', () => { let controller: CamController; @@ -8,7 +8,7 @@ describe('CamController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [CamController], - providers: [CamService], + providers: [CamInnerService], }).compile(); controller = module.get(CamController); diff --git a/backend/src/cam/cam.controller.ts b/backend/src/cam/cam.controller.ts index 74d69ea..501271f 100644 --- a/backend/src/cam/cam.controller.ts +++ b/backend/src/cam/cam.controller.ts @@ -1,39 +1,18 @@ -import { Controller, Get, Post, Param, Body } from '@nestjs/common'; +import { Controller, Post, Body } from '@nestjs/common'; +import ResponseEntity from '../common/response-entity'; +import { CreateCamDto } from './cam.dto'; import { CamService } from './cam.service'; @Controller('api/cam') export class CamController { constructor(private camService: CamService) {} - @Post('/room') - createRoom(@Body() payload: { roomid: string }): string { - const { roomid } = payload; - let statusCode = 201; - if (!this.camService.createRoom(roomid)) statusCode = 500; - return Object.assign({ - statusCode, - data: payload, - }); - } - - @Get('/room/:id') - isRoomExist(@Param('id') id: string): string { - let statusCode = 201; - if (!this.camService.isRoomExist(id)) statusCode = 500; - return Object.assign({ - statusCode, - data: { id }, - }); - } + @Post() async createCam( + @Body() cam: CreateCamDto, + ): Promise> { + const savedCam = await this.camService.createCam(cam); - @Get('/roomlist') - getRoomList(): string { - const roomList = this.camService.getRoomList(); - const roomListJson = JSON.stringify(Array.from(roomList.entries())); - return Object.assign({ - statusCode: 201, - data: { roomListJson }, - }); + return ResponseEntity.created(savedCam.id); } } diff --git a/backend/src/cams/cams.dto.ts b/backend/src/cam/cam.dto.ts similarity index 67% rename from backend/src/cams/cams.dto.ts rename to backend/src/cam/cam.dto.ts index 94477e3..75080bc 100644 --- a/backend/src/cams/cams.dto.ts +++ b/backend/src/cam/cam.dto.ts @@ -1,9 +1,9 @@ -export type CreateCamsDto = { +export type CreateCamDto = { name: string; serverId: number; }; -export class RequestCamsDto { +export class ResponseCamDto { name: string; url: string; constructor(name: string, url: string) { @@ -12,6 +12,6 @@ export class RequestCamsDto { } static fromEntry(name: string, url: string) { - return new RequestCamsDto(name, url); + return new ResponseCamDto(name, url); } } diff --git a/backend/src/cams/cams.entity.ts b/backend/src/cam/cam.entity.ts similarity index 84% rename from backend/src/cams/cams.entity.ts rename to backend/src/cam/cam.entity.ts index 27c5647..5d1b734 100644 --- a/backend/src/cams/cams.entity.ts +++ b/backend/src/cam/cam.entity.ts @@ -8,7 +8,7 @@ import { import { Server } from '../server/server.entity'; @Entity() -export class Cams { +export class Cam { @PrimaryGeneratedColumn({ type: 'bigint' }) id: number; @@ -21,6 +21,6 @@ export class Cams { @ManyToOne(() => Server) server: Server; - @RelationId((cams: Cams) => cams.server) + @RelationId((cam: Cam) => cam.server) serverId: number; } diff --git a/backend/src/cam/cam.gateway.spec.ts b/backend/src/cam/cam.gateway.spec.ts index f72dae8..3d06652 100644 --- a/backend/src/cam/cam.gateway.spec.ts +++ b/backend/src/cam/cam.gateway.spec.ts @@ -1,18 +1,18 @@ import { Test, TestingModule } from '@nestjs/testing'; import { CamGateway } from './cam.gateway'; -import { CamService } from './cam.service'; +import { CamInnerService } from './cam-inner.service'; describe('CamGateway', () => { let gateway: CamGateway; - let service: CamService; + let service: CamInnerService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [CamGateway, CamService], + providers: [CamGateway, CamInnerService], }).compile(); gateway = module.get(CamGateway); - service = module.get(CamService); + service = module.get(CamInnerService); }); it('should be defined', () => { diff --git a/backend/src/cam/cam.gateway.ts b/backend/src/cam/cam.gateway.ts index 8d11731..b84b88a 100644 --- a/backend/src/cam/cam.gateway.ts +++ b/backend/src/cam/cam.gateway.ts @@ -6,12 +6,12 @@ import { import { Socket, Server } from 'socket.io'; import { Status, MessageInfo } from '../types/cam'; -import { CamService } from './cam.service'; +import { CamInnerService } from './cam-inner.service'; @WebSocketGateway() export class CamGateway { @WebSocketServer() server: Server; - constructor(private camService: CamService) {} + constructor(private camInnerService: CamInnerService) {} @SubscribeMessage('joinRoom') handleJoinRoom( @@ -25,7 +25,13 @@ export class CamGateway { ): void { const { roomId, userId, userNickname, status } = payload; client.join(roomId); - this.camService.joinRoom(roomId, userId, client.id, userNickname, status); + this.camInnerService.joinRoom( + roomId, + userId, + client.id, + userNickname, + status, + ); client.to(roomId).emit('userConnected', { userId }); client.data.roomId = roomId; @@ -33,7 +39,7 @@ export class CamGateway { client.on('disconnect', () => { client.to(roomId).emit('userDisconnected', { userId }); - this.camService.exitRoom(roomId, userId); + this.camInnerService.exitRoom(roomId, userId); }); } @@ -43,7 +49,7 @@ export class CamGateway { const { roomId, userId } = client.data; client.to(roomId).emit('userDisconnected', { userId }); client.leave(roomId); - this.camService.exitRoom(roomId, userId); + this.camInnerService.exitRoom(roomId, userId); client.data.roomId = null; client.data.userId = null; } @@ -53,7 +59,7 @@ export class CamGateway { if (!client.data.roomId || !client.data.userId) return; const { roomId, userId } = client.data; const { status } = payload; - this.camService.updateStatus(roomId, userId, status); + this.camInnerService.updateStatus(roomId, userId, status); client.to(roomId).emit('userStatus', { userId, status }); } @@ -62,8 +68,8 @@ export class CamGateway { if (!client.data.roomId || !client.data.userId) return; const { roomId } = client.data; const { userId } = payload; - const status = this.camService.getStatus(roomId, userId); - const userNickname = this.camService.getNickname(roomId, userId); + const status = this.camInnerService.getStatus(roomId, userId); + const userNickname = this.camInnerService.getNickname(roomId, userId); if (status) { client.emit('userStatus', { userId, status }); } @@ -76,7 +82,7 @@ export class CamGateway { handleStartScreenShare(client: Socket, payload: { roomId: string }) { if (!client.data.roomId || !client.data.userId) return; const { roomId } = payload; - this.camService.setScreenSharingUser(roomId, client.id); + this.camInnerService.setScreenSharingUser(roomId, client.id); client .to(roomId) @@ -97,7 +103,7 @@ export class CamGateway { handleEndSharingScreen(client: Socket, payload: { roomId: string }) { if (!client.data.roomId || !client.data.userId) return; const { roomId } = payload; - this.camService.endSharingScreen(roomId); + this.camInnerService.endSharingScreen(roomId); client.to(roomId).emit('endSharingScreen'); } @@ -106,7 +112,7 @@ export class CamGateway { if (!client.data.roomId || !client.data.userId) return; const { roomId } = payload; const screenSharingUserInfo = - this.camService.getScreenSharingUserInfo(roomId); + this.camInnerService.getScreenSharingUserInfo(roomId); if (!screenSharingUserInfo || !screenSharingUserInfo.userId) { return; } @@ -120,7 +126,7 @@ export class CamGateway { handleSendMessage(client: Socket, payload: MessageInfo): void { if (!client.data.roomId || !client.data.userId) return; const { roomId } = client.data; - const nicknameInfo = this.camService.getRoomNicknameList(roomId); + const nicknameInfo = this.camInnerService.getRoomNicknameList(roomId); client.broadcast .to(roomId) .emit('receiveMessage', { payload, nicknameInfo }); @@ -128,7 +134,7 @@ export class CamGateway { @SubscribeMessage('changeRoomList') handleChangeRoomList(): void { - const roomList = this.camService.getRoomList(); + const roomList = this.camInnerService.getRoomList(); const roomListJson = JSON.stringify(Array.from(roomList.entries())); this.server.emit('getRoomList', roomListJson); } @@ -142,8 +148,8 @@ export class CamGateway { const { roomId, userId } = client.data; const { userNickname } = payload; - this.camService.changeNickname(roomId, client.id, userNickname); - const nicknameInfo = this.camService.getRoomNicknameList(roomId); + this.camInnerService.changeNickname(roomId, client.id, userNickname); + const nicknameInfo = this.camInnerService.getRoomNicknameList(roomId); client.broadcast.to(roomId).emit('getNicknameList', nicknameInfo); client.to(roomId).emit('userNickname', { userId, userNickname }); } diff --git a/backend/src/cam/cam.module.ts b/backend/src/cam/cam.module.ts index 1eb3b95..604b398 100644 --- a/backend/src/cam/cam.module.ts +++ b/backend/src/cam/cam.module.ts @@ -1,10 +1,19 @@ import { Module } from '@nestjs/common'; import { CamGateway } from './cam.gateway'; -import { CamService } from './cam.service'; +import { CamInnerService } from './cam-inner.service'; import { CamController } from './cam.controller'; +import { CamService } from './cam.service'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Cam } from './cam.entity'; +import { ServerRepository } from '../server/server.repository'; +import { CamRepository } from './cam.repository'; +import { Server } from '../server/server.entity'; @Module({ - providers: [CamGateway, CamService], + imports: [ + TypeOrmModule.forFeature([Cam, Server, CamRepository, ServerRepository]), + ], + providers: [CamGateway, CamInnerService, CamService], controllers: [CamController], exports: [CamService], }) diff --git a/backend/src/cam/cam.repository.ts b/backend/src/cam/cam.repository.ts new file mode 100644 index 0000000..ddab237 --- /dev/null +++ b/backend/src/cam/cam.repository.ts @@ -0,0 +1,13 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { Cam } from './cam.entity'; + +@EntityRepository(Cam) +export class CamRepository extends Repository { + findByServerId(serverId: number) { + return this.createQueryBuilder('cam') + .where('cam.serverId = :serverId', { + serverId, + }) + .getMany(); + } +} diff --git a/backend/src/cam/cam.service.ts b/backend/src/cam/cam.service.ts index b51694b..e35f586 100644 --- a/backend/src/cam/cam.service.ts +++ b/backend/src/cam/cam.service.ts @@ -1,104 +1,50 @@ -import { Injectable } from '@nestjs/common'; -import { Status, CamMap } from '../types/cam'; - -type RoomId = string; -type SocketId = string; -type ScreenSharingUserId = SocketId; -type RoomInfo = { - socketId: string; - userNickname: string; -}; +import { BadRequestException, Injectable } from '@nestjs/common'; +import { ServerRepository } from '../server/server.repository'; +import { CreateCamDto, ResponseCamDto } from './cam.dto'; +import { Cam } from './cam.entity'; +import { CamRepository } from './cam.repository'; +import { v4 } from 'uuid'; +import { CamInnerService } from './cam-inner.service'; @Injectable() export class CamService { - private map: Map>; - private sharedScreen: Map; - - constructor() { - this.map = new Map(); - this.sharedScreen = new Map(); - } + constructor( + private camRepository: CamRepository, + private serverRepository: ServerRepository, + private readonly camInnerService: CamInnerService, + ) {} - getRoomList() { - return this.map; + findOne(id: number): Promise { + return this.camRepository.findOne({ id: id }, { relations: ['server'] }); } - getRoomNicknameList(roomId: string): RoomInfo[] { - const roomInfo: CamMap[] = this.map.get(roomId); - return roomInfo.map((data) => { - const { socketId, userNickname } = data; - return { socketId, userNickname }; + async createCam(cam: CreateCamDto): Promise { + const camEntity = this.camRepository.create(); + const server = await this.serverRepository.findOne({ + id: cam.serverId, }); - } - - isRoomExist(roomId: string): boolean { - return this.map.has(roomId); - } - - createRoom(roomId: string): boolean { - if (this.map.get(roomId)) return false; - this.map.set(roomId, []); - this.sharedScreen.set(roomId, { userId: null }); - return true; - } - - joinRoom( - roomId: string, - userId: string, - socketId: string, - userNickname: string, - status: Status, - ): boolean { - if (!this.map.get(roomId)) return false; - this.map.get(roomId).push({ userId, socketId, userNickname, status }); - return true; - } - exitRoom(roomId: string, userId: string) { - if (!this.map.get(roomId)) return false; - const room = this.map.get(roomId).filter((user) => user.userId !== userId); - if (!room.length) this.map.delete(roomId); - else this.map.set(roomId, room); - } - - updateStatus(roomId: string, userId: string, status: Status) { - if (!this.map.get(roomId)) return false; - const user = this.map.get(roomId).find((user) => user.userId === userId); - user.status = status; - } - - getStatus(roomId: string, userId: string) { - if (!this.map.get(roomId)) return false; - return this.map.get(roomId).find((user) => user.userId === userId)?.status; - } + if (!server) { + throw new BadRequestException(); + } - getNickname(roomId: string, userId: string) { - if (!this.map.get(roomId)) return false; - return this.map.get(roomId).find((user) => user.userId === userId) - ?.userNickname; - } + camEntity.name = cam.name; + camEntity.server = server; + camEntity.url = v4(); - changeNickname(roomId: string, socketId: string, userNickname: string) { - if (!this.map.get(roomId)) return false; - const user = this.map - .get(roomId) - .find((user) => user.socketId === socketId); - user.userNickname = userNickname; - } + const savedCam = await this.camRepository.save(camEntity); + this.camInnerService.createRoom(camEntity.url); - setScreenSharingUser(roomId: RoomId, userId: ScreenSharingUserId) { - this.sharedScreen.set(roomId, { userId }); + return savedCam; } - endSharingScreen(roomId: RoomId) { - this.sharedScreen.set(roomId, { userId: null }); + // cam의 exitRooms 로직과 연결이 필요함! + async deleteCam(id: number): Promise { + await this.camRepository.delete({ id: id }); } - getScreenSharingUserInfo(roomId: RoomId) { - if (this.sharedScreen.has(roomId)) { - return this.sharedScreen.get(roomId); - } - - return null; + async getCamList(serverId: number): Promise { + const res = await this.camRepository.findByServerId(serverId); + return res.map((entry) => ResponseCamDto.fromEntry(entry.name, entry.url)); } } diff --git a/backend/src/cams/cams.controller.ts b/backend/src/cams/cams.controller.ts deleted file mode 100644 index 7aa8e99..0000000 --- a/backend/src/cams/cams.controller.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Body, Controller, Post, UseGuards } from '@nestjs/common'; -import ResponseEntity from '../common/response-entity'; - -import { LoginGuard } from '../login/login.guard'; -import { CreateCamsDto } from './cams.dto'; -import { CamsService } from './cams.service'; - -@Controller('/api/cams') -@UseGuards(LoginGuard) -export class CamsController { - constructor(private camsService: CamsService) { - this.camsService = camsService; - } - - @Post() async createCams( - @Body() cams: CreateCamsDto, - ): Promise> { - const savedCams = await this.camsService.createCams(cams); - return ResponseEntity.created(savedCams.id); - } -} diff --git a/backend/src/cams/cams.module.ts b/backend/src/cams/cams.module.ts deleted file mode 100644 index 54b7caf..0000000 --- a/backend/src/cams/cams.module.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { CamModule } from '../cam/cam.module'; -import { Server } from '../server/server.entity'; -import { ServerRepository } from '../server/server.repository'; -import { CamsController } from './cams.controller'; -import { Cams } from './cams.entity'; -import { CamsRepository } from './cams.repository'; -import { CamsService } from './cams.service'; - -@Module({ - imports: [ - TypeOrmModule.forFeature([Cams, Server, CamsRepository, ServerRepository]), - CamModule, - ], - providers: [CamsService], - controllers: [CamsController], - exports: [CamsService], -}) -export class CamsModule {} diff --git a/backend/src/cams/cams.repository.ts b/backend/src/cams/cams.repository.ts deleted file mode 100644 index e894d3e..0000000 --- a/backend/src/cams/cams.repository.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { EntityRepository, Repository } from 'typeorm'; -import { Cams } from './cams.entity'; - -@EntityRepository(Cams) -export class CamsRepository extends Repository { - findByServerId(serverId: number) { - return this.createQueryBuilder('cams') - .where('cams.serverId = :serverId', { - serverId, - }) - .getMany(); - } -} diff --git a/backend/src/cams/cams.service.ts b/backend/src/cams/cams.service.ts deleted file mode 100644 index be799b5..0000000 --- a/backend/src/cams/cams.service.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; -import { ServerRepository } from '../server/server.repository'; -import { CreateCamsDto, RequestCamsDto } from './cams.dto'; -import { Cams } from './cams.entity'; -import { CamsRepository } from './cams.repository'; -import { v4 } from 'uuid'; -import { CamService } from '../cam/cam.service'; - -@Injectable() -export class CamsService { - constructor( - private camsRepository: CamsRepository, - private serverRepository: ServerRepository, - private readonly camService: CamService, - ) {} - - findOne(id: number): Promise { - return this.camsRepository.findOne({ id: id }, { relations: ['server'] }); - } - - async createCams(cams: CreateCamsDto): Promise { - const camsEntity = this.camsRepository.create(); - const server = await this.serverRepository.findOne({ - id: cams.serverId, - }); - - if (!server) { - throw new BadRequestException(); - } - - camsEntity.name = cams.name; - camsEntity.server = server; - camsEntity.url = v4(); - - this.camService.createRoom(camsEntity.url); - const savedCams = await this.camsRepository.save(camsEntity); - - return savedCams; - } - - // cam의 exitRooms 로직과 연결이 필요함! - async deleteCams(id: number): Promise { - await this.camsRepository.delete({ id: id }); - } - - async getCams(serverId: number): Promise { - const res = await this.camsRepository.findByServerId(serverId); - return res.map((entry) => RequestCamsDto.fromEntry(entry.name, entry.url)); - } -} diff --git a/backend/src/server/server.controller.ts b/backend/src/server/server.controller.ts index f4b49c4..4886133 100644 --- a/backend/src/server/server.controller.ts +++ b/backend/src/server/server.controller.ts @@ -23,15 +23,15 @@ import ResponseEntity from '../common/response-entity'; import { FileInterceptor } from '@nestjs/platform-express'; import { ImageService } from '../image/image.service'; import ServerWithUsersDto from './dto/response-server-users.dto'; -import { CamsService } from '../cams/cams.service'; -import { RequestCamsDto } from '../cams/cams.dto'; +import { CamService } from '../cam/cam.service'; +import { ResponseCamDto } from '../cam/cam.dto'; @Controller('/api/servers') export class ServerController { constructor( private serverService: ServerService, private imageService: ImageService, - private camsService: CamsService, + private camService: CamService, ) {} @Get('/:id/users') async findOneWithUsers( @@ -41,11 +41,11 @@ export class ServerController { return ResponseEntity.ok(serverWithUsers); } - @Get('/:id/cams') async findCams( + @Get('/:id/cam') async findCams( @Param('id') id: number, - ): Promise> { - const cams = await this.camsService.getCams(id); - return ResponseEntity.ok(cams); + ): Promise> { + const cam = await this.camService.getCamList(id); + return ResponseEntity.ok(cam); } @Post() diff --git a/backend/src/server/server.module.ts b/backend/src/server/server.module.ts index cb613c8..4bbe07e 100644 --- a/backend/src/server/server.module.ts +++ b/backend/src/server/server.module.ts @@ -7,15 +7,14 @@ import { ServerController } from './server.controller'; import { UserServerModule } from '../user-server/user-server.module'; import { ImageModule } from '../image/image.module'; import { ServerRepository } from './server.repository'; -import { CamsModule } from '../cams/cams.module'; -import { CamsRepository } from '../cams/cams.repository'; +import { CamModule } from '../cam/cam.module'; @Module({ imports: [ ImageModule, forwardRef(() => UserServerModule), - TypeOrmModule.forFeature([User, ServerRepository, CamsRepository]), - CamsModule, + TypeOrmModule.forFeature([User, ServerRepository]), + CamModule, ], providers: [ServerService], controllers: [ServerController], diff --git a/frontend/src/components/Main/Cam/CreateCamModal.tsx b/frontend/src/components/Main/Cam/CreateCamModal.tsx index 1cd785e..94a8c3b 100644 --- a/frontend/src/components/Main/Cam/CreateCamModal.tsx +++ b/frontend/src/components/Main/Cam/CreateCamModal.tsx @@ -180,7 +180,7 @@ function CreateCamModal(): JSX.Element { const onSubmitCreateCamModal = async (data: { name: string; description: string }) => { const { name } = data; - await fetch('api/cams', { + await fetch('api/cam', { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/frontend/src/components/Main/MainStore.tsx b/frontend/src/components/Main/MainStore.tsx index 0cea251..ee436c8 100644 --- a/frontend/src/components/Main/MainStore.tsx +++ b/frontend/src/components/Main/MainStore.tsx @@ -68,7 +68,7 @@ function MainStore(props: MainStoreProps): JSX.Element { }; const getServerCamList = async (): Promise => { - const response = await fetch(`/api/servers/${selectedServer?.server.id}/cams`); + const response = await fetch(`/api/servers/${selectedServer?.server.id}/cam`); const list = await response.json(); const camList = list.data; From 0deb69f208ee0153012c0eea02d9eb15ef9136de Mon Sep 17 00:00:00 2001 From: korung3195 Date: Wed, 24 Nov 2021 18:01:18 +0900 Subject: [PATCH 117/172] =?UTF-8?q?Feat=20:=20cam=EC=97=90=20=EC=82=AC?= =?UTF-8?q?=EB=9E=8C=EC=9D=B4=20=EC=97=86=EC=9C=BC=EB=A9=B4=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=ED=95=98=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cam에 마지막으로 있던 사람이 종료하면 cam이 삭제되는 기능을 구현하였습니다. --- backend/src/cam/cam-inner.service.ts | 16 +++- backend/src/cam/cam.controller.ts | 22 +++++- backend/src/cam/cam.gateway.ts | 17 ++--- backend/src/cam/cam.service.ts | 17 +++-- .../components/Cam/ButtonBar/ButtonBar.tsx | 2 +- frontend/src/components/Cam/Cam.tsx | 76 ++++++++++++------- .../components/Main/Cam/CreateCamModal.tsx | 3 +- frontend/src/components/Main/MainStore.tsx | 1 + 8 files changed, 101 insertions(+), 53 deletions(-) diff --git a/backend/src/cam/cam-inner.service.ts b/backend/src/cam/cam-inner.service.ts index 22995cf..fa87938 100644 --- a/backend/src/cam/cam-inner.service.ts +++ b/backend/src/cam/cam-inner.service.ts @@ -1,5 +1,6 @@ -import { Injectable } from '@nestjs/common'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { Status, CamMap } from '../types/cam'; +import { CamService } from './cam.service'; type RoomId = string; type SocketId = string; @@ -14,7 +15,10 @@ export class CamInnerService { private map: Map>; private sharedScreen: Map; - constructor() { + constructor( + @Inject(forwardRef(() => CamService)) + private readonly camService: CamService, + ) { this.map = new Map(); this.sharedScreen = new Map(); } @@ -57,8 +61,12 @@ export class CamInnerService { exitRoom(roomId: string, userId: string) { if (!this.map.get(roomId)) return false; const room = this.map.get(roomId).filter((user) => user.userId !== userId); - if (!room.length) this.map.delete(roomId); - else this.map.set(roomId, room); + if (room.length == 0) { + this.map.delete(roomId); + this.camService.deleteCam(roomId); + } else { + this.map.set(roomId, room); + } } updateStatus(roomId: string, userId: string, status: Status) { diff --git a/backend/src/cam/cam.controller.ts b/backend/src/cam/cam.controller.ts index 501271f..8749973 100644 --- a/backend/src/cam/cam.controller.ts +++ b/backend/src/cam/cam.controller.ts @@ -1,7 +1,15 @@ -import { Controller, Post, Body } from '@nestjs/common'; +import { + Controller, + Post, + Body, + Get, + Param, + NotFoundException, +} from '@nestjs/common'; import ResponseEntity from '../common/response-entity'; import { CreateCamDto } from './cam.dto'; +import { Cam } from './cam.entity'; import { CamService } from './cam.service'; @Controller('api/cam') @@ -15,4 +23,16 @@ export class CamController { return ResponseEntity.created(savedCam.id); } + + @Get('/:url') async checkCam( + @Param('url') url: string, + ): Promise> { + const cam = await this.camService.findOne(url); + + if (cam) { + return ResponseEntity.ok(cam); + } else { + throw new NotFoundException(); + } + } } diff --git a/backend/src/cam/cam.gateway.ts b/backend/src/cam/cam.gateway.ts index b84b88a..bb646c5 100644 --- a/backend/src/cam/cam.gateway.ts +++ b/backend/src/cam/cam.gateway.ts @@ -38,20 +38,15 @@ export class CamGateway { client.data.userId = userId; client.on('disconnect', () => { + const { roomId, userId } = client.data; client.to(roomId).emit('userDisconnected', { userId }); + if (!client.data.roomId || !client.data.userId) return; this.camInnerService.exitRoom(roomId, userId); - }); - } - @SubscribeMessage('exitRoom') - handleExitRoom(client: Socket): void { - if (!client.data.roomId || !client.data.userId) return; - const { roomId, userId } = client.data; - client.to(roomId).emit('userDisconnected', { userId }); - client.leave(roomId); - this.camInnerService.exitRoom(roomId, userId); - client.data.roomId = null; - client.data.userId = null; + client.leave(roomId); + client.data.roomId = null; + client.data.userId = null; + }); } @SubscribeMessage('updateUserStatus') diff --git a/backend/src/cam/cam.service.ts b/backend/src/cam/cam.service.ts index e35f586..f494004 100644 --- a/backend/src/cam/cam.service.ts +++ b/backend/src/cam/cam.service.ts @@ -1,4 +1,9 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { + BadRequestException, + forwardRef, + Inject, + Injectable, +} from '@nestjs/common'; import { ServerRepository } from '../server/server.repository'; import { CreateCamDto, ResponseCamDto } from './cam.dto'; import { Cam } from './cam.entity'; @@ -11,11 +16,12 @@ export class CamService { constructor( private camRepository: CamRepository, private serverRepository: ServerRepository, + @Inject(forwardRef(() => CamInnerService)) private readonly camInnerService: CamInnerService, ) {} - findOne(id: number): Promise { - return this.camRepository.findOne({ id: id }, { relations: ['server'] }); + findOne(url: string): Promise { + return this.camRepository.findOne({ url: url }); } async createCam(cam: CreateCamDto): Promise { @@ -38,9 +44,8 @@ export class CamService { return savedCam; } - // cam의 exitRooms 로직과 연결이 필요함! - async deleteCam(id: number): Promise { - await this.camRepository.delete({ id: id }); + async deleteCam(url: string): Promise { + await this.camRepository.delete({ url: url }); } async getCamList(serverId: number): Promise { diff --git a/frontend/src/components/Cam/ButtonBar/ButtonBar.tsx b/frontend/src/components/Cam/ButtonBar/ButtonBar.tsx index 51d275e..aa1b0b9 100644 --- a/frontend/src/components/Cam/ButtonBar/ButtonBar.tsx +++ b/frontend/src/components/Cam/ButtonBar/ButtonBar.tsx @@ -109,7 +109,7 @@ function ButtonBar(props: ButtonBarProps): JSX.Element { }; const handleExit = () => { - window.location.href = '/camroom'; + window.history.back(); }; const handleMouseOverCamPage = (): void => { diff --git a/frontend/src/components/Cam/Cam.tsx b/frontend/src/components/Cam/Cam.tsx index f080e5a..b978940 100644 --- a/frontend/src/components/Cam/Cam.tsx +++ b/frontend/src/components/Cam/Cam.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; import styled from 'styled-components'; -import { useRecoilValue } from 'recoil'; import ButtonBar from './ButtonBar/ButtonBar'; import ChattingTab from './Chatting/ChattingTab'; @@ -9,7 +8,6 @@ import CamStore from './CamStore'; import UserListTab from './UserList/UserListTab'; import ToggleStore from './ToggleStore'; import { UserInfo } from '../../types/cam'; -import socketState from '../../atoms/socket'; import STTStore from './STT/STTStore'; import SharedScreenStore from './SharedScreen/SharedScreenStore'; import NickNameForm from './Nickname/NickNameForm'; @@ -38,41 +36,61 @@ const UpperTab = styled.div` function Cam(): JSX.Element { const [userInfo, setUserInfo] = useState({ roomId: null, nickname: null }); + const [isRoomExist, setIsRoomExist] = useState(false); - const socket = useRecoilValue(socketState); const camRef = useRef(null); + const checkRoomExist = async (roomId: string) => { + const response = await fetch(`api/cam/${roomId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + const json = await response.json(); + + if (json.statusCode === 200) { + setIsRoomExist(true); + } else { + setIsRoomExist(false); + } + }; + useEffect(() => { const roomId = new URLSearchParams(new URL(window.location.href).search).get('roomid'); + if (roomId) { + checkRoomExist(roomId); + } + setUserInfo((prev) => ({ ...prev, roomId })); - return () => { - socket.emit('exitRoom'); - socket.emit('changeRoomList'); - }; }, []); - return ( - - {!userInfo?.nickname ? ( - - ) : ( - - - - - - - - - - - - - - - )} - - ); + if (isRoomExist) { + return ( + + {!userInfo?.nickname ? ( + + ) : ( + + + + + + + + + + + + + + + )} + + ); + } + return
없는 방입니다~
; } export default Cam; diff --git a/frontend/src/components/Main/Cam/CreateCamModal.tsx b/frontend/src/components/Main/Cam/CreateCamModal.tsx index 94a8c3b..d586996 100644 --- a/frontend/src/components/Main/Cam/CreateCamModal.tsx +++ b/frontend/src/components/Main/Cam/CreateCamModal.tsx @@ -175,7 +175,7 @@ function CreateCamModal(): JSX.Element { watch, formState: { errors }, } = useForm(); - const { selectedServer, setIsCreateCamModalOpen } = useContext(MainStoreContext); + const { selectedServer, setIsCreateCamModalOpen, getServerCamList } = useContext(MainStoreContext); const [isButtonActive, setIsButtonActive] = useState(false); const onSubmitCreateCamModal = async (data: { name: string; description: string }) => { @@ -190,6 +190,7 @@ function CreateCamModal(): JSX.Element { serverId: selectedServer.server.id, }), }); + getServerCamList(); setIsCreateCamModalOpen(false); }; diff --git a/frontend/src/components/Main/MainStore.tsx b/frontend/src/components/Main/MainStore.tsx index ee436c8..b89932a 100644 --- a/frontend/src/components/Main/MainStore.tsx +++ b/frontend/src/components/Main/MainStore.tsx @@ -128,6 +128,7 @@ function MainStore(props: MainStoreProps): JSX.Element { setIsCreateCamModalOpen, setServerList, getUserServerList, + getServerCamList, }} > {children} From dc0443584bd4540da3b5c5902c9a46bd445c3f07 Mon Sep 17 00:00:00 2001 From: K Date: Wed, 24 Nov 2021 19:36:31 +0900 Subject: [PATCH 118/172] Feat : CommentDto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment를 표현하는 객체입니다. entity를 받아서 dto를 만드는 함수 또한 존재합니다. --- backend/src/comment/comment.dto.ts | 36 ++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 backend/src/comment/comment.dto.ts diff --git a/backend/src/comment/comment.dto.ts b/backend/src/comment/comment.dto.ts new file mode 100644 index 0000000..217db72 --- /dev/null +++ b/backend/src/comment/comment.dto.ts @@ -0,0 +1,36 @@ +import { UserDto } from '../user/user.dto'; +import { Comment } from './comment.entity'; + +export class CommentDto { + id: number; + contents: string; + createdAt: Date; + sender: UserDto; + messageId: number; + + static newInstance( + id: number, + contents: string, + createdAt: Date, + sender: UserDto, + messageId: number, + ) { + const newInstance = new CommentDto(); + newInstance.id = id; + newInstance.contents = contents; + newInstance.createdAt = createdAt; + newInstance.sender = sender; + newInstance.messageId = messageId; + return newInstance; + } + + static fromEntity(comment: Comment) { + return CommentDto.newInstance( + comment.id, + comment.contents, + comment.createdAt, + UserDto.fromEntity(comment.sender), + comment.messageId, + ); + } +} From 6f91b675466ffb2ca4015d86b3ca8dc8184d2bc3 Mon Sep 17 00:00:00 2001 From: K Date: Wed, 24 Nov 2021 19:56:00 +0900 Subject: [PATCH 119/172] =?UTF-8?q?Refactor=20:=20UserServerService?= =?UTF-8?q?=EB=A1=9C=20=EC=B1=84=EB=84=90=EC=97=90=20=EC=A0=91=EA=B7=BC=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EC=9E=88=EB=8A=94=EC=A7=80=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 자주 사용할 로직이라 UserServerService로 옮기고, 해당 서비스에서 exception 발생하도록 수정하였습니다. --- backend/src/message/message.service.ts | 17 +++-------------- backend/src/user-server/user-server.service.ts | 11 ++++++++++- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/backend/src/message/message.service.ts b/backend/src/message/message.service.ts index c7312f5..4f46c25 100644 --- a/backend/src/message/message.service.ts +++ b/backend/src/message/message.service.ts @@ -1,4 +1,4 @@ -import { ForbiddenException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Channel } from '../channel/channel.entity'; @@ -22,7 +22,7 @@ export class MessageService { channelId: number, contents: string, ): Promise { - await this.checkUserChannelAccess(senderId, channelId); + await this.userServerService.checkUserChannelAccess(senderId, channelId); const sender = await this.userRepository.findOne(senderId); const channel = await this.channelReposiotry.findOne(channelId); @@ -34,19 +34,8 @@ export class MessageService { } async findMessagesByChannelId(senderId: number, channelId: number) { - await this.checkUserChannelAccess(senderId, channelId); + await this.userServerService.checkUserChannelAccess(senderId, channelId); const messages = await this.messageRepository.findByChannelId(channelId); return messages.map(MessageDto.fromEntity); } - - private async checkUserChannelAccess(senderId: number, channelId: number) { - const userServer = await this.userServerService.userCanAccessChannel( - senderId, - channelId, - ); - - if (!userServer) { - throw new ForbiddenException('서버나 채널에 참여하지 않았습니다.'); - } - } } diff --git a/backend/src/user-server/user-server.service.ts b/backend/src/user-server/user-server.service.ts index 067413f..cd511aa 100644 --- a/backend/src/user-server/user-server.service.ts +++ b/backend/src/user-server/user-server.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ForbiddenException, forwardRef, Inject, Injectable, @@ -70,7 +71,15 @@ export class UserServerService { return userServerList; } - async userCanAccessChannel(userId: number, channelId: number) { + async checkUserChannelAccess(senderId: number, channelId: number) { + const userServer = await this.userCanAccessChannel(senderId, channelId); + + if (!userServer) { + throw new ForbiddenException('서버나 채널에 참여하지 않았습니다.'); + } + } + + private async userCanAccessChannel(userId: number, channelId: number) { return await this.userServerRepository.userCanAccessChannel( userId, channelId, From 385764514d597028093f9d4858ab3973ec88df90 Mon Sep 17 00:00:00 2001 From: K Date: Wed, 24 Nov 2021 20:00:08 +0900 Subject: [PATCH 120/172] Feat : CommentRepository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MessageRepository와 비슷한 구조이기 때문에 추가하였습니다. --- backend/src/comment/comment.repository.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 backend/src/comment/comment.repository.ts diff --git a/backend/src/comment/comment.repository.ts b/backend/src/comment/comment.repository.ts new file mode 100644 index 0000000..2ced5ee --- /dev/null +++ b/backend/src/comment/comment.repository.ts @@ -0,0 +1,12 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { Comment } from './comment.entity'; + +@EntityRepository(Comment) +export class CommentRepository extends Repository { + async findByMessageId(messageId: number): Promise { + return this.createQueryBuilder('comment') + .innerJoinAndSelect('comment.sender', 'user') + .where('comment.messageId = :messageId', { messageId }) + .getMany(); + } +} From 911f66aca0ed764ca22ef744b123a40fb8bca5b0 Mon Sep 17 00:00:00 2001 From: K Date: Wed, 24 Nov 2021 20:02:40 +0900 Subject: [PATCH 121/172] Feat : CommentService sendComment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 해당 메시지가 존재하는지, User가 Channel에 접근 가능한지를 확인한 후 코멘트 를 보냅니다. --- backend/src/comment/comment.service.ts | 41 ++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 backend/src/comment/comment.service.ts diff --git a/backend/src/comment/comment.service.ts b/backend/src/comment/comment.service.ts new file mode 100644 index 0000000..628433f --- /dev/null +++ b/backend/src/comment/comment.service.ts @@ -0,0 +1,41 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { MessageRepository } from '../message/message.repository'; +import { UserServerService } from '../user-server/user-server.service'; +import { User } from '../user/user.entity'; +import { CommentDto } from './comment.dto'; +import { Comment } from './comment.entity'; +import { CommentRepository } from './comment.repository'; + +@Injectable() +export class CommentService { + constructor( + private commentRepository: CommentRepository, + private messageRepository: MessageRepository, + private readonly userServerService: UserServerService, + @InjectRepository(User) private userRepository: Repository, + ) {} + + async sendComment( + senderId: number, + channelId: number, + messageId: number, + contents: string, + ): Promise { + const sender = await this.userRepository.findOne(senderId); + const message = await this.messageRepository.findOne(messageId); + + if (!message) { + throw new NotFoundException('메시지가 존재하지 않습니다.'); + } + + await this.userServerService.checkUserChannelAccess(senderId, channelId); + + const newComment = await this.commentRepository.save( + Comment.newInstance(sender, message, contents), + ); + + return CommentDto.fromEntity(newComment); + } +} From df1927bbebcdc562ca7b1089a8dbbe2ec107e92d Mon Sep 17 00:00:00 2001 From: K Date: Wed, 24 Nov 2021 20:14:27 +0900 Subject: [PATCH 122/172] Feat : CommentController sendComment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코멘트를 만들기 위한 endpoint --- backend/src/comment/comment.controller.ts | 27 +++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 backend/src/comment/comment.controller.ts diff --git a/backend/src/comment/comment.controller.ts b/backend/src/comment/comment.controller.ts new file mode 100644 index 0000000..0c5b9bb --- /dev/null +++ b/backend/src/comment/comment.controller.ts @@ -0,0 +1,27 @@ +import { Body, Controller, Post, Session } from '@nestjs/common'; +import ResponseEntity from '../common/response-entity'; +import { ExpressSession } from '../types/session'; +import { CommentDto } from './comment.dto'; +import { CommentService } from './comment.service'; + +@Controller('/api/comments') +export class CommentController { + constructor(private commentService: CommentService) {} + + @Post() + async sendComment( + @Session() session: ExpressSession, + @Body('channelId') channelId: number, + @Body('messageId') messageId: number, + @Body('contents') contents: string, + ): Promise> { + const sender = session.user; + const newComment = await this.commentService.sendComment( + sender.id, + channelId, + messageId, + contents, + ); + return ResponseEntity.ok(newComment); + } +} From 0fda7940bc152b81df9abbb0e3eb8e36501349a8 Mon Sep 17 00:00:00 2001 From: K Date: Wed, 24 Nov 2021 20:16:21 +0900 Subject: [PATCH 123/172] Feat : CommentModule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit comment 관련 controller, service, repository를 사용하기 위한 모듈 --- backend/src/comment/comment.module.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/backend/src/comment/comment.module.ts b/backend/src/comment/comment.module.ts index 0ced790..9e98f7b 100644 --- a/backend/src/comment/comment.module.ts +++ b/backend/src/comment/comment.module.ts @@ -1,10 +1,25 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Comment } from './comment.entity'; +import { CommentRepository } from './comment.repository'; +import { CommentService } from './comment.service'; +import { CommentController } from './comment.controller'; +import { MessageRepository } from '../message/message.repository'; +import { User } from '../user/user.entity'; +import { UserServerModule } from '../user-server/user-server.module'; @Module({ - imports: [TypeOrmModule.forFeature([Comment])], - providers: [], - controllers: [], + imports: [ + TypeOrmModule.forFeature([ + Comment, + CommentRepository, + MessageRepository, + User, + ]), + UserServerModule, + ], + providers: [CommentService], + controllers: [CommentController], + exports: [CommentService], }) export class CommentModule {} From a0a9fbde463d925d41e9ab6810e1c9ca02da2a6f Mon Sep 17 00:00:00 2001 From: K Date: Wed, 24 Nov 2021 20:44:10 +0900 Subject: [PATCH 124/172] Feat : CommentService.findCommentsByMessageId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit messageId를 이용하여 comments 목록을 가져옵니다. --- backend/src/comment/comment.service.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/src/comment/comment.service.ts b/backend/src/comment/comment.service.ts index 628433f..c54955a 100644 --- a/backend/src/comment/comment.service.ts +++ b/backend/src/comment/comment.service.ts @@ -38,4 +38,9 @@ export class CommentService { return CommentDto.fromEntity(newComment); } + + async findCommentsByMessageId(messageId: number) { + const comments = await this.commentRepository.findByMessageId(messageId); + return comments.map(CommentDto.fromEntity); + } } From a45d35c98e8bab1e11fb0bce1af3e5f9c9a645bf Mon Sep 17 00:00:00 2001 From: K Date: Wed, 24 Nov 2021 20:44:52 +0900 Subject: [PATCH 125/172] Feat : CommentController findCommentsByMessageId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/comments?messageId=?? 로 특정 메시지의 comments를 가져옵니다. 아직 권한 확인은 하지 않습니다. --- backend/src/comment/comment.controller.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/src/comment/comment.controller.ts b/backend/src/comment/comment.controller.ts index 0c5b9bb..c8b5468 100644 --- a/backend/src/comment/comment.controller.ts +++ b/backend/src/comment/comment.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Post, Session } from '@nestjs/common'; +import { Body, Controller, Get, Post, Query, Session } from '@nestjs/common'; import ResponseEntity from '../common/response-entity'; import { ExpressSession } from '../types/session'; import { CommentDto } from './comment.dto'; @@ -24,4 +24,12 @@ export class CommentController { ); return ResponseEntity.ok(newComment); } + + @Get() + async findCommentsByMessageId(@Query('messageId') messageId: number) { + const comments = await this.commentService.findCommentsByMessageId( + messageId, + ); + return ResponseEntity.ok(comments); + } } From e7cdca0cb40e14c8e34849b51784b07654513ada Mon Sep 17 00:00:00 2001 From: korung3195 Date: Wed, 24 Nov 2021 22:43:30 +0900 Subject: [PATCH 126/172] =?UTF-8?q?Refactor=20:=20cam=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=20import=20=EC=BB=A8=EB=B2=A4=EC=85=98=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 컨벤션에 맞게 외부 라이브러리 먼저 import하고, 한 줄의 공백 후에 내부 모듈을 import하는 방식으로 수정하였습니다. --- backend/src/cam/cam.controller.ts | 2 +- backend/src/cam/cam.entity.ts | 1 + backend/src/cam/cam.module.ts | 3 ++- backend/src/cam/cam.repository.ts | 1 + backend/src/cam/cam.service.ts | 3 ++- 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/backend/src/cam/cam.controller.ts b/backend/src/cam/cam.controller.ts index 8749973..4b3b7be 100644 --- a/backend/src/cam/cam.controller.ts +++ b/backend/src/cam/cam.controller.ts @@ -6,8 +6,8 @@ import { Param, NotFoundException, } from '@nestjs/common'; -import ResponseEntity from '../common/response-entity'; +import ResponseEntity from '../common/response-entity'; import { CreateCamDto } from './cam.dto'; import { Cam } from './cam.entity'; import { CamService } from './cam.service'; diff --git a/backend/src/cam/cam.entity.ts b/backend/src/cam/cam.entity.ts index 5d1b734..96fcc99 100644 --- a/backend/src/cam/cam.entity.ts +++ b/backend/src/cam/cam.entity.ts @@ -5,6 +5,7 @@ import { ManyToOne, RelationId, } from 'typeorm'; + import { Server } from '../server/server.entity'; @Entity() diff --git a/backend/src/cam/cam.module.ts b/backend/src/cam/cam.module.ts index 604b398..2e797da 100644 --- a/backend/src/cam/cam.module.ts +++ b/backend/src/cam/cam.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + import { CamGateway } from './cam.gateway'; import { CamInnerService } from './cam-inner.service'; import { CamController } from './cam.controller'; import { CamService } from './cam.service'; -import { TypeOrmModule } from '@nestjs/typeorm'; import { Cam } from './cam.entity'; import { ServerRepository } from '../server/server.repository'; import { CamRepository } from './cam.repository'; diff --git a/backend/src/cam/cam.repository.ts b/backend/src/cam/cam.repository.ts index ddab237..2fd8b62 100644 --- a/backend/src/cam/cam.repository.ts +++ b/backend/src/cam/cam.repository.ts @@ -1,4 +1,5 @@ import { EntityRepository, Repository } from 'typeorm'; + import { Cam } from './cam.entity'; @EntityRepository(Cam) diff --git a/backend/src/cam/cam.service.ts b/backend/src/cam/cam.service.ts index f494004..55c1aa8 100644 --- a/backend/src/cam/cam.service.ts +++ b/backend/src/cam/cam.service.ts @@ -4,11 +4,12 @@ import { Inject, Injectable, } from '@nestjs/common'; +import { v4 } from 'uuid'; + import { ServerRepository } from '../server/server.repository'; import { CreateCamDto, ResponseCamDto } from './cam.dto'; import { Cam } from './cam.entity'; import { CamRepository } from './cam.repository'; -import { v4 } from 'uuid'; import { CamInnerService } from './cam-inner.service'; @Injectable() From b6cee86bd4a45511933be4862dde01976fb266c0 Mon Sep 17 00:00:00 2001 From: korung3195 Date: Wed, 24 Nov 2021 22:49:59 +0900 Subject: [PATCH 127/172] =?UTF-8?q?Feat=20:=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=EB=A5=BC=20=EC=8B=9C=EC=9E=91=ED=95=A0=20=EB=95=8C=20camReposi?= =?UTF-8?q?tory=EB=A5=BC=20=EC=B4=88=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit camRepository는 인스턴스 형식이므로 서버를 켤 때 초기화하도록 구현하였습니다. --- backend/src/cam/cam.service.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/src/cam/cam.service.ts b/backend/src/cam/cam.service.ts index 55c1aa8..b0a8ac9 100644 --- a/backend/src/cam/cam.service.ts +++ b/backend/src/cam/cam.service.ts @@ -19,7 +19,9 @@ export class CamService { private serverRepository: ServerRepository, @Inject(forwardRef(() => CamInnerService)) private readonly camInnerService: CamInnerService, - ) {} + ) { + this.camRepository.clear(); + } findOne(url: string): Promise { return this.camRepository.findOne({ url: url }); From 7c73bb06466beec2c0177e19324c86c38fac8a93 Mon Sep 17 00:00:00 2001 From: korung3195 Date: Wed, 24 Nov 2021 23:26:59 +0900 Subject: [PATCH 128/172] =?UTF-8?q?Fix=20:=20CamRoom=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CamRoom은 더 이상 사용되지 않으므로 CamRoom 모듈을 삭제하고, 관련 로직을 삭제하였습니다. --- backend/src/cam/cam.gateway.ts | 7 - frontend/src/App.tsx | 2 - frontend/src/components/Main/CamRooms.tsx | 201 ---------------------- 3 files changed, 210 deletions(-) delete mode 100644 frontend/src/components/Main/CamRooms.tsx diff --git a/backend/src/cam/cam.gateway.ts b/backend/src/cam/cam.gateway.ts index bb646c5..49221b9 100644 --- a/backend/src/cam/cam.gateway.ts +++ b/backend/src/cam/cam.gateway.ts @@ -127,13 +127,6 @@ export class CamGateway { .emit('receiveMessage', { payload, nicknameInfo }); } - @SubscribeMessage('changeRoomList') - handleChangeRoomList(): void { - const roomList = this.camInnerService.getRoomList(); - const roomListJson = JSON.stringify(Array.from(roomList.entries())); - this.server.emit('getRoomList', roomListJson); - } - @SubscribeMessage('changeNickname') handleChangeNickname( client: Socket, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f2be6e3..9d6662f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,7 +4,6 @@ import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; import LoginMain from './components/LoginPage/LoginMain'; import Cam from './components/Cam/Cam'; -import CamRooms from './components/Main/CamRooms'; import BoostCamMain from './components/Main/BoostCamMain'; import LoginCallback from './components/LoginPage/LoginCallback'; @@ -17,7 +16,6 @@ function App(): JSX.Element { } /> } /> } /> - } /> diff --git a/frontend/src/components/Main/CamRooms.tsx b/frontend/src/components/Main/CamRooms.tsx deleted file mode 100644 index 2a5f250..0000000 --- a/frontend/src/components/Main/CamRooms.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import styled from 'styled-components'; -import { useRecoilValue } from 'recoil'; -import { useNavigate } from 'react-router-dom'; - -import { Status } from '../../types/cam'; -import socketState from '../../atoms/socket'; - -const Container = styled.div` - width: 100vw; - height: 100vh; - background-color: #009b9f; - - display: flex; - justify-content: space-around; - align-items: center; -`; - -const MainBox = styled.div` - min-width: 600px; - min-height: 450px; - width: 50%; - height: 50%; - border: 2px solid #12cdd1; - border-radius: 20px; - - display: flex; - flex-direction: column; - align-items: center; -`; - -const ListDiv = styled.div` - display: flex; - justify-content: center; - flex-wrap: wrap; -`; - -const RoomDiv = styled.div` - width: 200px; - padding: 10px 15px; - border: 2px solid #12cdd1; - border-radius: 10px; - margin: 10px; - cursor: pointer; - - &:hover { - background-color: #12cdd1; - transition: all 0.3s; - } -`; - -const Form = styled.form` - width: 50%; - height: 45%; - border-radius: 20px; - padding: 20px 0; - margin: 30px 0; - - display: flex; - flex-direction: column; - justify-content: space-around; - align-items: center; -`; - -const BoxTag = styled.span` - font-size: 25px; -`; - -const InputDiv = styled.div` - width: 60%; - display: flex; - flex-direction: column; - align-items: center; - &:last-child { - margin-top: 15px; - } -`; - -const Input = styled.input` - border: none; - outline: none; - padding: 8px 10px; - margin-top: 10px; - border-radius: 10px; -`; - -const SubmitButton = styled.button` - width: 60%; - margin-top: 15px; - height: 35px; - background: none; - - border: 0; - outline: 0; - - border-radius: 10px; - border: 2px solid #12cdd1; - cursor: pointer; - text-align: center; - transition: all 0.3s; - - &:hover { - background-color: #12cdd1; - transition: all 0.3s; - } - - a { - text-decoration: none; - } -`; - -type MapInfo = { - userId: string; - status: Status; -}; - -function CamRooms(): JSX.Element { - const socket = useRecoilValue(socketState); - const [roomList, setRoomList] = useState(); - const navigate = useNavigate(); - - const onSumbitCreateForm = async (e: React.FormEvent): Promise => { - e.preventDefault(); - const roomId = new FormData(e.currentTarget).get('roomid')?.toString().trim(); - if (!roomId) { - return; - } - - const response = await fetch('/api/cam/room/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - roomid: roomId, - }), - }); - - socket.emit('changeRoomList'); - - const { statusCode } = await response.json(); - if (statusCode === 201) navigate(`/cam?roomid=${roomId}`); - // eslint-disable-next-line no-alert - else if (statusCode === 500) alert('이미 존재하는 방 입니다.'); - }; - - const onClickRoomDiv = (e: React.MouseEvent): void => { - const { currentTarget } = e; - const roomId = currentTarget.dataset.id; - navigate(`/cam?roomid=${roomId}`); - }; - - const buildRoomList = (receivedRoomList: []) => { - const roomListJSX = receivedRoomList.map((val: [string, MapInfo[]]): JSX.Element => { - const roomId = val[0]; - const roomParticipant = val[1].length; - return ( - - Room Id : {roomId} -
- User : {roomParticipant} -
- ); - }); - - setRoomList(roomListJSX); - }; - - const receiveRoomList = async (): Promise => { - const response = await fetch('/api/cam/roomlist/'); - const { data } = await response.json(); - const { roomListJson } = data; - - const receivedRoomList = JSON.parse(roomListJson); - buildRoomList(receivedRoomList); - }; - - useEffect(() => { - socket.on('getRoomList', (receivedRoomList) => { - buildRoomList(JSON.parse(receivedRoomList)); - }); - receiveRoomList(); - }, []); - - return ( - - - - Create Room - - - - Create - - {roomList} - - - ); -} - -export default CamRooms; From b91db018920f22aa05084e023761833890436ba3 Mon Sep 17 00:00:00 2001 From: korung3195 Date: Wed, 24 Nov 2021 23:27:23 +0900 Subject: [PATCH 129/172] =?UTF-8?q?Chore=20:=20public=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cam 입장 시 배경으로 사용할 static 파일을 추가하였습니다. --- frontend/public/nickname-form.jpg | Bin 0 -> 28555 bytes frontend/public/not-found.jpg | Bin 0 -> 44535 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 frontend/public/nickname-form.jpg create mode 100644 frontend/public/not-found.jpg diff --git a/frontend/public/nickname-form.jpg b/frontend/public/nickname-form.jpg new file mode 100644 index 0000000000000000000000000000000000000000..75a64255fe483ab510b1ae334db94d534f465a35 GIT binary patch literal 28555 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 TKq4fUG$j5nN=VS)EF literal 0 HcmV?d00001 diff --git a/frontend/public/not-found.jpg b/frontend/public/not-found.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8173feb97f58fe214fb702f57c49baf31cb0b2ad GIT binary patch literal 44535 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

2tXM(H?)jf$eO`yoU^2A~lLecpX9nfg z*KtDDqeTymUeNK`B9eW}VdI*1v6Ib6m~GQ(X}QBJn^<3CR_Br!8!H<*6BOv?!gdwW z_TMyCHrsHDb&<tW8SS+LC*^!aVamt@#}ttsJ+7efyzb%^KZ{qa_q}OLwyQcPv<>(($KFGq%$=_N%z& z2B%Hd1nWQxo&`VZW9x^6Q>jt>hM^dYpVS#cI(FO33@62gzc~tKt*#9cl6?4&E`;Hg z*H!UR)g<={t4eCvf&OB{_0FPBxTv{d^7&3FX+^88kAfFT{q0EBKK_{K*9j^P_#*w0 z)bygp0fx8zWg`Fh)cbiepE#l5?PluF_#fJpD$ggKbu@|#&2AiIK2Zy`wF7^j>LYX# zxr1bm#9mp13qQjXP{m^xW#yRe>CVY@1~ zY%&?asb-(b%;y&k?!?h-c+S`nz#px%M%k(Tkp=JiPZJ8sP`En5h3&lWqr$jbhU zo52jr-h`gb$-Q44QyBmRaIB(JwS!f3>wI&yHM!T?2;eIN?3M+!>eF90XbOFld@Em$ zP;)G&9)9uaxHcRrGBiDcdjJiEcrq(&?m7xQZ`^bS+WfFGeeOx5 z!M+-+Re|>p=jdNSVcwl=le=2NOQ1BMvw)_mU;rRsTnR$c9#QVT%vAL@fxz0Oq6^8*P>hu!XmRcNykWi98*! z-T2S7;tHrguepqXpc@%%;Pf`JdJbOk2Pn-0vHT<^vdN|a(U%uRrf3u_>GU3>`Qb0X zZRButfB%3bHFn$HSs}(&qm4~EFvoESuBmu$eaLBdD1kM7Cs^s(q%E3cA!Ga738TS} z|$VG_?K+MJOcxBe-8sILhXyJW=(ZigqQr}9|ndm zhYSp_WZ&}=B())AUm_|S+b+7s(nL_3LT5q4f!K(2i7A);5gLc~NxufdXKPvN7*h=4evT};REviW(5Ds;e5a`3=ChCMe%4JP@zK&7r* zNg=0RS{DieVcN;x6@y!rRoNOUc(IKVJ${X$)2mP%_;U!bW1N(E-eEks+P~0@j?_(~@R#ws1*!Oc5c3?)7X6!UPea!aoCY@uh7nyfqVWq`kVVL0qR)5~f zik~bDcbH$`KE@3b5;b?`$H#&cYI~)ymx#4i}Kscjkk8x$}t+xcIScX%agUL^u8u^bkJ9s!Zc)%Gl%SsBCwhFeD}j~eT?%TiCnLZ8ucuOW>> z<^Z=jF6_9zlfdCNjUiClpvG0y>apKmWogr$n~dLf`27-4LJ!OiTFoT0%X}fRz94A&wwbZ8TIf;V?i`h&+vF$2 zz4!gIFQ-6R&I3_}k+`qrM@Ac(40YvsAg+qEZxkgc#9CZ@a+D_VB?5`*lUrgAX!7G* z_j$x>wM(-~n>W_vZ=7)u^-b5raTl8Y{$us}#CV2IVah*GWlWsu(U(3iDzs=#j;< zjfzkTzO6f4SRZKDDjVBRFZ+dU*J3HgUC$?t`Zjl^GY?O7i57C;OQclH$b$5py0!_I zQr4e(v!-4fOW0z`z2rriNqyQN-?n2-$Rt{Jw@0RLK+ygksy~BN-0VEUzi0trWQoW= zj+hEHuurCYkFin6T6fJBq%7cm9acd17od0Z=F;nor71gUQG_NUBu2MHzF>xKZTK(X zx@*vlGFuPoG7$9GbgE(dkhL6WWF|}t_bWCm2h*s+XEXn7|p4~?I& z9SJo`?+S|@W|a2XhycOd8`=a}S^CDpiaotZ@(<1|kgoF}hnd|)FAqSwmLqGtJG zZBl<-;yxtQBiH%x|D4=UDB=HbRc$!*7obvbz7??EGD4u$Z;T?m>@9z%fu3n$0M5hM(ZS5hIvK8)0Ci!q-v1QCP|yrpV?O_dHZ|eT1xT!$+%C97{4h| z53ak_q&dyg1x`=W_$XwO8+(#}g*vSNe7HWZ{=x7TV&MzOVAQtC~_0Ta3O zFetWwVx|q~&&`SObAdJV>=0!82r&RPYPL$<6GF0GN!1IuAtZdC`=66K&t#gdXje9? zMMG4Ks#M*JVn#;baK2Ah9H+KF_6r*uSp1Zy2l+uNm+>w7)_e*bOt|M?#@$i7Y77?} zaP#oWc)`XzoRH7rB) zH@~F!wL_!LsfZuIW5I~4^sj*~?MEf;HTi%U3mbI1x2C5*OT7?=q_yCr=MW-g;fbj9vcKb>IRa-v$=pynlMH2wt)st{^;juv; z%_@nGdYOMZe7NMi{`eyI%U{4G76L5S3H<(=nngn9o!&rEXm+~v?YCVmw;uP4iX-&b*{_D^Ge&-UXS;oAGU zU3sNd#Tc}ti=Rby3ogBf1 z{08T=A`+=QcKE%767Jw3Xum1CQ0QBY*wwdbxoPUCIKfV#hLbG%)lCva zdaV_l+gvCO*n;k);)*TW+R~eC8^4~rXsqi*qsV#vJ43Hr$zknj^CSww3TvOeuhGFb zZQu4P{FypC?28&FE!nQTZ)KwzPBa%(e;MP$Y8%SHsQj^=h|XQvvN=�+}(09yq(m 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 S3}aa) Date: Wed, 24 Nov 2021 23:27:54 +0900 Subject: [PATCH 130/172] =?UTF-8?q?Refactor=20:=20Modal=EC=9D=84=20?= =?UTF-8?q?=ED=95=98=EB=82=98=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존에 존재하던 Modal들 중에서 내부 ModalBox 요소들만 남기고 MainPage에서 하나의 MainModal 만 출력하도록 변경하였습니다. - MainStore 의 modalContents 상태에 원하는 컴포넌트를 지정하면 MainModal 에서 이를 출력합니다. --- .../src/components/Main/Cam/CamListHeader.tsx | 5 +- .../components/Main/Cam/CreateCamModal.tsx | 76 ++++------ .../src/components/Main/ChannelListHeader.tsx | 17 +-- .../src/components/Main/ChannelListItem.tsx | 18 +-- .../Main/ChannelModal/CreateChannelModal.tsx | 100 +++++-------- .../Main/ChannelModal/JoinChannelModal.tsx | 52 ++----- .../Main/ChannelModal/QuitChannelModal .tsx | 58 +++----- .../Main/ChannelModal/UpdateChannelModal.tsx | 92 +++++------- frontend/src/components/Main/MainHeader.tsx | 22 +-- frontend/src/components/Main/MainModal.tsx | 38 +++++ frontend/src/components/Main/MainPage.tsx | 25 +--- frontend/src/components/Main/MainStore.tsx | 39 +---- .../src/components/Main/ServerListTab.tsx | 19 +-- .../Main/ServerModal/CreateServerModal.tsx | 112 ++++++--------- .../Main/ServerModal/JoinServerModal.tsx | 78 ++++------ .../Main/ServerModal/QuitServerModal.tsx | 58 +++----- .../Main/ServerModal/ServerInfoModal.tsx | 86 ++++------- .../Main/ServerModal/ServerSettingModal.tsx | 134 +++++++----------- frontend/src/components/core/DropdownMenu.tsx | 14 +- 19 files changed, 383 insertions(+), 660 deletions(-) create mode 100644 frontend/src/components/Main/MainModal.tsx diff --git a/frontend/src/components/Main/Cam/CamListHeader.tsx b/frontend/src/components/Main/Cam/CamListHeader.tsx index 8a768af..b615bd1 100644 --- a/frontend/src/components/Main/Cam/CamListHeader.tsx +++ b/frontend/src/components/Main/Cam/CamListHeader.tsx @@ -5,6 +5,7 @@ import { BoostCamMainIcons } from '../../../utils/SvgIcons'; import { MainStoreContext } from '../MainStore'; import Dropdown from '../../core/Dropdown'; import DropdownMenu from '../../core/DropdownMenu'; +import CreateCamModal from './CreateCamModal'; const { Plus, ListArrow } = BoostCamMainIcons; @@ -58,7 +59,6 @@ function CamListHeader(props: CamListHeaderProps): JSX.Element { const [isButtonVisible, setIsButtonVisible] = useState(false); const [isDropdownActivated, setIsDropdownActivated] = useState(false); const { isListOpen, setIsListOpen } = props; - const { isCreateCamModalOpen, setIsCreateCamModalOpen } = useContext(MainStoreContext); const onClickCamAddButton = (e: React.MouseEvent) => { e.stopPropagation(); @@ -79,8 +79,7 @@ function CamListHeader(props: CamListHeaderProps): JSX.Element { } /> diff --git a/frontend/src/components/Main/Cam/CreateCamModal.tsx b/frontend/src/components/Main/Cam/CreateCamModal.tsx index 1cd785e..7e47c24 100644 --- a/frontend/src/components/Main/Cam/CreateCamModal.tsx +++ b/frontend/src/components/Main/Cam/CreateCamModal.tsx @@ -8,27 +8,6 @@ import { BoostCamMainIcons } from '../../../utils/SvgIcons'; const { Close } = BoostCamMainIcons; const Container = styled.div` - position: fixed; - width: 100vw; - height: 100vh; - left: 0px; - right: 0px; - - display: flex; - justify-content: space-around; - align-items: center; -`; - -const ModalBackground = styled.div` - position: fixed; - left: 0px; - right: 0px; - width: 100%; - height: 100%; - background-color: rgb(0, 0, 0, 0.5); -`; - -const ModalBox = styled.div` width: 35%; min-width: 400px; height: 50%; @@ -175,7 +154,7 @@ function CreateCamModal(): JSX.Element { watch, formState: { errors }, } = useForm(); - const { selectedServer, setIsCreateCamModalOpen } = useContext(MainStoreContext); + const { selectedServer, setIsModalOpen } = useContext(MainStoreContext); const [isButtonActive, setIsButtonActive] = useState(false); const onSubmitCreateCamModal = async (data: { name: string; description: string }) => { @@ -190,7 +169,7 @@ function CreateCamModal(): JSX.Element { serverId: selectedServer.server.id, }), }); - setIsCreateCamModalOpen(false); + setIsModalOpen(false); }; useEffect(() => { @@ -202,33 +181,30 @@ function CreateCamModal(): JSX.Element { /* eslint-disable react/jsx-props-no-spreading */ return ( - setIsCreateCamModalOpen(false)} /> - - - - Cam 생성 - setIsCreateCamModalOpen(false)}> - - - - 생성할 Cam의 이름을 작성해주세요 -

- - 이름 - value.trim().length > 2 || '"이름" 칸은 3글자 이상 입력되어야합니다!', - })} - placeholder="Cam명을 입력해주세요" - /> - {errors.name && {errors.name.message}} - - - 생성 - -
- - + + + Cam 생성 + setIsModalOpen(false)}> + + + + 생성할 Cam의 이름을 작성해주세요 +
+ + 이름 + value.trim().length > 2 || '"이름" 칸은 3글자 이상 입력되어야합니다!', + })} + placeholder="Cam명을 입력해주세요" + /> + {errors.name && {errors.name.message}} + + + 생성 + +
+
); } diff --git a/frontend/src/components/Main/ChannelListHeader.tsx b/frontend/src/components/Main/ChannelListHeader.tsx index 4df1a0a..8e4ef42 100644 --- a/frontend/src/components/Main/ChannelListHeader.tsx +++ b/frontend/src/components/Main/ChannelListHeader.tsx @@ -6,6 +6,8 @@ import { BoostCamMainIcons } from '../../utils/SvgIcons'; import { MainStoreContext } from './MainStore'; import Dropdown from '../core/Dropdown'; import DropdownMenu from '../core/DropdownMenu'; +import CreateChannelModal from './ChannelModal/CreateChannelModal'; +import JoinChannelModal from './ChannelModal/JoinChannelModal'; const { Plus, ListArrow } = BoostCamMainIcons; @@ -59,14 +61,7 @@ function ChannelListHeader(props: ChannelListHeaderProps): JSX.Element { const [isButtonVisible, setIsButtonVisible] = useState(false); const [isDropdownActivated, setIsDropdownActivated] = useState(false); const { isListOpen, setIsListOpen } = props; - const { - selectedServer, - selectedChannel, - isCreateChannelModalOpen, - isJoinChannelModalOpen, - setIsCreateChannelModalOpen, - setIsJoinChannelModalOpen, - } = useContext(MainStoreContext); + const { selectedServer, selectedChannel } = useContext(MainStoreContext); const navigate = useNavigate(); const onClickChannelAddButton = (e: React.MouseEvent) => { @@ -99,14 +94,12 @@ function ChannelListHeader(props: ChannelListHeaderProps): JSX.Element { } /> } /> diff --git a/frontend/src/components/Main/ChannelListItem.tsx b/frontend/src/components/Main/ChannelListItem.tsx index 3358534..2bca344 100644 --- a/frontend/src/components/Main/ChannelListItem.tsx +++ b/frontend/src/components/Main/ChannelListItem.tsx @@ -5,6 +5,8 @@ import { BoostCamMainIcons } from '../../utils/SvgIcons'; import { MainStoreContext } from './MainStore'; import Dropdown from '../core/Dropdown'; import DropdownMenu from '../core/DropdownMenu'; +import UpdateChannelModal from './ChannelModal/UpdateChannelModal'; +import QuitChannelModal from './ChannelModal/QuitChannelModal '; const { Hash } = BoostCamMainIcons; @@ -57,15 +59,7 @@ type ChannelListItemProps = { }; function ChannelListItem(props: ChannelListItemProps): JSX.Element { - const { - setSelectedChannel, - isUpdateChannelModalOpen, - isQuitChannelModalOpen, - setIsUpdateChannelModalOpen, - setIsQuitChannelModalOpen, - setRightClickedChannelId, - setRightClickedChannelName, - } = useContext(MainStoreContext); + const { setSelectedChannel, setRightClickedChannelId, setRightClickedChannelName } = useContext(MainStoreContext); const [isDropdownActivated, setIsDropdownActivated] = useState(false); const { dataId, selected, name } = props; @@ -96,15 +90,13 @@ function ChannelListItem(props: ChannelListItemProps): JSX.Element { } /> } /> diff --git a/frontend/src/components/Main/ChannelModal/CreateChannelModal.tsx b/frontend/src/components/Main/ChannelModal/CreateChannelModal.tsx index 1b39487..1c47454 100644 --- a/frontend/src/components/Main/ChannelModal/CreateChannelModal.tsx +++ b/frontend/src/components/Main/ChannelModal/CreateChannelModal.tsx @@ -8,27 +8,6 @@ import { BoostCamMainIcons } from '../../../utils/SvgIcons'; const { Close } = BoostCamMainIcons; const Container = styled.div` - position: fixed; - width: 100vw; - height: 100vh; - left: 0px; - right: 0px; - - display: flex; - justify-content: space-around; - align-items: center; -`; - -const ModalBackground = styled.div` - position: fixed; - left: 0px; - right: 0px; - width: 100%; - height: 100%; - background-color: rgb(0, 0, 0, 0.5); -`; - -const ModalBox = styled.div` width: 35%; min-width: 400px; height: 50%; @@ -168,14 +147,14 @@ type CreateModalForm = { description: string; }; -function CreateChannelModal(): JSX.Element { +function CreateChannelModal2(): JSX.Element { const { register, handleSubmit, watch, formState: { errors }, } = useForm(); - const { selectedServer, setIsCreateChannelModalOpen, getServerChannelList } = useContext(MainStoreContext); + const { selectedServer, setIsModalOpen, getServerChannelList } = useContext(MainStoreContext); const [isButtonActive, setIsButtonActive] = useState(false); const onSubmitCreateChannelModal = async (data: { name: string; description: string }) => { @@ -192,7 +171,7 @@ function CreateChannelModal(): JSX.Element { }), }); getServerChannelList(); - setIsCreateChannelModalOpen(false); + setIsModalOpen(false); }; useEffect(() => { @@ -204,45 +183,42 @@ function CreateChannelModal(): JSX.Element { /* eslint-disable react/jsx-props-no-spreading */ return ( - setIsCreateChannelModalOpen(false)} /> - - - - 채널 생성 - setIsCreateChannelModalOpen(false)}> - - - - 생성할 채널의 이름과 설명을 작성해주세요 -
- - 이름 - value.trim().length > 2 || '"이름" 칸은 3글자 이상 입력되어야합니다!', - })} - placeholder="채널명을 입력해주세요" - /> - {errors.name && {errors.name.message}} - - - 설명 - value.trim().length > 0 || '"설명" 칸은 꼭 입력되어야합니다!', - })} - placeholder="채널 설명을 입력해주세요" - /> - {errors.description && {errors.description.message}} - - - 생성 - -
-
-
+ + + 채널 생성 + setIsModalOpen(false)}> + + + + 생성할 채널의 이름과 설명을 작성해주세요 +
+ + 이름 + value.trim().length > 2 || '"이름" 칸은 3글자 이상 입력되어야합니다!', + })} + placeholder="채널명을 입력해주세요" + /> + {errors.name && {errors.name.message}} + + + 설명 + value.trim().length > 0 || '"설명" 칸은 꼭 입력되어야합니다!', + })} + placeholder="채널 설명을 입력해주세요" + /> + {errors.description && {errors.description.message}} + + + 생성 + +
+
); } -export default CreateChannelModal; +export default CreateChannelModal2; diff --git a/frontend/src/components/Main/ChannelModal/JoinChannelModal.tsx b/frontend/src/components/Main/ChannelModal/JoinChannelModal.tsx index 98196d8..e57aebc 100644 --- a/frontend/src/components/Main/ChannelModal/JoinChannelModal.tsx +++ b/frontend/src/components/Main/ChannelModal/JoinChannelModal.tsx @@ -8,27 +8,6 @@ import { ChannelData } from '../../../types/main'; const { Close } = BoostCamMainIcons; const Container = styled.div` - position: fixed; - width: 100vw; - height: 100vh; - left: 0px; - right: 0px; - - display: flex; - justify-content: space-around; - align-items: center; -`; - -const ModalBackground = styled.div` - position: fixed; - left: 0px; - right: 0px; - width: 100%; - height: 100%; - background-color: rgb(0, 0, 0, 0.5); -`; - -const ModalBox = styled.div` width: 50%; min-width: 400px; height: 70%; @@ -191,7 +170,7 @@ const CloseIcon = styled(Close)` `; function JoinChannelModal(): JSX.Element { - const { selectedServer, setIsJoinChannelModalOpen, getServerChannelList } = useContext(MainStoreContext); + const { selectedServer, setIsModalOpen, getServerChannelList } = useContext(MainStoreContext); const [channelList, setChannelList] = useState([]); const getNotJoinedChannelList = async () => { @@ -201,7 +180,7 @@ function JoinChannelModal(): JSX.Element { }; const onClickChannelListButton = async (id: string) => { - await fetch('/api/user/channels', { + await fetch('/api/user/servers', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -212,14 +191,14 @@ function JoinChannelModal(): JSX.Element { }), }); getServerChannelList(); - setIsJoinChannelModalOpen(false); + setIsModalOpen(false); }; useEffect(() => { getNotJoinedChannelList(); }, []); - const tmpList = channelList.map((val) => ( + const notJoinedChannelList = channelList.map((val) => ( {val.name} @@ -232,19 +211,16 @@ function JoinChannelModal(): JSX.Element { /* eslint-disable react/jsx-props-no-spreading */ return ( - setIsJoinChannelModalOpen(false)} /> - - - - 채널 참가 - setIsJoinChannelModalOpen(false)}> - - - - 참가할 채널을 선택해주세요 - {tmpList} - - + + + 채널 참가 + setIsModalOpen(false)}> + + + + 참가할 채널을 선택해주세요 + {notJoinedChannelList} + ); } diff --git a/frontend/src/components/Main/ChannelModal/QuitChannelModal .tsx b/frontend/src/components/Main/ChannelModal/QuitChannelModal .tsx index ed03526..064eb60 100644 --- a/frontend/src/components/Main/ChannelModal/QuitChannelModal .tsx +++ b/frontend/src/components/Main/ChannelModal/QuitChannelModal .tsx @@ -7,27 +7,6 @@ import { BoostCamMainIcons } from '../../../utils/SvgIcons'; const { Close } = BoostCamMainIcons; const Container = styled.div` - position: fixed; - width: 100vw; - height: 100vh; - left: 0px; - right: 0px; - - display: flex; - justify-content: space-around; - align-items: center; -`; - -const ModalBackground = styled.div` - position: fixed; - left: 0px; - right: 0px; - width: 100%; - height: 100%; - background-color: rgb(0, 0, 0, 0.5); -`; - -const ModalBox = styled.div` width: 50%; min-width: 400px; height: 25%; @@ -182,26 +161,23 @@ function QuitChannelModal(): JSX.Element { return ( - setIsQuitChannelModalOpen(false)} /> - - - - 채널 나가기 - setIsQuitChannelModalOpen(false)}> - - - - - 정말 - {selectedChannelName} - 에서 나가시겠습니까? - - - 확인 - setIsQuitChannelModalOpen(false)}>취소 - - - + + + 채널 나가기 + setIsQuitChannelModalOpen(false)}> + + + + + 정말 + {selectedChannelName} + 에서 나가시겠습니까? + + + 확인 + setIsQuitChannelModalOpen(false)}>취소 + + ); } diff --git a/frontend/src/components/Main/ChannelModal/UpdateChannelModal.tsx b/frontend/src/components/Main/ChannelModal/UpdateChannelModal.tsx index 25160a6..1b9ea59 100644 --- a/frontend/src/components/Main/ChannelModal/UpdateChannelModal.tsx +++ b/frontend/src/components/Main/ChannelModal/UpdateChannelModal.tsx @@ -8,27 +8,6 @@ import { BoostCamMainIcons } from '../../../utils/SvgIcons'; const { Close } = BoostCamMainIcons; const Container = styled.div` - position: fixed; - width: 100vw; - height: 100vh; - left: 0px; - right: 0px; - - display: flex; - justify-content: space-around; - align-items: center; -`; - -const ModalBackground = styled.div` - position: fixed; - left: 0px; - right: 0px; - width: 100%; - height: 100%; - background-color: rgb(0, 0, 0, 0.5); -`; - -const ModalBox = styled.div` width: 35%; min-width: 400px; height: 50%; @@ -218,43 +197,40 @@ function UpdateChannelModal(): JSX.Element { /* eslint-disable react/jsx-props-no-spreading */ return ( - setIsUpdateChannelModalOpen(false)} /> - - - - 채널 수정 - setIsUpdateChannelModalOpen(false)}> - - - - 선택한 채널에 대한 내용을 변경할 수 있습니다. -
- - 이름 - value.trim().length > 2 || '"이름" 칸은 3글자 이상 입력되어야합니다!', - })} - placeholder="채널명을 입력해주세요" - /> - {errors.name && {errors.name.message}} - - - 설명 - value.trim().length > 0 || '"설명" 칸은 꼭 입력되어야합니다!', - })} - placeholder="채널 설명을 입력해주세요" - /> - {errors.description && {errors.description.message}} - - - 수정 - -
-
-
+ + + 채널 수정 + setIsUpdateChannelModalOpen(false)}> + + + + 선택한 채널에 대한 내용을 변경할 수 있습니다. +
+ + 이름 + value.trim().length > 2 || '"이름" 칸은 3글자 이상 입력되어야합니다!', + })} + placeholder="채널명을 입력해주세요" + /> + {errors.name && {errors.name.message}} + + + 설명 + value.trim().length > 0 || '"설명" 칸은 꼭 입력되어야합니다!', + })} + placeholder="채널 설명을 입력해주세요" + /> + {errors.description && {errors.description.message}} + + + 수정 + +
+
); } diff --git a/frontend/src/components/Main/MainHeader.tsx b/frontend/src/components/Main/MainHeader.tsx index 35eaec5..00f8528 100644 --- a/frontend/src/components/Main/MainHeader.tsx +++ b/frontend/src/components/Main/MainHeader.tsx @@ -3,6 +3,9 @@ import styled from 'styled-components'; import Dropdown from '../core/Dropdown'; import DropdownMenu from '../core/DropdownMenu'; import { MainStoreContext } from './MainStore'; +import QuitServerModal from './ServerModal/QuitServerModal'; +import ServerInfoModal from './ServerModal/ServerInfoModal'; +import ServerSettingModal from './ServerModal/ServerSettingModal'; const Container = styled.div` width: 100%; @@ -28,15 +31,7 @@ const CurrentServerName = styled.span` function MainHeader(): JSX.Element { const [isDropdownActivated, setIsDropdownActivated] = useState(false); - const { - selectedServer, - isServerInfoModalOpen, - isServerSettingModalOpen, - isQuitServerModalOpen, - setIsServerInfoModalOpen, - setIsServerSettingModalOpen, - setIsQuitServerModalOpen, - } = useContext(MainStoreContext); + const { selectedServer } = useContext(MainStoreContext); const onClickServerInfoButton = (e: React.MouseEvent) => { if (selectedServer !== undefined) { @@ -56,20 +51,17 @@ function MainHeader(): JSX.Element { } /> } /> } /> diff --git a/frontend/src/components/Main/MainModal.tsx b/frontend/src/components/Main/MainModal.tsx new file mode 100644 index 0000000..b6babaa --- /dev/null +++ b/frontend/src/components/Main/MainModal.tsx @@ -0,0 +1,38 @@ +import React, { useContext } from 'react'; +import styled from 'styled-components'; + +import { MainStoreContext } from './MainStore'; + +const Container = styled.div` + position: fixed; + width: 100vw; + height: 100vh; + left: 0px; + right: 0px; + + display: flex; + justify-content: space-around; + align-items: center; +`; + +const ModalBackground = styled.div` + position: fixed; + left: 0px; + right: 0px; + width: 100%; + height: 100%; + background-color: rgb(0, 0, 0, 0.5); +`; + +function MainModal(): JSX.Element { + const { setIsModalOpen, modalContents } = useContext(MainStoreContext); + + return ( + + setIsModalOpen(false)} /> + {modalContents} + + ); +} + +export default MainModal; diff --git a/frontend/src/components/Main/MainPage.tsx b/frontend/src/components/Main/MainPage.tsx index aac1231..72f5b64 100644 --- a/frontend/src/components/Main/MainPage.tsx +++ b/frontend/src/components/Main/MainPage.tsx @@ -14,6 +14,7 @@ import QuitServerModal from './ServerModal/QuitServerModal'; import UpdateChannelModal from './ChannelModal/UpdateChannelModal'; import QuitChannelModal from './ChannelModal/QuitChannelModal '; import CreateCamModal from './Cam/CreateCamModal'; +import MainModal from './MainModal'; const Container = styled.div` width: 100vw; @@ -26,32 +27,12 @@ const Container = styled.div` `; function MainPage(): JSX.Element { - const { - isCreateChannelModalOpen, - isJoinChannelModalOpen, - isUpdateChannelModalOpen, - isQuitChannelModalOpen, - isCreateServerModalOpen, - isJoinServerModalOpen, - isServerInfoModalOpen, - isServerSettingModalOpen, - isQuitServerModalOpen, - isCreateCamModalOpen, - } = useContext(MainStoreContext); + const { isModalOpen } = useContext(MainStoreContext); useEffect(() => {}, []); return ( - {isCreateChannelModalOpen && } - {isJoinChannelModalOpen && } - {isUpdateChannelModalOpen && } - {isQuitChannelModalOpen && } - {isCreateServerModalOpen && } - {isJoinServerModalOpen && } - {isServerSettingModalOpen && } - {isServerInfoModalOpen && } - {isQuitServerModalOpen && } - {isCreateCamModalOpen && } + {isModalOpen && } diff --git a/frontend/src/components/Main/MainStore.tsx b/frontend/src/components/Main/MainStore.tsx index 0cea251..5e674e3 100644 --- a/frontend/src/components/Main/MainStore.tsx +++ b/frontend/src/components/Main/MainStore.tsx @@ -1,4 +1,4 @@ -import { createContext, useEffect, useState } from 'react'; +import React, { createContext, useEffect, useState } from 'react'; import { CamData, ChannelData, MyServerData } from '../../types/main'; export const MainStoreContext = createContext(null); @@ -15,20 +15,11 @@ function MainStore(props: MainStoreProps): JSX.Element { const [rightClickedChannelName, setRightClickedChannelName] = useState(''); const [serverChannelList, setServerChannelList] = useState([]); - const [isCreateChannelModalOpen, setIsCreateChannelModalOpen] = useState(false); - const [isJoinChannelModalOpen, setIsJoinChannelModalOpen] = useState(false); - const [isUpdateChannelModalOpen, setIsUpdateChannelModalOpen] = useState(false); - const [isQuitChannelModalOpen, setIsQuitChannelModalOpen] = useState(false); - - const [isCreateServerModalOpen, setIsCreateServerModalOpen] = useState(false); - const [isJoinServerModalOpen, setIsJoinServerModalOpen] = useState(false); - const [isServerInfoModalOpen, setIsServerInfoModalOpen] = useState(false); - const [isServerSettingModalOpen, setIsServerSettingModalOpen] = useState(false); - const [isQuitServerModalOpen, setIsQuitServerModalOpen] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); + const [modalContents, setModalContents] = useState(<>); const [serverList, setServerList] = useState([]); - const [isCreateCamModalOpen, setIsCreateCamModalOpen] = useState(false); const [serverCamList, setServerCamList] = useState([]); const getServerChannelList = async (): Promise => { @@ -93,39 +84,23 @@ function MainStore(props: MainStoreProps): JSX.Element { return ( (false); - const { - selectedServer, - setSelectedServer, - isCreateServerModalOpen, - isJoinServerModalOpen, - setIsCreateServerModalOpen, - setIsJoinServerModalOpen, - serverList, - getUserServerList, - } = useContext(MainStoreContext); + const { selectedServer, setSelectedServer, serverList, getUserServerList } = useContext(MainStoreContext); const initChannel = '1'; const navigate = useNavigate(); @@ -147,14 +140,12 @@ function ServerListTab(): JSX.Element { } /> } /> diff --git a/frontend/src/components/Main/ServerModal/CreateServerModal.tsx b/frontend/src/components/Main/ServerModal/CreateServerModal.tsx index 2967a25..44f9b06 100644 --- a/frontend/src/components/Main/ServerModal/CreateServerModal.tsx +++ b/frontend/src/components/Main/ServerModal/CreateServerModal.tsx @@ -8,27 +8,6 @@ import { BoostCamMainIcons } from '../../../utils/SvgIcons'; const { Close } = BoostCamMainIcons; const Container = styled.div` - position: fixed; - width: 100vw; - height: 100vh; - left: 0px; - right: 0px; - - display: flex; - justify-content: space-around; - align-items: center; -`; - -const ModalBackground = styled.div` - position: fixed; - left: 0px; - right: 0px; - width: 100%; - height: 100%; - background-color: rgb(0, 0, 0, 0.5); -`; - -const ModalBox = styled.div` width: 35%; min-width: 400px; @@ -192,7 +171,7 @@ function CreateServerModal(): JSX.Element { watch, formState: { errors }, } = useForm(); - const { setIsCreateServerModalOpen, getUserServerList } = useContext(MainStoreContext); + const { setIsModalOpen, getUserServerList } = useContext(MainStoreContext); const [isButtonActive, setIsButtonActive] = useState(false); const [messageFailToPost, setMessageFailToPost] = useState(''); const [imagePreview, setImagePreview] = useState(); @@ -212,7 +191,7 @@ function CreateServerModal(): JSX.Element { if (response.status === 201) { getUserServerList('created'); - setIsCreateServerModalOpen(false); + setIsModalOpen(false); } else { const body = await response.json(); setMessageFailToPost(body.message); @@ -236,51 +215,48 @@ function CreateServerModal(): JSX.Element { /* eslint-disable react/jsx-props-no-spreading */ return ( - setIsCreateServerModalOpen(false)} /> - - - - 서버 생성 - setIsCreateServerModalOpen(false)}> - - - - 생성할 서버의 이름과 설명을 작성해주세요 -
- - 이름 - value.trim().length > 1 || '"이름" 칸은 2글자 이상 입력되어야합니다!', - })} - placeholder="서버명을 입력해주세요" - /> - {errors.name && {errors.name.message}} - - - 설명 - value.trim().length > 0 || '"설명" 칸은 꼭 입력되어야합니다!', - })} - placeholder="서버 설명을 입력해주세요" - /> - {errors.description && {errors.description.message}} - - - 서버 아이콘 - - - - - - {messageFailToPost} - - 생성 - -
-
-
+ + + 서버 생성 + setIsModalOpen(false)}> + + + + 생성할 서버의 이름과 설명을 작성해주세요 +
+ + 이름 + value.trim().length > 1 || '"이름" 칸은 2글자 이상 입력되어야합니다!', + })} + placeholder="서버명을 입력해주세요" + /> + {errors.name && {errors.name.message}} + + + 설명 + value.trim().length > 0 || '"설명" 칸은 꼭 입력되어야합니다!', + })} + placeholder="서버 설명을 입력해주세요" + /> + {errors.description && {errors.description.message}} + + + 서버 아이콘 + + + + + + {messageFailToPost} + + 생성 + +
+
); } diff --git a/frontend/src/components/Main/ServerModal/JoinServerModal.tsx b/frontend/src/components/Main/ServerModal/JoinServerModal.tsx index 5e66061..8d72bf1 100644 --- a/frontend/src/components/Main/ServerModal/JoinServerModal.tsx +++ b/frontend/src/components/Main/ServerModal/JoinServerModal.tsx @@ -8,27 +8,6 @@ import { BoostCamMainIcons } from '../../../utils/SvgIcons'; const { Close } = BoostCamMainIcons; const Container = styled.div` - position: fixed; - width: 100vw; - height: 100vh; - left: 0px; - right: 0px; - - display: flex; - justify-content: space-around; - align-items: center; -`; - -const ModalBackground = styled.div` - position: fixed; - left: 0px; - right: 0px; - width: 100%; - height: 100%; - background-color: rgb(0, 0, 0, 0.5); -`; - -const ModalBox = styled.div` width: 35%; min-width: 400px; height: 50%; @@ -179,7 +158,7 @@ function JoinServerModal(): JSX.Element { watch, formState: { errors }, } = useForm(); - const { setIsJoinServerModalOpen, getUserServerList } = useContext(MainStoreContext); + const { setIsModalOpen, getUserServerList } = useContext(MainStoreContext); const [isButtonActive, setIsButtonActive] = useState(false); const [messageFailToPost, setMessageFailToPost] = useState(''); @@ -197,7 +176,7 @@ function JoinServerModal(): JSX.Element { if (response.status === 201) { getUserServerList('created'); - setIsJoinServerModalOpen(false); + setIsModalOpen(false); } else { const body = await response.json(); setMessageFailToPost(body.message); @@ -213,34 +192,31 @@ function JoinServerModal(): JSX.Element { /* eslint-disable react/jsx-props-no-spreading */ return ( - setIsJoinServerModalOpen(false)} /> - - - - 서버 참가 - setIsJoinServerModalOpen(false)}> - - - - 참가 코드를 입력하세요. -
- - 참가 코드 - value.trim().length > 0 || '"참가코드" 칸을 입력해주세요!', - })} - placeholder="참가코드를 입력해주세요" - /> - {errors.serverId && {errors.serverId.message}} - - {messageFailToPost} - - 생성 - -
-
-
+ + + 서버 참가 + setIsModalOpen(false)}> + + + + 참가 코드를 입력하세요. +
+ + 참가 코드 + value.trim().length > 0 || '"참가코드" 칸을 입력해주세요!', + })} + placeholder="참가코드를 입력해주세요" + /> + {errors.serverId && {errors.serverId.message}} + + {messageFailToPost} + + 생성 + +
+
); } diff --git a/frontend/src/components/Main/ServerModal/QuitServerModal.tsx b/frontend/src/components/Main/ServerModal/QuitServerModal.tsx index 0107fa2..91f161f 100644 --- a/frontend/src/components/Main/ServerModal/QuitServerModal.tsx +++ b/frontend/src/components/Main/ServerModal/QuitServerModal.tsx @@ -7,27 +7,6 @@ import { BoostCamMainIcons } from '../../../utils/SvgIcons'; const { Close } = BoostCamMainIcons; const Container = styled.div` - position: fixed; - width: 100vw; - height: 100vh; - left: 0px; - right: 0px; - - display: flex; - justify-content: space-around; - align-items: center; -`; - -const ModalBackground = styled.div` - position: fixed; - left: 0px; - right: 0px; - width: 100%; - height: 100%; - background-color: rgb(0, 0, 0, 0.5); -`; - -const ModalBox = styled.div` width: 35%; min-width: 400px; height: 50%; @@ -139,7 +118,7 @@ const CloseIcon = styled(Close)` `; function QuitServerModal(): JSX.Element { - const { setIsQuitServerModalOpen, selectedServer, getUserServerList } = useContext(MainStoreContext); + const { setIsModalOpen, selectedServer, getUserServerList } = useContext(MainStoreContext); const isButtonActive = true; const [messageFailToPost, setMessageFailToPost] = useState(''); @@ -154,7 +133,7 @@ function QuitServerModal(): JSX.Element { if (response.status === 204) { const isServerOrUserServerCreated = false; getUserServerList(isServerOrUserServerCreated); - setIsQuitServerModalOpen(false); + setIsModalOpen(false); } else { const body = await response.json(); setMessageFailToPost(body.message); @@ -163,24 +142,21 @@ function QuitServerModal(): JSX.Element { return ( - setIsQuitServerModalOpen(false)} /> - - - - 서버 나가기 - setIsQuitServerModalOpen(false)}> - - - - 서버에서 나가시겠습니까? -
- {messageFailToPost} - - 예 - -
-
-
+ + + 서버 나가기 + setIsModalOpen(false)}> + + + + 서버에서 나가시겠습니까? +
+ {messageFailToPost} + + 예 + +
+
); } diff --git a/frontend/src/components/Main/ServerModal/ServerInfoModal.tsx b/frontend/src/components/Main/ServerModal/ServerInfoModal.tsx index 5bd55be..8dec1f6 100644 --- a/frontend/src/components/Main/ServerModal/ServerInfoModal.tsx +++ b/frontend/src/components/Main/ServerModal/ServerInfoModal.tsx @@ -7,27 +7,6 @@ import { BoostCamMainIcons } from '../../../utils/SvgIcons'; const { Close } = BoostCamMainIcons; const Container = styled.div` - position: fixed; - width: 100vw; - height: 100vh; - left: 0px; - right: 0px; - - display: flex; - justify-content: space-around; - align-items: center; -`; - -const ModalBackground = styled.div` - position: fixed; - left: 0px; - right: 0px; - width: 100%; - height: 100%; - background-color: rgb(0, 0, 0, 0.5); -`; - -const ModalBox = styled.div` width: 35%; min-width: 400px; @@ -176,7 +155,7 @@ type UserInfo = { }; function ServerInfoModal(): JSX.Element { - const { setIsServerInfoModalOpen, selectedServer } = useContext(MainStoreContext); + const { setIsModalOpen, selectedServer } = useContext(MainStoreContext); const [joinedUserList, setJoinedUserList] = useState(); const [serverDescription, setServerDescription] = useState(); const [serverName, setServerName] = useState(''); @@ -204,39 +183,36 @@ function ServerInfoModal(): JSX.Element { /* eslint-disable react/jsx-props-no-spreading */ return ( - setIsServerInfoModalOpen(false)} /> - - - - 서버 정보 - setIsServerInfoModalOpen(false)}> - - - - - {serverIconUrl ? : {serverName[0]}} - {serverName} - - - {serverDescription} - - - 서버 참가 URL - 서버 참가 url - - - 서버 사용자 리스트 - - {joinedUserList - ?.map((joinedUser) => { - const { nickname } = joinedUser; - return nickname; - }) - .join('\n')} - - - - + + + 서버 정보 + setIsModalOpen(false)}> + + + + + {serverIconUrl ? : {serverName[0]}} + {serverName} + + + {serverDescription} + + + 서버 참가 URL + 서버 참가 url + + + 서버 사용자 리스트 + + {joinedUserList + ?.map((joinedUser) => { + const { nickname } = joinedUser; + return nickname; + }) + .join('\n')} + + + ); } diff --git a/frontend/src/components/Main/ServerModal/ServerSettingModal.tsx b/frontend/src/components/Main/ServerModal/ServerSettingModal.tsx index ecb3e7d..616dbf7 100644 --- a/frontend/src/components/Main/ServerModal/ServerSettingModal.tsx +++ b/frontend/src/components/Main/ServerModal/ServerSettingModal.tsx @@ -7,27 +7,6 @@ import { BoostCamMainIcons } from '../../../utils/SvgIcons'; const { Close } = BoostCamMainIcons; const Container = styled.div` - position: fixed; - width: 100vw; - height: 100vh; - left: 0px; - right: 0px; - - display: flex; - justify-content: space-around; - align-items: center; -`; - -const ModalBackground = styled.div` - position: fixed; - left: 0px; - right: 0px; - width: 100%; - height: 100%; - background-color: rgb(0, 0, 0, 0.5); -`; - -const ModalBox = styled.div` width: 35%; min-width: 400px; @@ -164,7 +143,7 @@ const CloseIcon = styled(Close)` `; function ServerSettingModal(): JSX.Element { - const { setIsServerSettingModalOpen, selectedServer, getUserServerList } = useContext(MainStoreContext); + const { setIsModalOpen, selectedServer, getUserServerList } = useContext(MainStoreContext); const isButtonActive = true; const [imagePreview, setImagePreview] = useState(); const [messageFailToPost, setMessageFailToPost] = useState(''); @@ -198,7 +177,7 @@ function ServerSettingModal(): JSX.Element { }); if (response.status === 204) { getUserServerList('updated'); - setIsServerSettingModalOpen(false); + setIsModalOpen(false); } else { const body = await response.json(); setMessageFailToPost(body.message); @@ -216,7 +195,7 @@ function ServerSettingModal(): JSX.Element { if (response.status === 204) { getUserServerList(); - setIsServerSettingModalOpen(false); + setIsModalOpen(false); } else { const body = await response.json(); setMessageFailToPost(body.message); @@ -229,61 +208,58 @@ function ServerSettingModal(): JSX.Element { /* eslint-disable react/jsx-props-no-spreading */ return ( - setIsServerSettingModalOpen(false)} /> - - - - 서버 설정 - setIsServerSettingModalOpen(false)}> - - - -
- 서버 이름 변경 - - setName(e.target.value)} /> - - 제출 - - - 서버 설명 변경 - - setDescription(e.target.value)} - /> - - 제출 - - - 서버 아이콘 변경 - - - - - - - 제출 - - - 서버 URL 재생성 - - - - 생성 - - - - 서버 삭제 - - 서버 삭제 - - - {messageFailToPost} -
-
-
+ + + 서버 설정 + setIsModalOpen(false)}> + + + +
+ 서버 이름 변경 + + setName(e.target.value)} /> + + 제출 + + + 서버 설명 변경 + + setDescription(e.target.value)} + /> + + 제출 + + + 서버 아이콘 변경 + + + + + + + 제출 + + + 서버 URL 재생성 + + + + 생성 + + + + 서버 삭제 + + 서버 삭제 + + + {messageFailToPost} +
+
); } diff --git a/frontend/src/components/core/DropdownMenu.tsx b/frontend/src/components/core/DropdownMenu.tsx index abd42c4..b000c96 100644 --- a/frontend/src/components/core/DropdownMenu.tsx +++ b/frontend/src/components/core/DropdownMenu.tsx @@ -1,5 +1,6 @@ -import React from 'react'; +import React, { useContext } from 'react'; import styled from 'styled-components'; +import { MainStoreContext } from '../Main/MainStore'; const Container = styled.li` border-bottom: 1px solid #dddddd; @@ -16,19 +17,20 @@ const Container = styled.li` `; type DropdownMenuProps = { - setIsDropdownActivated: React.Dispatch>; name: string; - state: boolean; - stateSetter: React.Dispatch>; + setIsDropdownActivated: React.Dispatch>; + modalContents: JSX.Element; }; function DropdownMenu(props: DropdownMenuProps): JSX.Element { - const { name, state, stateSetter, setIsDropdownActivated } = props; + const { setModalContents, setIsModalOpen } = useContext(MainStoreContext); + const { name, setIsDropdownActivated, modalContents } = props; const onClickMenu = (e: React.MouseEvent) => { e.stopPropagation(); - stateSetter(!state); + setIsModalOpen(true); setIsDropdownActivated(false); + setModalContents(modalContents); }; return {name}; From 1dda83c5d37dc4e13af3c6eb4962bdf79d760a81 Mon Sep 17 00:00:00 2001 From: korung3195 Date: Wed, 24 Nov 2021 23:28:35 +0900 Subject: [PATCH 131/172] =?UTF-8?q?Feat=20:=20=EC=9E=98=EB=AA=BB=EB=90=9C?= =?UTF-8?q?=20cam=20url=20=EC=9E=85=EB=A0=A5,=20=EC=B4=88=EA=B8=B0=20?= =?UTF-8?q?=EC=9E=85=EC=9E=A5=20=EC=B2=98=EB=A6=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 잘못된 cam url을 입력할 경우 존재하지 않는 방을 알리는 component를 추가하였습니다. - 닉네임이 null일 경우 닉네임을 입력하는 component의 스타일을 변경하였습니다. --- frontend/src/components/Cam/Cam.tsx | 3 +- frontend/src/components/Cam/CamNotFound.tsx | 44 ++++++++++++++++ .../components/Cam/Nickname/NickNameForm.tsx | 51 +++++++++---------- .../components/Cam/Nickname/NicknameModal.tsx | 17 ++----- 4 files changed, 75 insertions(+), 40 deletions(-) create mode 100644 frontend/src/components/Cam/CamNotFound.tsx diff --git a/frontend/src/components/Cam/Cam.tsx b/frontend/src/components/Cam/Cam.tsx index b978940..4118ba7 100644 --- a/frontend/src/components/Cam/Cam.tsx +++ b/frontend/src/components/Cam/Cam.tsx @@ -11,6 +11,7 @@ import { UserInfo } from '../../types/cam'; import STTStore from './STT/STTStore'; import SharedScreenStore from './SharedScreen/SharedScreenStore'; import NickNameForm from './Nickname/NickNameForm'; +import CamNotFound from './CamNotFound'; const Container = styled.div` width: 100vw; @@ -90,7 +91,7 @@ function Cam(): JSX.Element {
); } - return
없는 방입니다~
; + return ; } export default Cam; diff --git a/frontend/src/components/Cam/CamNotFound.tsx b/frontend/src/components/Cam/CamNotFound.tsx new file mode 100644 index 0000000..6c882da --- /dev/null +++ b/frontend/src/components/Cam/CamNotFound.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import styled from 'styled-components'; + +const Container = styled.div` + position: fixed; + width: 100vw; + height: 100vh; + left: 0px; + right: 0px; + + display: flex; + justify-content: center; + align-items: center; +`; + +const Title = styled.div` + background-color: rgba(0, 0, 0, 0.7); + width: 100%; + height: 20%; + display: flex; + justify-content: center; + align-items: center; + color: white; + font-size: 44px; +`; + +const Background = styled.img` + position: absolute; + width: 70%; + height: auto; + margin: 0 auto; + opacity: 0.1; +`; + +function CamNotFound(): JSX.Element { + return ( + + + 존재하지 않는 방입니다. + + ); +} + +export default CamNotFound; diff --git a/frontend/src/components/Cam/Nickname/NickNameForm.tsx b/frontend/src/components/Cam/Nickname/NickNameForm.tsx index 5f055d6..0b2daee 100644 --- a/frontend/src/components/Cam/Nickname/NickNameForm.tsx +++ b/frontend/src/components/Cam/Nickname/NickNameForm.tsx @@ -3,62 +3,60 @@ import styled from 'styled-components'; import { UserInfo } from '../../../types/cam'; const Container = styled.div` + position: fixed; width: 100vw; height: 100vh; - background-color: #009b9f; + left: 0px; + right: 0px; display: flex; - justify-content: space-around; + justify-content: center; align-items: center; + background-color: white; `; const Form = styled.form` - width: 30%; - height: 30%; - border-radius: 20px; - padding: 20px 0; - margin: 30px 0; - - border: 2px solid #12cdd1; + background-color: rgba(0, 0, 0, 0.7); + width: 100%; + height: 20%; display: flex; - flex-direction: column; - justify-content: space-around; + justify-content: center; align-items: center; + color: white; + font-size: 44px; `; const Input = styled.input` border: none; outline: none; - padding: 8px 10px; - margin-top: 10px; + padding: 10px; border-radius: 10px; + width: 30%; `; const SubmitButton = styled.button` - width: 60%; - margin-top: 15px; - height: 35px; - background: none; - + padding: 10px 30px; border: 0; outline: 0; - border-radius: 10px; - border: 2px solid #12cdd1; cursor: pointer; text-align: center; - transition: all 0.3s; - - &:hover { - background-color: #12cdd1; - transition: all 0.3s; - } + margin-left: 20px; a { text-decoration: none; } `; +const Background = styled.img` + position: absolute; + width: 70%; + height: auto; + margin: 0 auto; + opacity: 0.1; + z-index: -1; +`; + type NickNameFormProps = { setUserInfo: React.Dispatch>; }; @@ -78,6 +76,7 @@ function NickNameForm(props: NickNameFormProps): JSX.Element { return ( +
입력 diff --git a/frontend/src/components/Cam/Nickname/NicknameModal.tsx b/frontend/src/components/Cam/Nickname/NicknameModal.tsx index 6b3587d..7d39cff 100644 --- a/frontend/src/components/Cam/Nickname/NicknameModal.tsx +++ b/frontend/src/components/Cam/Nickname/NicknameModal.tsx @@ -28,12 +28,12 @@ const ModalBackground = styled.div` `; const ModalBox = styled.div` - width: 20%; + width: 30%; height: 20%; background-color: white; display: flex; flex-direction: column; - justify-content: space-between; + justify-content: space-around; align-items: center; padding: 20px; border-radius: 20px; @@ -45,7 +45,6 @@ const Form = styled.form` border-radius: 20px; display: flex; - flex-direction: column; justify-content: space-around; align-items: center; `; @@ -54,29 +53,21 @@ const Input = styled.input` border: 1px solid grey; outline: none; padding: 8px 10px; - margin-top: 10px; + margin-right: 20px; border-radius: 10px; `; const SubmitButton = styled.button` width: 30%; - margin-top: 15px; height: 35px; background: none; - border: 0; outline: 0; + border: 1px solid grey; border-radius: 10px; - background-color: #2dc2e6; cursor: pointer; text-align: center; - transition: all 0.3s; - - &:hover { - background-color: #26a9ca; - transition: all 0.3s; - } a { text-decoration: none; From b6427c97d280ae50d8b2f2aad5e295743bd61fa5 Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Wed, 24 Nov 2021 23:33:32 +0900 Subject: [PATCH 132/172] =?UTF-8?q?Fix=20:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=B0=B8=EC=A1=B0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Main/Cam/CamListHeader.tsx | 3 +-- frontend/src/components/Main/MainPage.tsx | 13 +------------ 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/Main/Cam/CamListHeader.tsx b/frontend/src/components/Main/Cam/CamListHeader.tsx index b615bd1..8849cc7 100644 --- a/frontend/src/components/Main/Cam/CamListHeader.tsx +++ b/frontend/src/components/Main/Cam/CamListHeader.tsx @@ -1,8 +1,7 @@ -import React, { useContext, useState } from 'react'; +import React, { useState } from 'react'; import styled from 'styled-components'; import { BoostCamMainIcons } from '../../../utils/SvgIcons'; -import { MainStoreContext } from '../MainStore'; import Dropdown from '../../core/Dropdown'; import DropdownMenu from '../../core/DropdownMenu'; import CreateCamModal from './CreateCamModal'; diff --git a/frontend/src/components/Main/MainPage.tsx b/frontend/src/components/Main/MainPage.tsx index 72f5b64..d906127 100644 --- a/frontend/src/components/Main/MainPage.tsx +++ b/frontend/src/components/Main/MainPage.tsx @@ -1,19 +1,9 @@ -import React, { useContext, useEffect } from 'react'; +import React, { useContext } from 'react'; import styled from 'styled-components'; import ServerListTab from './ServerListTab'; import MainSection from './MainSection'; import { MainStoreContext } from './MainStore'; -import CreateChannelModal from './ChannelModal/CreateChannelModal'; -import JoinChannelModal from './ChannelModal/JoinChannelModal'; -import CreateServerModal from './ServerModal/CreateServerModal'; -import JoinServerModal from './ServerModal/JoinServerModal'; -import ServerSettingModal from './ServerModal/ServerSettingModal'; -import ServerInfoModal from './ServerModal/ServerInfoModal'; -import QuitServerModal from './ServerModal/QuitServerModal'; -import UpdateChannelModal from './ChannelModal/UpdateChannelModal'; -import QuitChannelModal from './ChannelModal/QuitChannelModal '; -import CreateCamModal from './Cam/CreateCamModal'; import MainModal from './MainModal'; const Container = styled.div` @@ -28,7 +18,6 @@ const Container = styled.div` function MainPage(): JSX.Element { const { isModalOpen } = useContext(MainStoreContext); - useEffect(() => {}, []); return ( From 86ff388a4264636aee67df4585c8580eef8dcdf6 Mon Sep 17 00:00:00 2001 From: korung3195 Date: Wed, 24 Nov 2021 23:52:52 +0900 Subject: [PATCH 133/172] =?UTF-8?q?Fix=20:=20=EB=A7=90=EC=8D=BD=EC=9D=84?= =?UTF-8?q?=20=EC=9D=BC=EC=9C=BC=ED=82=A4=EB=8A=94=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cam, cams 모듈의 병합으로 인해 테스트 코드가 에러를 일으켜서 삭제했습니다. 이게 최선의 방법이 아닌 것은 알지만, 어쩌겠습니까. 저희는 시간이 없는걸요. --- backend/src/cam/cam.controller.spec.ts | 20 -------------------- backend/src/cam/cam.gateway.spec.ts | 25 ------------------------- 2 files changed, 45 deletions(-) delete mode 100644 backend/src/cam/cam.controller.spec.ts delete mode 100644 backend/src/cam/cam.gateway.spec.ts diff --git a/backend/src/cam/cam.controller.spec.ts b/backend/src/cam/cam.controller.spec.ts deleted file mode 100644 index 4bffdc8..0000000 --- a/backend/src/cam/cam.controller.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { CamController } from './cam.controller'; -import { CamInnerService } from './cam-inner.service'; - -describe('CamController', () => { - let controller: CamController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [CamController], - providers: [CamInnerService], - }).compile(); - - controller = module.get(CamController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/backend/src/cam/cam.gateway.spec.ts b/backend/src/cam/cam.gateway.spec.ts deleted file mode 100644 index 3d06652..0000000 --- a/backend/src/cam/cam.gateway.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { CamGateway } from './cam.gateway'; -import { CamInnerService } from './cam-inner.service'; - -describe('CamGateway', () => { - let gateway: CamGateway; - let service: CamInnerService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [CamGateway, CamInnerService], - }).compile(); - - gateway = module.get(CamGateway); - service = module.get(CamInnerService); - }); - - it('should be defined', () => { - expect(gateway).toBeDefined(); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); From f1cb072cf2c2f3bdf6c43afffd9225d424c81e74 Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Thu, 25 Nov 2021 01:42:23 +0900 Subject: [PATCH 134/172] =?UTF-8?q?Feat=20:=20=EC=B1=84=EB=84=90=EC=97=90?= =?UTF-8?q?=20=EB=A9=94=EC=8B=9C=EC=A7=80=EB=A5=BC=20=EB=B3=B4=EB=82=B4?= =?UTF-8?q?=EA=B3=A0=20=EB=A9=94=EC=8B=9C=EC=A7=80=EB=A5=BC=20=EB=B6=88?= =?UTF-8?q?=EB=9F=AC=EC=98=A4=EB=8A=94=20=EB=8F=99=EC=9E=91=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MessageSection의 Textarea에서 선택한 Channel에 메시지를 전송할 수 있습니다. - 아직 Socket을 통해 메시지가 보내졌다는 통신을 하지 않고 있으므로 이에 대한 구현이 필요합니다. --- .../Main/ContentsSection/MessageSection.tsx | 61 +++++++++++++------ frontend/src/components/Main/MainStore.tsx | 18 +----- frontend/src/types/messags.ts | 20 ++++++ frontend/src/utils/fetchMethods.ts | 14 +++++ 4 files changed, 79 insertions(+), 34 deletions(-) create mode 100644 frontend/src/types/messags.ts create mode 100644 frontend/src/utils/fetchMethods.ts diff --git a/frontend/src/components/Main/ContentsSection/MessageSection.tsx b/frontend/src/components/Main/ContentsSection/MessageSection.tsx index fb8f2af..c476222 100644 --- a/frontend/src/components/Main/ContentsSection/MessageSection.tsx +++ b/frontend/src/components/Main/ContentsSection/MessageSection.tsx @@ -1,6 +1,10 @@ -import React, { useRef } from 'react'; +import React, { useContext, useEffect, useRef, useState } from 'react'; import styled from 'styled-components'; +import { MainStoreContext } from '../MainStore'; +import { MessageData, MessageRequestBody } from '../../../types/messags'; +import fetchData from '../../../utils/fetchMethods'; + const Container = styled.div` width: 100%; height: 100%; @@ -84,11 +88,13 @@ const MessageItemBlock = styled.div` } `; -const MessageItemIcon = styled.div` +const MessageItemIcon = styled.div<{ imgUrl: string }>` width: 36px; height: 36px; margin: 10px; - background-color: indigo; + background-image: url(${(props) => props.imgUrl}); + background-size: cover; + background-repeat: no-repeat; border-radius: 8px; `; @@ -165,10 +171,29 @@ const MessageTextarea = styled.textarea` `; function MessageSection(): JSX.Element { - const tmpAry = new Array(15).fill('value'); + const { selectedChannel } = useContext(MainStoreContext); + const [messageList, setMessageList] = useState([]); const textDivRef = useRef(null); const tmpChannelName = '# ChannelName'; + const getMessageList = async () => { + const responseData = await fetchData('GET', `/api/messages?channelId=${selectedChannel}`); + + if (responseData) { + responseData.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10)); + setMessageList(responseData); + } + }; + + const sendMessage = async (contents: string) => { + const requestBody: MessageRequestBody = { + channelId: selectedChannel, + contents, + }; + await fetchData('POST', '/api/messages', requestBody); + getMessageList(); + }; + const onKeyDownMessageTextarea = (e: React.KeyboardEvent) => { const { key, currentTarget, shiftKey } = e; const msg = currentTarget.value.trim(); @@ -185,6 +210,7 @@ function MessageSection(): JSX.Element { e.preventDefault(); if (!msg.length) currentTarget.value = ''; else { + sendMessage(currentTarget.value); currentTarget.value = ''; } currentTarget.style.height = '21px'; @@ -192,35 +218,34 @@ function MessageSection(): JSX.Element { } }; - const tmpMessageItems = tmpAry.map((val: string, idx: number): JSX.Element => { - const key = `${val}-${idx}`; - const tmp = new Array(idx).fill('Message'); - const contents = tmp.reduce((acc, va) => { - return `${acc}-${va}`; - }, ''); + const MessageItemList = messageList.map((val: MessageData): JSX.Element => { + const { id, contents, createdAt, sender } = val; + const { nickname, profile } = sender; return ( - - + + - Sender {idx} - Timestamp + {nickname} + {createdAt} - - `${contents}-${idx}` - + {contents} ); }); + useEffect(() => { + getMessageList(); + }, [selectedChannel]); + return ( {tmpChannelName} Users 5 - {tmpMessageItems} + {MessageItemList} diff --git a/frontend/src/components/Main/MainStore.tsx b/frontend/src/components/Main/MainStore.tsx index 5e674e3..e030412 100644 --- a/frontend/src/components/Main/MainStore.tsx +++ b/frontend/src/components/Main/MainStore.tsx @@ -10,7 +10,7 @@ type MainStoreProps = { function MainStore(props: MainStoreProps): JSX.Element { const { children } = props; const [selectedServer, setSelectedServer] = useState(); - const [selectedChannel, setSelectedChannel] = useState('-1'); + const [selectedChannel, setSelectedChannel] = useState(''); const [rightClickedChannelId, setRightClickedChannelId] = useState(''); const [rightClickedChannelName, setRightClickedChannelName] = useState(''); const [serverChannelList, setServerChannelList] = useState([]); @@ -29,7 +29,7 @@ function MainStore(props: MainStoreProps): JSX.Element { if (channelList.length) { setSelectedChannel(channelList[0].id); } else { - setSelectedChannel('-1'); + setSelectedChannel(''); } setServerChannelList(channelList); }; @@ -50,14 +50,6 @@ function MainStore(props: MainStoreProps): JSX.Element { } }; - const getMessageList = async (): Promise => { - const response = await fetch(`/api/messages?channelId=${selectedChannel}`); - const list = await response.json(); - const messageList = list.data; - // eslint-disable-next-line no-console - console.log(messageList); - }; - const getServerCamList = async (): Promise => { const response = await fetch(`/api/servers/${selectedServer?.server.id}/cams`); const list = await response.json(); @@ -75,12 +67,6 @@ function MainStore(props: MainStoreProps): JSX.Element { } }, [selectedServer]); - useEffect(() => { - if (selectedChannel) { - getMessageList(); - } - }, [selectedChannel]); - return ( (method: string, url: string, requestBody?: T): Promise => { + const response = await fetch(url, { + method, + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + const responseObject = await response.json(); + return responseObject.data; +}; + +export default fetchData; From e817acf38d119a6ee6fee89c4bebf64c17509ebf Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Thu, 25 Nov 2021 12:22:35 +0900 Subject: [PATCH 135/172] =?UTF-8?q?Feat=20:=20LoginCallback=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20ui=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배경색과 로그인 상태 메시지를 변경하였습니다. --- .../components/LoginPage/LoginCallback.tsx | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/LoginPage/LoginCallback.tsx b/frontend/src/components/LoginPage/LoginCallback.tsx index 2dcf038..a606745 100644 --- a/frontend/src/components/LoginPage/LoginCallback.tsx +++ b/frontend/src/components/LoginPage/LoginCallback.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Navigate } from 'react-router-dom'; import { useRecoilState } from 'recoil'; import styled from 'styled-components'; @@ -6,7 +6,17 @@ import userState from '../../atoms/user'; import User from '../../types/user'; -const Container = styled.div``; +const Container = styled.div` + width: 100vw; + height: 100vh; + background-color: #492148; + display: flex; + justify-content: center; + align-items: center; + font-size: 36px; + font-weight: bold; + color: #eeeeee; +`; const requestLogin = async (code: string, service: string): Promise => { const response = await fetch(`/api/login/${service}?code=${code}`); @@ -26,6 +36,8 @@ function LoginCallback(props: LoginCallbackProps): JSX.Element { const [loading, setLoading] = useState(true); const [isSuccess, setIsSuccess] = useState(false); const [loggedInUser, setLoggedInUser] = useRecoilState(userState); + const [loginStatus, setLoginStatus] = useState('로그인 중'); + const loginStatusRef = useRef('로그인 중중'); const urlParams = new URLSearchParams(window.location.search); const code = urlParams.get('code'); @@ -50,6 +62,22 @@ function LoginCallback(props: LoginCallbackProps): JSX.Element { tryLogin(); }, [code]); + const changeLoginStatusMessage = () => { + loginStatusRef.current += '.'; + if (loginStatusRef.current.length > 8) { + loginStatusRef.current = loginStatusRef.current.substring(0, 5); + } + setLoginStatus(loginStatusRef.current); + }; + + useEffect(() => { + const intervalId = setInterval(changeLoginStatusMessage, 200); + + return () => { + clearInterval(intervalId); + }; + }, []); + if (loggedInUser || isSuccess) { return ; } @@ -57,7 +85,7 @@ function LoginCallback(props: LoginCallbackProps): JSX.Element { if (loading) { return ( -
로그인 중...
+
{loginStatus}
); } From 55411546e62c2cb0cea45a7d485969efed95043b Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Thu, 25 Nov 2021 13:15:36 +0900 Subject: [PATCH 136/172] =?UTF-8?q?Feat=20:=20LoginMain=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit github auth 로그인 버튼만 남기고 불필요한 컴포넌트를 제거하였습니다. --- frontend/src/assets/icons/cover_new.png | Bin 0 -> 62870 bytes .../src/components/LoginPage/LocalLogin.tsx | 87 ------------------ .../components/LoginPage/LoginCallback.tsx | 4 +- .../src/components/LoginPage/LoginMain.tsx | 57 +++--------- .../src/components/LoginPage/OAuthLogin.tsx | 28 +----- 5 files changed, 19 insertions(+), 157 deletions(-) create mode 100644 frontend/src/assets/icons/cover_new.png delete mode 100644 frontend/src/components/LoginPage/LocalLogin.tsx diff --git a/frontend/src/assets/icons/cover_new.png b/frontend/src/assets/icons/cover_new.png new file mode 100644 index 0000000000000000000000000000000000000000..696756c61bea55535a608bec610917da60a8cd72 GIT binary patch literal 62870 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 ?rt8-sfLTvdmfk4D+0!s%c<1OPR|g) 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 z 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 Vyj1&WJRb0+t*-mHO!Za7{{tGZ4{87a literal 0 HcmV?d00001 diff --git a/frontend/src/components/LoginPage/LocalLogin.tsx b/frontend/src/components/LoginPage/LocalLogin.tsx deleted file mode 100644 index 640a080..0000000 --- a/frontend/src/components/LoginPage/LocalLogin.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import React from 'react'; -import styled from 'styled-components'; -import { useNavigate } from 'react-router-dom'; - -const Container = styled.form` - display: flex; - flex-direction: column; - justify-content: space-around; - margin-left: 40px; - margin-top: 15px; - width: 100%; - height: 50%; -`; - -const InputDiv = styled.div` - width: 100%; - display: flex; - flex-direction: column; - justify-content: flex-start; - &:last-child { - margin-top: 15px; - } -`; - -const InputTag = styled.span` - width: 100%; -`; - -const LoginInput = styled.input` - width: 80%; - border: none; - outline: none; - padding: 8px 10px; - margin-top: 10px; - - border-radius: 10px; -`; - -const SubmitButton = styled.button` - width: 85%; - margin-top: 15px; - height: 35px; - background-color: #4ddddf; - - border: 0; - outline: 0; - - border-radius: 10px; - cursor: pointer; - text-align: center; - box-shadow: 5px 3px 3px #7c7b7b; - transition: all 0.3s; - - &:hover { - background-color: #26f5f8; - transition: all 0.3s; - } - - a { - text-decoration: none; - } -`; - -function LocalLogin(): JSX.Element { - const navigate = useNavigate(); - const onSumbitLoginForm = (e: React.FormEvent): void => { - e.preventDefault(); - - navigate('/rooms'); - }; - - return ( - - - 아이디 - - - - 비밀번호 - - - 로그인 - - ); -} - -export default LocalLogin; diff --git a/frontend/src/components/LoginPage/LoginCallback.tsx b/frontend/src/components/LoginPage/LoginCallback.tsx index a606745..d5f3dfd 100644 --- a/frontend/src/components/LoginPage/LoginCallback.tsx +++ b/frontend/src/components/LoginPage/LoginCallback.tsx @@ -12,7 +12,7 @@ const Container = styled.div` background-color: #492148; display: flex; justify-content: center; - align-items: center; + padding-top: 100px; font-size: 36px; font-weight: bold; color: #eeeeee; @@ -37,7 +37,7 @@ function LoginCallback(props: LoginCallbackProps): JSX.Element { const [isSuccess, setIsSuccess] = useState(false); const [loggedInUser, setLoggedInUser] = useRecoilState(userState); const [loginStatus, setLoginStatus] = useState('로그인 중'); - const loginStatusRef = useRef('로그인 중중'); + const loginStatusRef = useRef('로그인 중'); const urlParams = new URLSearchParams(window.location.search); const code = urlParams.get('code'); diff --git a/frontend/src/components/LoginPage/LoginMain.tsx b/frontend/src/components/LoginPage/LoginMain.tsx index 25d89d8..cc815d8 100644 --- a/frontend/src/components/LoginPage/LoginMain.tsx +++ b/frontend/src/components/LoginPage/LoginMain.tsx @@ -1,82 +1,49 @@ import React from 'react'; -import { Link } from 'react-router-dom'; import styled from 'styled-components'; -import LocalLogin from './LocalLogin'; import OAuthLogin from './OAuthLogin'; - -import { ReactComponent as TmpIcon } from '../../assets/icons/slack.svg'; +import boostCamIcon from '../../assets/icons/cover_new.png'; const Container = styled.div` width: 100vw; height: 100vh; - background-color: #c4c4c4; + background-color: #492148; display: flex; flex-direction: column; - justify-content: space-around; + justify-content: space-between; align-items: center; `; const LoginBox = styled.div` - min-width: 700px; + min-width: 500px; min-height: 600px; - background-color: skyblue; + background-color: white; border-radius: 20px; + margin: 30px 0px; + padding: 30px 0px; - display: flex; - flex-direction: row; - justify-content: space-around; - align-items: center; -`; - -const LeftDiv = styled.div` - width: 50%; - height: 100%; display: flex; flex-direction: column; justify-content: space-around; align-items: center; `; -const RightDiv = styled.div` - width: 50%; - display: flex; - flex-direction: column; - justify-content: space-around; - align-items: center; - - svg { - width: 50%; - height: 50%; - } -`; - const WelcomeMessage = styled.span` font-size: 30px; color: black; `; -const SplitLine = styled.hr` - width: 90%; - margin: 15px 0; +const Im = styled.img` + height: 200px; `; - function LoginMain(): JSX.Element { return ( - - Welcom to boostCam! - - - - Go To BoostCamMain - Go To CamRoom - - - - + Welcome to boostCam! + + ); diff --git a/frontend/src/components/LoginPage/OAuthLogin.tsx b/frontend/src/components/LoginPage/OAuthLogin.tsx index 13a6604..40bfb9a 100644 --- a/frontend/src/components/LoginPage/OAuthLogin.tsx +++ b/frontend/src/components/LoginPage/OAuthLogin.tsx @@ -3,28 +3,14 @@ import styled from 'styled-components'; const Container = styled.div` display: flex; - flex-direction: column; - justify-content: flex-start; - margin-left: 40px; + justify-content: center; + aligh-items: center; width: 100%; - height: 45%; -`; - -const DivTitle = styled.span` - width: 80%; - font-size: 15px; - color: black; -`; - -const ButtonDiv = styled.div` - height: 50%; - display: flex; - flex-direction: column; - justify-content: space-around; + max-width: 500px; `; const OAuthLoginButton = styled.div` - width: 80%; + width: 70%; margin-top: 15px; height: 25px; background-color: #4ddddf; @@ -52,11 +38,7 @@ function OAuthLogin(): JSX.Element { return ( - OAuth Login - - Github - Google - + Github ); } From b6d6091fc207dffe6f4508c44602d3fe4438e75a Mon Sep 17 00:00:00 2001 From: korung3195 Date: Thu, 25 Nov 2021 13:55:36 +0900 Subject: [PATCH 137/172] =?UTF-8?q?Fix=20:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fromEntry 메소드를 이름에 맞게 cam entry를 입력받아서 Dto를 반환하는 방식으로 변경 - checkRoomExist fetch url 변경 --- backend/src/cam/cam.dto.ts | 6 ++++-- backend/src/cam/cam.service.ts | 2 +- frontend/src/components/Cam/Cam.tsx | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/src/cam/cam.dto.ts b/backend/src/cam/cam.dto.ts index 75080bc..a12c848 100644 --- a/backend/src/cam/cam.dto.ts +++ b/backend/src/cam/cam.dto.ts @@ -1,3 +1,5 @@ +import { Cam } from './cam.entity'; + export type CreateCamDto = { name: string; serverId: number; @@ -11,7 +13,7 @@ export class ResponseCamDto { this.url = url; } - static fromEntry(name: string, url: string) { - return new ResponseCamDto(name, url); + static fromEntry(cam: Cam) { + return new ResponseCamDto(cam.name, cam.url); } } diff --git a/backend/src/cam/cam.service.ts b/backend/src/cam/cam.service.ts index b0a8ac9..d5074b8 100644 --- a/backend/src/cam/cam.service.ts +++ b/backend/src/cam/cam.service.ts @@ -53,6 +53,6 @@ export class CamService { async getCamList(serverId: number): Promise { const res = await this.camRepository.findByServerId(serverId); - return res.map((entry) => ResponseCamDto.fromEntry(entry.name, entry.url)); + return res.map((entry) => ResponseCamDto.fromEntry(entry)); } } diff --git a/frontend/src/components/Cam/Cam.tsx b/frontend/src/components/Cam/Cam.tsx index 4118ba7..ff746aa 100644 --- a/frontend/src/components/Cam/Cam.tsx +++ b/frontend/src/components/Cam/Cam.tsx @@ -42,7 +42,7 @@ function Cam(): JSX.Element { const camRef = useRef(null); const checkRoomExist = async (roomId: string) => { - const response = await fetch(`api/cam/${roomId}`, { + const response = await fetch(`/api/cam/${roomId}`, { method: 'GET', headers: { 'Content-Type': 'application/json', From 5740ea20513d9551feeebd1ab653a575b5d123de Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Thu, 25 Nov 2021 14:13:22 +0900 Subject: [PATCH 138/172] =?UTF-8?q?Fix=20:=20=EC=9E=98=EB=AA=BB=EB=90=9C?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EB=AA=85=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/Main/ChannelModal/CreateChannelModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Main/ChannelModal/CreateChannelModal.tsx b/frontend/src/components/Main/ChannelModal/CreateChannelModal.tsx index 1c47454..77a5e04 100644 --- a/frontend/src/components/Main/ChannelModal/CreateChannelModal.tsx +++ b/frontend/src/components/Main/ChannelModal/CreateChannelModal.tsx @@ -147,7 +147,7 @@ type CreateModalForm = { description: string; }; -function CreateChannelModal2(): JSX.Element { +function CreateChannelModal(): JSX.Element { const { register, handleSubmit, @@ -221,4 +221,4 @@ function CreateChannelModal2(): JSX.Element { ); } -export default CreateChannelModal2; +export default CreateChannelModal; From 9af4aac9afdb9d48916ede2b4d2f0048adfc84c9 Mon Sep 17 00:00:00 2001 From: korung3195 Date: Thu, 25 Nov 2021 14:26:31 +0900 Subject: [PATCH 139/172] =?UTF-8?q?Feat=20:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 깃허브 아이콘을 추가하였습니다. - 로그인 버튼 스타일을 변경하였습니다. --- frontend/src/assets/icons/github.svg | 3 ++ .../src/components/LoginPage/OAuthLogin.tsx | 33 +++++++++++++++---- frontend/src/utils/SvgIcons.ts | 3 ++ 3 files changed, 32 insertions(+), 7 deletions(-) create mode 100644 frontend/src/assets/icons/github.svg diff --git a/frontend/src/assets/icons/github.svg b/frontend/src/assets/icons/github.svg new file mode 100644 index 0000000..838282d --- /dev/null +++ b/frontend/src/assets/icons/github.svg @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/frontend/src/components/LoginPage/OAuthLogin.tsx b/frontend/src/components/LoginPage/OAuthLogin.tsx index 40bfb9a..555baac 100644 --- a/frontend/src/components/LoginPage/OAuthLogin.tsx +++ b/frontend/src/components/LoginPage/OAuthLogin.tsx @@ -1,5 +1,8 @@ import React from 'react'; import styled from 'styled-components'; +import { BoostCamMainIcons } from '../../utils/SvgIcons'; + +const { Github } = BoostCamMainIcons; const Container = styled.div` display: flex; @@ -13,18 +16,29 @@ const OAuthLoginButton = styled.div` width: 70%; margin-top: 15px; height: 25px; - background-color: #4ddddf; + background-color: #92508f; border-radius: 10px; - padding: 5px 10px; + padding: 8px 10px; cursor: pointer; text-align: center; box-shadow: 5px 3px 3px #7c7b7b; transition: all 0.3s; + color: white; + + display: flex; + justify-content: center; + align-items: centerl; +`; + +const GithubIcon = styled(Github)` + width: 24px; + margin: -3px 20px 0 0; + position: absolute; + left: -40px; +`; - &:hover { - padding: 8px 15px; - transition: all 0.3s; - } +const Content = styled.div` + position: relative; `; function OAuthLogin(): JSX.Element { @@ -38,7 +52,12 @@ function OAuthLogin(): JSX.Element { return ( - Github + + + + Log in with Github + + ); } diff --git a/frontend/src/utils/SvgIcons.ts b/frontend/src/utils/SvgIcons.ts index 6569354..9ad021b 100644 --- a/frontend/src/utils/SvgIcons.ts +++ b/frontend/src/utils/SvgIcons.ts @@ -15,6 +15,8 @@ import { ReactComponent as Plus } from '../assets/icons/plus.svg'; import { ReactComponent as ListArrow } from '../assets/icons/listarrow.svg'; import { ReactComponent as Close } from '../assets/icons/close.svg'; +import { ReactComponent as Github } from '../assets/icons/github.svg'; + export const ButtonBarIcons = { MicIcon, MicDisabledIcon, @@ -35,4 +37,5 @@ export const BoostCamMainIcons = { Plus, ListArrow, Close, + Github, }; From b38e03b4f64c17282aece4266dbd311007b78dc8 Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Thu, 25 Nov 2021 14:54:41 +0900 Subject: [PATCH 140/172] =?UTF-8?q?Feat=20:=20Server=20=EC=B0=B8=EA=B0=80?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server에 참가할 때 Server참가 코드를 입력하여 참가하도록 변경하였습니다. --- backend/src/server/server.entity.ts | 3 +++ backend/src/server/server.service.ts | 8 +++++++- .../src/user-server/user-server.controller.ts | 7 ++----- backend/src/user-server/user-server.service.ts | 6 +++--- .../Main/ServerModal/JoinServerModal.tsx | 16 ++++++++-------- 5 files changed, 23 insertions(+), 17 deletions(-) diff --git a/backend/src/server/server.entity.ts b/backend/src/server/server.entity.ts index 229f983..472026c 100644 --- a/backend/src/server/server.entity.ts +++ b/backend/src/server/server.entity.ts @@ -23,6 +23,9 @@ export class Server { @Column() imgUrl: string; + @Column() + code: string; + @ManyToOne(() => User) @JoinColumn({ referencedColumnName: 'id' }) owner: User; diff --git a/backend/src/server/server.service.ts b/backend/src/server/server.service.ts index 776a740..039733d 100644 --- a/backend/src/server/server.service.ts +++ b/backend/src/server/server.service.ts @@ -6,6 +6,7 @@ import { Injectable, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { v4 } from 'uuid'; import { User } from '../user/user.entity'; import { Server } from './server.entity'; @@ -30,6 +31,10 @@ export class ServerService { return this.serverRepository.findOne({ id: id }); } + findByCode(code: string): Promise { + return this.serverRepository.findOne({ code }); + } + async create( user: User, requestServerDto: RequestServerDto, @@ -38,9 +43,10 @@ export class ServerService { const server = requestServerDto.toServerEntity(); server.owner = user; server.imgUrl = imgUrl || ''; + server.code = v4(); const createdServer = await this.serverRepository.save(server); - this.userServerService.create(user, createdServer.id); + this.userServerService.create(user, createdServer.code); return createdServer; } diff --git a/backend/src/user-server/user-server.controller.ts b/backend/src/user-server/user-server.controller.ts index 4583f6f..32251f6 100644 --- a/backend/src/user-server/user-server.controller.ts +++ b/backend/src/user-server/user-server.controller.ts @@ -25,14 +25,11 @@ export class UserServerController { async createUserServer( @Session() session: ExpressSession, - @Body() server: Server, + @Body() code: string, ) { try { const user = session.user; - const newUserServer = await this.userServerService.create( - user, - server.id, - ); + const newUserServer = await this.userServerService.create(user, code); return ResponseEntity.created(newUserServer.id); } catch (error) { if (error instanceof HttpException) { diff --git a/backend/src/user-server/user-server.service.ts b/backend/src/user-server/user-server.service.ts index cd511aa..b45935f 100644 --- a/backend/src/user-server/user-server.service.ts +++ b/backend/src/user-server/user-server.service.ts @@ -22,17 +22,17 @@ export class UserServerService { private userServerRepository: UserServerRepository, ) {} - async create(user: User, serverId: number): Promise { + async create(user: User, code: string): Promise { const newUserServer = new UserServer(); newUserServer.user = user; - newUserServer.server = await this.serverService.findOne(serverId); + newUserServer.server = await this.serverService.findByCode(code); if (newUserServer.server == undefined) { throw new BadRequestException('존재하지 않는 서버입니다.'); } const userServer = await this.userServerRepository.findByUserIdAndServerId( user.id, - serverId, + newUserServer.server.id, ); if (userServer !== undefined) { throw new BadRequestException('이미 등록된 서버입니다.'); diff --git a/frontend/src/components/Main/ServerModal/JoinServerModal.tsx b/frontend/src/components/Main/ServerModal/JoinServerModal.tsx index 8d72bf1..1dd9d93 100644 --- a/frontend/src/components/Main/ServerModal/JoinServerModal.tsx +++ b/frontend/src/components/Main/ServerModal/JoinServerModal.tsx @@ -148,7 +148,7 @@ const CloseIcon = styled(Close)` `; type JoinServerModalForm = { - serverId: string; + code: string; }; function JoinServerModal(): JSX.Element { @@ -162,15 +162,15 @@ function JoinServerModal(): JSX.Element { const [isButtonActive, setIsButtonActive] = useState(false); const [messageFailToPost, setMessageFailToPost] = useState(''); - const onSubmitJoinServerModal = async (data: { serverId: string }) => { - const { serverId } = data; + const onSubmitJoinServerModal = async (data: { code: string }) => { + const { code } = data; const response = await fetch('api/users/servers', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ - id: serverId.trim(), + id: code.trim(), }), }); @@ -184,8 +184,8 @@ function JoinServerModal(): JSX.Element { }; useEffect(() => { - const { serverId } = watch(); - const isActive = serverId.trim().length > 0; + const { code } = watch(); + const isActive = code.trim().length > 0; setIsButtonActive(isActive); }, [watch()]); @@ -204,12 +204,12 @@ function JoinServerModal(): JSX.Element { 참가 코드 value.trim().length > 0 || '"참가코드" 칸을 입력해주세요!', })} placeholder="참가코드를 입력해주세요" /> - {errors.serverId && {errors.serverId.message}} + {errors.code && {errors.code.message}} {messageFailToPost} From fbef7a759821cd8f4deaa6ec2568dea3a36a662a Mon Sep 17 00:00:00 2001 From: K Date: Thu, 25 Nov 2021 11:43:43 +0900 Subject: [PATCH 141/172] =?UTF-8?q?Feat=20:=20session=EC=9D=84=20redis?= =?UTF-8?q?=EB=A1=9C=20=EC=82=AC=EC=9A=A9=ED=95=98=EA=B8=B0=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit redis, connect-redis(express-session) 및 type 추가 --- backend/package-lock.json | 163 ++++++++++++++++++++++++++++++++++++++ backend/package.json | 4 + 2 files changed, 167 insertions(+) diff --git a/backend/package-lock.json b/backend/package-lock.json index 2c2aa7b..75542e5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -18,9 +18,11 @@ "@nestjs/websockets": "^8.1.2", "aws-sdk": "^2.1033.0", "axios": "^0.24.0", + "connect-redis": "^6.0.0", "express-session": "^1.17.2", "mysql2": "^2.3.3", "peer": "^0.6.1", + "redis": "^3.1.2", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.2.0", @@ -31,11 +33,13 @@ "@nestjs/cli": "^8.0.0", "@nestjs/schematics": "^8.0.0", "@nestjs/testing": "^8.0.0", + "@types/connect-redis": "^0.0.17", "@types/express": "^4.17.13", "@types/express-session": "^1.17.4", "@types/jest": "^27.0.1", "@types/multer": "^1.4.7", "@types/node": "^16.0.0", + "@types/redis": "^2.8.32", "@types/supertest": "^2.0.11", "@types/uuid": "^8.3.3", "@typescript-eslint/eslint-plugin": "^5.0.0", @@ -1883,6 +1887,18 @@ "@types/node": "*" } }, + "node_modules/@types/connect-redis": { + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/@types/connect-redis/-/connect-redis-0.0.17.tgz", + "integrity": "sha512-6apVqZFTMspIWwjGPfip7fKP+6Sl3M589DuYuYtzyc+Fzn2fnZqtQQ2o/UfwDlLh6zxq0RAnFbW3Mx1+2Nuh0w==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/express-session": "*", + "@types/ioredis": "*", + "@types/redis": "*" + } + }, "node_modules/@types/cookie": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", @@ -1964,6 +1980,15 @@ "@types/node": "*" } }, + "node_modules/@types/ioredis": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.28.1.tgz", + "integrity": "sha512-raYHPqRWrfnEoym94BY28mG1+tcZqh3dsp2q7x5IyMAAEvIdu+H0X8diASMpncIm+oHyH9dalOeOnGOL/YnuOA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz", @@ -2051,6 +2076,15 @@ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==" }, + "node_modules/@types/redis": { + "version": "2.8.32", + "resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.32.tgz", + "integrity": "sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/serve-static": { "version": "1.13.10", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz", @@ -3494,6 +3528,14 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/connect-redis": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-6.0.0.tgz", + "integrity": "sha512-6eGEAAPHYvcfbRNCMmPzBIjrqRWLw7at9lCUH4G6NQ8gwWDJelaUmFNOqPIhehbw941euVmIuqWsaWiKXfb+5g==", + "engines": { + "node": ">=12" + } + }, "node_modules/consola": { "version": "2.15.3", "resolved": "https://registry.npmjs.org/consola/-/consola-2.15.3.tgz", @@ -7811,6 +7853,56 @@ "node": ">= 0.10" } }, + "node_modules/redis": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz", + "integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==", + "dependencies": { + "denque": "^1.5.0", + "redis-commands": "^1.7.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-redis" + } + }, + "node_modules/redis-commands": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz", + "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==" + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/redis/node_modules/denque": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", + "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==", + "engines": { + "node": ">=0.10" + } + }, "node_modules/reflect-metadata": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", @@ -11277,6 +11369,18 @@ "@types/node": "*" } }, + "@types/connect-redis": { + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/@types/connect-redis/-/connect-redis-0.0.17.tgz", + "integrity": "sha512-6apVqZFTMspIWwjGPfip7fKP+6Sl3M589DuYuYtzyc+Fzn2fnZqtQQ2o/UfwDlLh6zxq0RAnFbW3Mx1+2Nuh0w==", + "dev": true, + "requires": { + "@types/express": "*", + "@types/express-session": "*", + "@types/ioredis": "*", + "@types/redis": "*" + } + }, "@types/cookie": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", @@ -11358,6 +11462,15 @@ "@types/node": "*" } }, + "@types/ioredis": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.28.1.tgz", + "integrity": "sha512-raYHPqRWrfnEoym94BY28mG1+tcZqh3dsp2q7x5IyMAAEvIdu+H0X8diASMpncIm+oHyH9dalOeOnGOL/YnuOA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/istanbul-lib-coverage": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz", @@ -11445,6 +11558,15 @@ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==" }, + "@types/redis": { + "version": "2.8.32", + "resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.32.tgz", + "integrity": "sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/serve-static": { "version": "1.13.10", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz", @@ -12555,6 +12677,11 @@ } } }, + "connect-redis": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-6.0.0.tgz", + "integrity": "sha512-6eGEAAPHYvcfbRNCMmPzBIjrqRWLw7at9lCUH4G6NQ8gwWDJelaUmFNOqPIhehbw941euVmIuqWsaWiKXfb+5g==" + }, "consola": { "version": "2.15.3", "resolved": "https://registry.npmjs.org/consola/-/consola-2.15.3.tgz", @@ -15837,6 +15964,42 @@ "resolve": "^1.1.6" } }, + "redis": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz", + "integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==", + "requires": { + "denque": "^1.5.0", + "redis-commands": "^1.7.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0" + }, + "dependencies": { + "denque": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", + "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==" + } + } + }, + "redis-commands": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz", + "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==" + }, + "redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=" + }, + "redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=", + "requires": { + "redis-errors": "^1.0.0" + } + }, "reflect-metadata": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", diff --git a/backend/package.json b/backend/package.json index 325ce58..e5c15f9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -30,9 +30,11 @@ "@nestjs/websockets": "^8.1.2", "aws-sdk": "^2.1033.0", "axios": "^0.24.0", + "connect-redis": "^6.0.0", "express-session": "^1.17.2", "mysql2": "^2.3.3", "peer": "^0.6.1", + "redis": "^3.1.2", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.2.0", @@ -43,11 +45,13 @@ "@nestjs/cli": "^8.0.0", "@nestjs/schematics": "^8.0.0", "@nestjs/testing": "^8.0.0", + "@types/connect-redis": "^0.0.17", "@types/express": "^4.17.13", "@types/express-session": "^1.17.4", "@types/jest": "^27.0.1", "@types/multer": "^1.4.7", "@types/node": "^16.0.0", + "@types/redis": "^2.8.32", "@types/supertest": "^2.0.11", "@types/uuid": "^8.3.3", "@typescript-eslint/eslint-plugin": "^5.0.0", From 363af24498165cb0ad9273ff65c8233610d09f21 Mon Sep 17 00:00:00 2001 From: K Date: Thu, 25 Nov 2021 11:45:04 +0900 Subject: [PATCH 142/172] =?UTF-8?q?Feat=20:=20.env=20sample=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REDIS_HOST, SESSION 종류, SESSION_SECRET 등 추가 이와 관련해서 sample이 아닌 것들을 gitignore에 추가하였습니다. --- backend/.env.redis.sample | 1 + backend/.env.sample | 2 ++ backend/.gitignore | 1 + 3 files changed, 4 insertions(+) create mode 100644 backend/.env.redis.sample diff --git a/backend/.env.redis.sample b/backend/.env.redis.sample new file mode 100644 index 0000000..84a5dfd --- /dev/null +++ b/backend/.env.redis.sample @@ -0,0 +1 @@ +REDIS_HOST=127.0.0.1 diff --git a/backend/.env.sample b/backend/.env.sample index a2000a7..4715b69 100644 --- a/backend/.env.sample +++ b/backend/.env.sample @@ -6,3 +6,5 @@ NCP_STORAGE_ACCESS_KEY= NCP_STORAGE_SECRET_KEY= NCP_STORAGE_BUCKET_NAME= NCP_STORAGE_REGION= +SESSION=redis +SESSION_SECRET=my_secret diff --git a/backend/.gitignore b/backend/.gitignore index d94f89a..763cf4d 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -30,6 +30,7 @@ lerna-debug.log* # .env .env .env.github +.env.redis # IDE - VSCode .vscode/* From bd2a02352a47f9c9abbd9448f0c878caa76226a5 Mon Sep 17 00:00:00 2001 From: K Date: Thu, 25 Nov 2021 11:47:36 +0900 Subject: [PATCH 143/172] =?UTF-8?q?Feat=20:=20SESSION=20=EC=9D=B4=20redis?= =?UTF-8?q?=EC=9D=B8=20=EA=B2=BD=EC=9A=B0,=20SessionStore=EB=A1=9C=20redis?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit module에서는 .env를 사용하도록 추가하였고, main.ts에서 이를 이용하여 설정하도록 수정하였습니다. --- backend/src/app.module.ts | 2 +- backend/src/main.ts | 25 ++++++++++++++++++------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 853bf75..dcd1421 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -22,7 +22,7 @@ import githubConfig from './config/github.config'; imports: [ ConfigModule.forRoot({ load: [githubConfig], - envFilePath: ['.env', '.env.github'], + envFilePath: ['.env', '.env.github', '.env.redis'], isGlobal: true, }), TypeOrmModule.forRoot(ormConfig()), diff --git a/backend/src/main.ts b/backend/src/main.ts index c01a2d2..0dbabf4 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,22 +1,33 @@ import { NestFactory } from '@nestjs/core'; import { ExpressPeerServer } from 'peer'; import * as session from 'express-session'; +import * as redis from 'redis'; +import * as createRedisStore from 'connect-redis'; import { AppModule } from './app.module'; +import { ConfigService } from '@nestjs/config'; async function bootstrap() { const app = await NestFactory.create(AppModule); const server = app.getHttpServer(); const peerServer = ExpressPeerServer(server); + const configService = app.get(ConfigService); - app.use( - session({ - secret: 'my-secret', - resave: false, - saveUninitialized: false, - }), - ); + const sessionOption: session.SessionOptions = { + secret: configService.get('SESSION_SECRET'), + resave: false, + saveUninitialized: false, + }; + if (configService.get('SESSION') === 'redis') { + const redisClient = redis.createClient({ + host: configService.get('REDIS_HOST'), + }); + const RedisStore = createRedisStore(session); + sessionOption.store = new RedisStore({ client: redisClient }); + } + + app.use(session(sessionOption)); app.use('/peerjs', peerServer); await app.listen(9000); } From ca892ae087604b97549cc97b08976360725b125c Mon Sep 17 00:00:00 2001 From: K Date: Thu, 25 Nov 2021 11:48:45 +0900 Subject: [PATCH 144/172] =?UTF-8?q?Chore=20:=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=8B=9C=EC=97=90=20=ED=95=B4=EB=8B=B9=20=EB=B3=80=EC=88=98?= =?UTF-8?q?=EB=93=A4=EC=9D=84=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20.env=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cicd.yml | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index ce2b9f0..862b0c6 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -63,6 +63,9 @@ jobs: ^NCP_STORAGE_SECRET_KEY: ${{ secrets.NCP_STORAGE_SECRET_KEY }} ^NCP_STORAGE_BUCKET_NAME: ${{ secrets.NCP_STORAGE_BUCKET_NAME }} ^NCP_STORAGE_REGION: ${{ secrets.NCP_STORAGE_REGION }} + ^SESSION: ${{ secrets.SESSION }} + ^SESSION_SECRET: ${{ secrets.SESSION_SECRET }} + - name: Create .env.github uses: weyheyhey/create-dotenv-action@v1 with: @@ -72,8 +75,15 @@ jobs: ^CLIENT_ID_GITHUB: ${{ secrets.CLIENT_ID_GITHUB }} ^CLIENT_SECRET_GITHUB: ${{ secrets.CLIENT_SECRET_GITHUB }} ^CALLBACK_URL_GITHUB: ${{ secrets.CALLBACK_URL_GITHUB }} + - name: Create .env.redis + uses: weyheyhey/create-dotenv-action@v1 + with: + wildecard: '^' + filename: '.env.redis' + env: + ^REDIS_HOST: ${{ secrets.REDIS_HOST }} - name: Move .envs - run: mv .env .env.github ./backend + run: mv .env .env.github .env.redis ./backend - name: Test run: npm run test working-directory: ${{env.working-directory}} @@ -146,6 +156,8 @@ jobs: ^NCP_STORAGE_SECRET_KEY: ${{ secrets.NCP_STORAGE_SECRET_KEY }} ^NCP_STORAGE_BUCKET_NAME: ${{ secrets.NCP_STORAGE_BUCKET_NAME }} ^NCP_STORAGE_REGION: ${{ secrets.NCP_STORAGE_REGION }} + ^SESSION: ${{ secrets.SESSION }} + ^SESSION_SECRET: ${{ secrets.SESSION_SECRET }} - name: Create .env.github uses: weyheyhey/create-dotenv-action@v1 @@ -156,8 +168,15 @@ jobs: ^CLIENT_ID_GITHUB: ${{ secrets.CLIENT_ID_GITHUB }} ^CLIENT_SECRET_GITHUB: ${{ secrets.CLIENT_SECRET_GITHUB }} ^CALLBACK_URL_GITHUB: ${{ secrets.CALLBACK_URL_GITHUB }} + - name: Create .env.redis + uses: weyheyhey/create-dotenv-action@v1 + with: + wildecard: '^' + filename: '.env.redis' + env: + ^REDIS_HOST: ${{ secrets.REDIS_HOST }} - name: Move .envs - run: mv .env .env.github ./backend + run: mv .env .env.github .env.redis ./backend - name: Build run: npm run build working-directory: ${{env.working-directory}} From c24901c5f76991b8ed261b2e8c4b85bf94172ada Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Thu, 25 Nov 2021 15:39:56 +0900 Subject: [PATCH 145/172] =?UTF-8?q?Feat=20:=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=B0=B8=EC=97=AC=EC=BD=94=EB=93=9C=20=EC=9E=AC=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버 참여 코드를 재생성할 수 있는 기능을 추가하고 연결하였습니다. --- backend/src/server/server.controller.ts | 14 ++++++ backend/src/server/server.service.ts | 12 +++++ .../src/user-server/user-server.controller.ts | 3 +- .../Main/ServerModal/JoinServerModal.tsx | 8 +--- .../Main/ServerModal/ServerSettingModal.tsx | 45 +++++++++++++++++-- 5 files changed, 70 insertions(+), 12 deletions(-) diff --git a/backend/src/server/server.controller.ts b/backend/src/server/server.controller.ts index 4886133..d420e3a 100644 --- a/backend/src/server/server.controller.ts +++ b/backend/src/server/server.controller.ts @@ -48,6 +48,20 @@ export class ServerController { return ResponseEntity.ok(cam); } + @Get('/:id/code') async findCode( + @Param('id') id: number, + ): Promise> { + const code = await this.serverService.findCode(id); + return ResponseEntity.ok(code); + } + + @Patch('/:id/code') async refreshCode( + @Param('id') id: number, + ): Promise> { + const code = await this.serverService.refreshCode(id); + return ResponseEntity.ok(code); + } + @Post() @UseGuards(LoginGuard) @UseInterceptors(FileInterceptor('icon')) diff --git a/backend/src/server/server.service.ts b/backend/src/server/server.service.ts index 039733d..684bd0b 100644 --- a/backend/src/server/server.service.ts +++ b/backend/src/server/server.service.ts @@ -35,6 +35,18 @@ export class ServerService { return this.serverRepository.findOne({ code }); } + async findCode(id: number): Promise { + const server = await this.serverRepository.findOne(id); + return server.code; + } + + async refreshCode(id: number): Promise { + const server = await this.serverRepository.findOne(id); + server.code = v4(); + this.serverRepository.save(server); + return server.code; + } + async create( user: User, requestServerDto: RequestServerDto, diff --git a/backend/src/user-server/user-server.controller.ts b/backend/src/user-server/user-server.controller.ts index 32251f6..b748ed5 100644 --- a/backend/src/user-server/user-server.controller.ts +++ b/backend/src/user-server/user-server.controller.ts @@ -10,7 +10,6 @@ import { HttpCode, HttpStatus, } from '@nestjs/common'; -import { Server } from '../server/server.entity'; import { LoginGuard } from '../login/login.guard'; import { ExpressSession } from '../types/session'; import { UserServerService } from './user-server.service'; @@ -25,7 +24,7 @@ export class UserServerController { async createUserServer( @Session() session: ExpressSession, - @Body() code: string, + @Body() { code }, ) { try { const user = session.user; diff --git a/frontend/src/components/Main/ServerModal/JoinServerModal.tsx b/frontend/src/components/Main/ServerModal/JoinServerModal.tsx index 1dd9d93..e54c3fd 100644 --- a/frontend/src/components/Main/ServerModal/JoinServerModal.tsx +++ b/frontend/src/components/Main/ServerModal/JoinServerModal.tsx @@ -166,12 +166,8 @@ function JoinServerModal(): JSX.Element { const { code } = data; const response = await fetch('api/users/servers', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - id: code.trim(), - }), + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: code.trim() }), }); if (response.status === 201) { diff --git a/frontend/src/components/Main/ServerModal/ServerSettingModal.tsx b/frontend/src/components/Main/ServerModal/ServerSettingModal.tsx index 616dbf7..8225a22 100644 --- a/frontend/src/components/Main/ServerModal/ServerSettingModal.tsx +++ b/frontend/src/components/Main/ServerModal/ServerSettingModal.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useState } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import styled from 'styled-components'; import { MainStoreContext } from '../MainStore'; @@ -151,6 +151,7 @@ function ServerSettingModal(): JSX.Element { const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [files, setFiles] = useState(); + const [code, setCode] = useState(); const serverId = selectedServer?.server.id; @@ -205,6 +206,42 @@ function ServerSettingModal(): JSX.Element { } }; + const onClickRefreshCode = async () => { + if (serverId) { + const response = await fetch(`api/servers/${serverId}/code`, { method: 'PATCH' }); + + if (response.status === 200) { + const body = await response.json(); + setCode(body.data); + } else { + const body = await response.json(); + setMessageFailToPost(body.message); + } + } else { + setMessageFailToPost('선택된 서버가 없습니다.'); + } + }; + + const setServerParticipationCode = async () => { + if (serverId) { + const response = await fetch(`api/servers/${serverId}/code`); + + if (response.status === 200) { + const body = await response.json(); + setCode(body.data); + } else { + const body = await response.json(); + setMessageFailToPost(body.message); + } + } else { + setMessageFailToPost('선택된 서버가 없습니다.'); + } + }; + + useEffect(() => { + setServerParticipationCode(); + }, []); + /* eslint-disable react/jsx-props-no-spreading */ return ( @@ -244,10 +281,10 @@ function ServerSettingModal(): JSX.Element { 제출 - 서버 URL 재생성 + 서버 참여 코드 재생성 - - + + 생성 From 06fee94527f678339b17918680f7cdb95272d674 Mon Sep 17 00:00:00 2001 From: korung3195 Date: Thu, 25 Nov 2021 15:41:09 +0900 Subject: [PATCH 146/172] =?UTF-8?q?Refactor=20:=20socket=EC=9D=84=20recoil?= =?UTF-8?q?=20=EB=8C=80=EC=8B=A0=20store=EC=97=90=EC=84=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존에 socket을 recoil에서 전역 state로 관리했더니 cam에서 나가도 socket disconnect가 발생하지 않아서 이제부터 socket을 context로 cam 내부에서만 생성해서 사용하도록 변경하였습니다. --- frontend/src/components/Cam/CamStore.tsx | 9 +++++---- frontend/src/components/Cam/Chatting/ChattingTab.tsx | 5 +---- .../src/components/Cam/Nickname/NicknameModal.tsx | 7 +++---- frontend/src/components/Cam/Screen/UserScreen.tsx | 11 +++++------ .../components/Cam/SharedScreen/SharedScreenStore.tsx | 5 ++--- 5 files changed, 16 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/Cam/CamStore.tsx b/frontend/src/components/Cam/CamStore.tsx index ede0459..5b5261f 100644 --- a/frontend/src/components/Cam/CamStore.tsx +++ b/frontend/src/components/Cam/CamStore.tsx @@ -1,8 +1,7 @@ import React, { createContext } from 'react'; -import { useRecoilValue } from 'recoil'; +import { io } from 'socket.io-client'; import useUserMedia from '../../hooks/useUserMedia'; -import SocketState from '../../atoms/socket'; import { UserInfo } from '../../types/cam'; type CamStoreProps = { @@ -12,10 +11,10 @@ type CamStoreProps = { }; export const CamStoreContext = createContext(null); +const socket = io(); function CamStore(props: CamStoreProps): JSX.Element { const { children, userInfo, setUserInfo } = props; - const socket = useRecoilValue(SocketState); const currentURL = new URL(window.location.href); const roomId = currentURL.searchParams.get('roomid'); const { localStatus, localStream, setLocalStatus, screenList } = useUserMedia({ @@ -25,7 +24,9 @@ function CamStore(props: CamStoreProps): JSX.Element { }); return ( - + {children} ); diff --git a/frontend/src/components/Cam/Chatting/ChattingTab.tsx b/frontend/src/components/Cam/Chatting/ChattingTab.tsx index 9535cf5..edcb776 100644 --- a/frontend/src/components/Cam/Chatting/ChattingTab.tsx +++ b/frontend/src/components/Cam/Chatting/ChattingTab.tsx @@ -1,8 +1,6 @@ import React, { useEffect, useState, useRef, useContext } from 'react'; -import { useRecoilValue } from 'recoil'; import styled from 'styled-components'; -import socketState from '../../../atoms/socket'; import STTScreen from '../STT/STTScreen'; import { ToggleStoreContext } from '../ToggleStore'; import { CamStoreContext } from '../CamStore'; @@ -154,8 +152,7 @@ const getCurrentDate = (): CurrentDate => { function ChattingTab(): JSX.Element { const { isChattingTabActive, isMouseOnCamPage } = useContext(ToggleStoreContext); - const { userInfo, setLocalStatus } = useContext(CamStoreContext); - const socket = useRecoilValue(socketState); + const { userInfo, setLocalStatus, socket } = useContext(CamStoreContext); const [chatLogs, setChatLogs] = useState([]); const [nicknameList, setNicknameList] = useState([ { diff --git a/frontend/src/components/Cam/Nickname/NicknameModal.tsx b/frontend/src/components/Cam/Nickname/NicknameModal.tsx index 7d39cff..1b547c1 100644 --- a/frontend/src/components/Cam/Nickname/NicknameModal.tsx +++ b/frontend/src/components/Cam/Nickname/NicknameModal.tsx @@ -1,9 +1,8 @@ -import React from 'react'; -import { useRecoilValue } from 'recoil'; +import React, { useContext } from 'react'; import styled from 'styled-components'; -import socketState from '../../../atoms/socket'; import { UserInfo } from '../../../types/cam'; +import { CamStoreContext } from '../CamStore'; const Container = styled.div` position: fixed; @@ -83,7 +82,7 @@ type NicknameModalProps = { function NicknameModal(props: NicknameModalProps): JSX.Element { const { setUserInfo, setIsActiveNicknameModal } = props; - const socket = useRecoilValue(socketState); + const { socket } = useContext(CamStoreContext); const onSubmitNicknameForm = (e: React.FormEvent) => { e.preventDefault(); diff --git a/frontend/src/components/Cam/Screen/UserScreen.tsx b/frontend/src/components/Cam/Screen/UserScreen.tsx index d0a8965..f891fae 100644 --- a/frontend/src/components/Cam/Screen/UserScreen.tsx +++ b/frontend/src/components/Cam/Screen/UserScreen.tsx @@ -1,12 +1,11 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { useRecoilValue } from 'recoil'; +import React, { useContext, useEffect, useRef, useState } from 'react'; import styled from 'styled-components'; -import socketState from '../../../atoms/socket'; import DefaultScreen from './DefaultScreen'; import type { Control, Status } from '../../../types/cam'; import StreamStatusIndicator from './StreamStatusIndicator'; import ControlMenu from './ControlMenu'; +import { CamStoreContext } from '../CamStore'; type UserScreenProps = { stream: MediaStream | undefined; @@ -31,7 +30,7 @@ const Video = styled.video<{ isSpeaking: boolean }>` function UserScreen(props: UserScreenProps): JSX.Element { const { stream, userId } = props; - const socket = useRecoilValue(socketState); + const { socket } = useContext(CamStoreContext); const videoRef = useRef(null); const [nickname, setNickname] = useState('김철수'); const [status, setStatus] = useState({ @@ -59,12 +58,12 @@ function UserScreen(props: UserScreenProps): JSX.Element { }); useEffect(() => { - socket.on('userStatus', (payload) => { + socket.on('userStatus', (payload: { userId: string; status: Status }) => { if (payload.userId === userId) { setStatus(payload.status); } }); - socket.on('userNickname', (payload) => { + socket.on('userNickname', (payload: { userId: string; userNickname: string }) => { if (payload.userId === userId) { setNickname(payload.userNickname); } diff --git a/frontend/src/components/Cam/SharedScreen/SharedScreenStore.tsx b/frontend/src/components/Cam/SharedScreen/SharedScreenStore.tsx index 64ad298..cd826e9 100644 --- a/frontend/src/components/Cam/SharedScreen/SharedScreenStore.tsx +++ b/frontend/src/components/Cam/SharedScreen/SharedScreenStore.tsx @@ -1,10 +1,9 @@ import React, { createContext, useContext, useEffect, useRef, useState } from 'react'; -import { useRecoilValue } from 'recoil'; import SharedScreenReceiver from './SharedScreenReceiver'; -import SocketState from '../../../atoms/socket'; import SharedScreenSender from './SharedScreenSender'; import { ToggleStoreContext } from '../ToggleStore'; +import { CamStoreContext } from '../CamStore'; type SharedScreenStoreProps = { children: React.ReactChild[] | React.ReactChild; @@ -14,7 +13,7 @@ export const SharedScreenStoreContext = createContext(null function SharedScreenStore(props: SharedScreenStoreProps): JSX.Element { const { children } = props; - const socket = useRecoilValue(SocketState); + const { socket } = useContext(CamStoreContext); const [sharedScreen, setSharedScreen] = useState(null); const [sharedFromMe, setSharedFromMe] = useState(false); From 7998d1c3572e067a2f2c02ed915078b4632c94a1 Mon Sep 17 00:00:00 2001 From: korung3195 Date: Thu, 25 Nov 2021 15:53:21 +0900 Subject: [PATCH 147/172] =?UTF-8?q?Fix=20:=20ListArrowIcon=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B2=84=EA=B7=B8=EA=B0=80=20=EB=B0=9C=EC=83=9D?= =?UTF-8?q?=ED=95=98=EB=8D=98=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존에 콘솔창에서 `react does not recognize ~` 에러가 나던 현상을 transient props를 이용해서 해결하였습니다. --- frontend/src/components/Main/Cam/CamListHeader.tsx | 6 +++--- frontend/src/components/Main/ChannelListHeader.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/Main/Cam/CamListHeader.tsx b/frontend/src/components/Main/Cam/CamListHeader.tsx index 8849cc7..e244b92 100644 --- a/frontend/src/components/Main/Cam/CamListHeader.tsx +++ b/frontend/src/components/Main/Cam/CamListHeader.tsx @@ -35,12 +35,12 @@ const CamListHeaderButton = styled.div<{ isButtonVisible: boolean }>` visibility: ${(props) => (props.isButtonVisible ? 'visible' : 'hidden')}; `; -const ListArrowIcon = styled(ListArrow)<{ isListOpen: boolean }>` +const ListArrowIcon = styled(ListArrow)<{ $isListOpen: boolean }>` width: 20px; height: 20px; fill: #a69c96; transition: all ease-out 0.3s; - ${(props) => (props.isListOpen ? 'transform: rotate(90deg);' : 'transform: rotate(0deg);')} + ${(props) => (props.$isListOpen ? 'transform: rotate(90deg);' : 'transform: rotate(0deg);')} `; const PlusIcon = styled(Plus)` @@ -70,7 +70,7 @@ function CamListHeader(props: CamListHeaderProps): JSX.Element { onMouseLeave={() => setIsButtonVisible(false)} onClick={() => setIsListOpen(!isListOpen)} > - + Cam diff --git a/frontend/src/components/Main/ChannelListHeader.tsx b/frontend/src/components/Main/ChannelListHeader.tsx index 8e4ef42..e3cbe84 100644 --- a/frontend/src/components/Main/ChannelListHeader.tsx +++ b/frontend/src/components/Main/ChannelListHeader.tsx @@ -38,12 +38,12 @@ const ChannelListHeaderButton = styled.div<{ isButtonVisible: boolean }>` visibility: ${(props) => (props.isButtonVisible ? 'visible' : 'hidden')}; `; -const ListArrowIcon = styled(ListArrow)<{ isListOpen: boolean }>` +const ListArrowIcon = styled(ListArrow)<{ $isListOpen: boolean }>` width: 20px; height: 20px; fill: #a69c96; transition: all ease-out 0.3s; - ${(props) => (props.isListOpen ? 'transform: rotate(90deg);' : 'transform: rotate(0deg);')} + ${(props) => (props.$isListOpen ? 'transform: rotate(90deg);' : 'transform: rotate(0deg);')} `; const PlusIcon = styled(Plus)` @@ -86,7 +86,7 @@ function ChannelListHeader(props: ChannelListHeaderProps): JSX.Element { onMouseLeave={() => setIsButtonVisible(false)} onClick={() => setIsListOpen(!isListOpen)} > - + 채널 From f7ea6aa8e64fafa0863efd0c3fe6bee25f95a496 Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Thu, 25 Nov 2021 16:00:53 +0900 Subject: [PATCH 148/172] =?UTF-8?q?Feat=20:=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EB=B2=84=ED=8A=BC=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B8=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Main/ServerModal/CreateServerModal.tsx | 30 +++++++++++++++++-- .../Main/ServerModal/ServerSettingModal.tsx | 30 ++++++++++++++++--- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/Main/ServerModal/CreateServerModal.tsx b/frontend/src/components/Main/ServerModal/CreateServerModal.tsx index 44f9b06..f7b3db9 100644 --- a/frontend/src/components/Main/ServerModal/CreateServerModal.tsx +++ b/frontend/src/components/Main/ServerModal/CreateServerModal.tsx @@ -77,6 +77,7 @@ const InputDiv = styled.div` flex-direction: column; justify-content: flex-start; align-items: flex-start; + margin: 10px 0px; `; const ImageInputDiv = styled.div` @@ -84,7 +85,7 @@ const ImageInputDiv = styled.div` height: 100%; display: flex; justify-content: flex-start; - align-items: flex-start; + align-items: center; `; const InputName = styled.span` @@ -135,6 +136,30 @@ const ImagePreview = styled.img` width: 40px; height: 40px; `; + +const InputFile = styled.input` + display: none; +`; +const InputLabel = styled.label` + background-color: #26a9ca; + width: 300px; + height: 40px; + border-radius: 10px; + margin: 10px; + font-weight: 400; + transition: all 0.3s; + + display: flex; + justify-content: center; + align-items: center; + + &:hover { + background-color: '#2dc2e6'; + transition: all 0.3s; + cursor: pointer; + } +`; + const MessageFailToPost = styled.span` color: red; font-size: 16px; @@ -248,7 +273,8 @@ function CreateServerModal(): JSX.Element { 서버 아이콘 - + 파일을 선택하세요 + {messageFailToPost} diff --git a/frontend/src/components/Main/ServerModal/ServerSettingModal.tsx b/frontend/src/components/Main/ServerModal/ServerSettingModal.tsx index 8225a22..c9abb49 100644 --- a/frontend/src/components/Main/ServerModal/ServerSettingModal.tsx +++ b/frontend/src/components/Main/ServerModal/ServerSettingModal.tsx @@ -72,11 +72,11 @@ const InputDiv = styled.div` `; const ImageInputDiv = styled.div` - width: 250px; + width: 270px; height: 100%; display: flex; - justify-content: flex-start; - align-items: flex-start; + justify-content: space-between; + align-items: center; `; const InputName = styled.span` @@ -114,6 +114,27 @@ const SubmitButton = styled.button<{ isButtonActive: boolean }>` transition: all 0.3s; } `; +const InputFile = styled.input` + display: none; +`; +const InputLabel = styled.label` + background-color: #26a9ca; + width: 220px; + height: 40px; + border-radius: 10px; + font-weight: 400; + transition: all 0.3s; + + display: flex; + justify-content: center; + align-items: center; + + &:hover { + background-color: '#2dc2e6'; + transition: all 0.3s; + cursor: pointer; + } +`; const ImagePreview = styled.img` width: 40px; @@ -275,7 +296,8 @@ function ServerSettingModal(): JSX.Element { - + 파일을 선택하세요 + 제출 From c6efd4c9df3696befe0a6c087e5114bb6b0ad1d7 Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Thu, 25 Nov 2021 16:24:17 +0900 Subject: [PATCH 149/172] =?UTF-8?q?Refactor=20:=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - userServerService의 불필요한 코드를 제거하였습니다. - Server 참가 Code의 생성으로 userServerService 테스트코드의 매개변수를 수정하였습니다. --- .../user-server/user-server.service.spec.ts | 10 ++++--- .../src/user-server/user-server.service.ts | 12 +------- frontend/src/components/Main/MainHeader.tsx | 3 +- .../src/components/Main/ServerListTab.tsx | 30 +++++++++++-------- frontend/src/components/core/Dropdown.tsx | 2 +- 5 files changed, 27 insertions(+), 30 deletions(-) diff --git a/backend/src/user-server/user-server.service.spec.ts b/backend/src/user-server/user-server.service.spec.ts index f6a3d9c..1533a0a 100644 --- a/backend/src/user-server/user-server.service.spec.ts +++ b/backend/src/user-server/user-server.service.spec.ts @@ -9,6 +9,7 @@ import { DeleteResult } from 'typeorm'; import { ServerService } from '../server/server.service'; import { ServerRepository } from '../server/server.repository'; import { HttpStatus } from '@nestjs/common'; +import { v4 } from 'uuid'; const mockUserServerRepository = () => ({ save: jest.fn(), @@ -17,6 +18,7 @@ const mockUserServerRepository = () => ({ deleteByUserIdAndServerId: jest.fn(), getServerListByUserId: jest.fn(), findWithServerOwner: jest.fn(), + findByCode: jest.fn(), }); const mockServerRepository = () => ({ @@ -73,19 +75,18 @@ describe('UserServerService', () => { serverRepository.findOne.mockResolvedValue(server); userServerRepository.findByUserIdAndServerId.mockResolvedValue(undefined); - const newUserServer = await service.create(user, serverId); + const newUserServer = await service.create(user, server.code); expect(newUserServer.user).toBe(user); expect(newUserServer.server).toBe(server); }); it('서버가 존재하지 않는 경우', async () => { - const nonExistsId = 0; userServerRepository.save.mockResolvedValue(userServer); serverRepository.findOne.mockResolvedValue(undefined); try { - await service.create(user, nonExistsId); + await service.create(user, v4()); } catch (error) { expect(error.response.message).toBe('존재하지 않는 서버입니다.'); expect(error.response.error).toBe('Bad Request'); @@ -101,7 +102,7 @@ describe('UserServerService', () => { ); try { - await service.create(user, serverId); + await service.create(user, server.code); } catch (error) { expect(error.response.message).toBe('이미 등록된 서버입니다.'); expect(error.response.error).toBe('Bad Request'); @@ -182,6 +183,7 @@ describe('UserServerService', () => { server = new Server(); server.id = serverId; server.owner = user; + server.code = v4(); userServer = new UserServer(); userServer.user = user; diff --git a/backend/src/user-server/user-server.service.ts b/backend/src/user-server/user-server.service.ts index b45935f..ce188e2 100644 --- a/backend/src/user-server/user-server.service.ts +++ b/backend/src/user-server/user-server.service.ts @@ -8,7 +8,7 @@ import { import { InjectRepository } from '@nestjs/typeorm'; import { UserServerRepository } from './user-server.repository'; import { UserServer } from './user-server.entity'; -import { DeleteQueryBuilder, DeleteResult } from 'typeorm'; +import { DeleteResult } from 'typeorm'; import { User } from '../user/user.entity'; import { ServerService } from '../server/server.service'; import UserServerDto from './dto/user-server-list.dto'; @@ -54,16 +54,6 @@ export class UserServerService { return this.userServerRepository.delete(id); } - deleteByUserIdAndServerId( - userId: number, - serverId: number, - ): DeleteQueryBuilder { - return this.userServerRepository.deleteByUserIdAndServerId( - userId, - serverId, - ); - } - async getServerListByUserId(userId: number): Promise { const userServerList = await this.userServerRepository.getServerListByUserId(userId); diff --git a/frontend/src/components/Main/MainHeader.tsx b/frontend/src/components/Main/MainHeader.tsx index 00f8528..b05935f 100644 --- a/frontend/src/components/Main/MainHeader.tsx +++ b/frontend/src/components/Main/MainHeader.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect, useState } from 'react'; +import React, { useContext, useState } from 'react'; import styled from 'styled-components'; import Dropdown from '../core/Dropdown'; import DropdownMenu from '../core/DropdownMenu'; @@ -40,7 +40,6 @@ function MainHeader(): JSX.Element { } }; - useEffect(() => {}, []); return ( diff --git a/frontend/src/components/Main/ServerListTab.tsx b/frontend/src/components/Main/ServerListTab.tsx index 230e0d3..fd4856f 100644 --- a/frontend/src/components/Main/ServerListTab.tsx +++ b/frontend/src/components/Main/ServerListTab.tsx @@ -85,6 +85,10 @@ const PlusIcon = styled(Plus)` fill: #a69c96; `; +const DropDownWrapper = styled.div` + position: absolute; +`; + function ServerListTab(): JSX.Element { const [isDropdownActivated, setIsDropdownActivated] = useState(false); const { selectedServer, setSelectedServer, serverList, getUserServerList } = useContext(MainStoreContext); @@ -136,18 +140,20 @@ function ServerListTab(): JSX.Element { {listElements} - - } - /> - } - /> - + + + } + /> + } + /> + + ); diff --git a/frontend/src/components/core/Dropdown.tsx b/frontend/src/components/core/Dropdown.tsx index e393a28..0f80ebf 100644 --- a/frontend/src/components/core/Dropdown.tsx +++ b/frontend/src/components/core/Dropdown.tsx @@ -25,7 +25,7 @@ const InnerContainer = styled.div` background-color: white; border-radius: 8px; position: relative; - width: 90px; + width: 100px; text-align: center; box-shadow: 0 1px 8px rgba(0, 0, 0, 0.3); z-index: 99; From ce621b677e654e26c3ae3219eaa6890d111aef0e Mon Sep 17 00:00:00 2001 From: K Date: Thu, 25 Nov 2021 16:40:06 +0900 Subject: [PATCH 150/172] =?UTF-8?q?Feat=20:=20SessionMiddleware=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=ED=95=A8=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이를 분리한 이유는 socket.io 에서도 세션을 사용할 수 있도록 하기 위해서입니다. - sessionMiddleware를 외부에서 값을 주입 받아서 생성합니다. --- backend/src/session.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 backend/src/session.ts diff --git a/backend/src/session.ts b/backend/src/session.ts new file mode 100644 index 0000000..0367260 --- /dev/null +++ b/backend/src/session.ts @@ -0,0 +1,22 @@ +import * as session from 'express-session'; +import * as redis from 'redis'; +import * as createRedisStore from 'connect-redis'; +import { ConfigService } from '@nestjs/config'; + +export function createSessionMiddleware(configService: ConfigService) { + const sessionOption: session.SessionOptions = { + secret: configService.get('SESSION_SECRET'), + resave: false, + saveUninitialized: false, + }; + + if (configService.get('SESSION') === 'redis') { + const redisClient = redis.createClient({ + host: configService.get('REDIS_HOST'), + }); + const RedisStore = createRedisStore(session); + sessionOption.store = new RedisStore({ client: redisClient }); + } + + return session(sessionOption); +} From ed3e2f900c4a98ee30e9e479f40340703e402f8d Mon Sep 17 00:00:00 2001 From: K Date: Thu, 25 Nov 2021 16:41:53 +0900 Subject: [PATCH 151/172] Feat : MessageSessionAdapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 외부에서 sessionMiddleware를 받아서 socketio server에 middleware를 추가합니다. --- backend/src/message.adapter.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 backend/src/message.adapter.ts diff --git a/backend/src/message.adapter.ts b/backend/src/message.adapter.ts new file mode 100644 index 0000000..9867e39 --- /dev/null +++ b/backend/src/message.adapter.ts @@ -0,0 +1,25 @@ +import { IoAdapter } from '@nestjs/platform-socket.io'; +import { Server } from 'socket.io'; +import { INestApplication } from '@nestjs/common'; + +export class MessageSessionAdapter extends IoAdapter { + private app: INestApplication; + private sessionMiddleware; + + constructor(app: INestApplication, sessionMiddleware) { + super(app); + this.app = app; + this.sessionMiddleware = sessionMiddleware; + } + + createIOServer(port: number, options?: any): any { + const server: Server = super.createIOServer(port, options); + + this.app.use(this.sessionMiddleware); + server.use((socket, next) => { + this.sessionMiddleware(socket.request, {}, next); + }); + + return server; + } +} From 52df24f3cc886d046f5861134892607e74db9d97 Mon Sep 17 00:00:00 2001 From: K Date: Thu, 25 Nov 2021 16:42:58 +0900 Subject: [PATCH 152/172] =?UTF-8?q?Feat=20:=20Session=EC=9D=84=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=ED=95=98=EB=8A=94=20MessageSessionAdapter=EB=A5=BC=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sessionMiddleware를 만들고, 이를 bootstrap에서 사용하도록 수정하였습니다. --- backend/src/main.ts | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/backend/src/main.ts b/backend/src/main.ts index 0dbabf4..cf83543 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,33 +1,20 @@ import { NestFactory } from '@nestjs/core'; import { ExpressPeerServer } from 'peer'; -import * as session from 'express-session'; -import * as redis from 'redis'; -import * as createRedisStore from 'connect-redis'; +import { ConfigService } from '@nestjs/config'; import { AppModule } from './app.module'; -import { ConfigService } from '@nestjs/config'; +import { createSessionMiddleware } from './session'; +import { MessageSessionAdapter } from './message.adapter'; async function bootstrap() { const app = await NestFactory.create(AppModule); const server = app.getHttpServer(); const peerServer = ExpressPeerServer(server); const configService = app.get(ConfigService); + const session = createSessionMiddleware(configService); - const sessionOption: session.SessionOptions = { - secret: configService.get('SESSION_SECRET'), - resave: false, - saveUninitialized: false, - }; - - if (configService.get('SESSION') === 'redis') { - const redisClient = redis.createClient({ - host: configService.get('REDIS_HOST'), - }); - const RedisStore = createRedisStore(session); - sessionOption.store = new RedisStore({ client: redisClient }); - } - - app.use(session(sessionOption)); + app.use(session); + app.useWebSocketAdapter(new MessageSessionAdapter(app, session)); app.use('/peerjs', peerServer); await app.listen(9000); } From 664bf2449bbf29e961275d121e4e443b307b0205 Mon Sep 17 00:00:00 2001 From: K Date: Thu, 25 Nov 2021 16:47:30 +0900 Subject: [PATCH 153/172] =?UTF-8?q?Feat=20:=20MessageGateway=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 세션을 이용할 수 있는 MessageGateway 입니다. --- backend/src/app.module.ts | 3 ++- backend/src/message.gateway.ts | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 backend/src/message.gateway.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index dcd1421..5a0d9b8 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -16,6 +16,7 @@ import { UserServerModule } from './user-server/user-server.module'; import { LoginModule } from './login/login.module'; import { UserChannelModule } from './user-channel/user-channel.module'; import { ImageModule } from './image/image.module'; +import { MessageGateway } from './message.gateway'; import githubConfig from './config/github.config'; @Module({ @@ -39,6 +40,6 @@ import githubConfig from './config/github.config'; ImageModule, ], controllers: [AppController], - providers: [AppService], + providers: [AppService, MessageGateway], }) export class AppModule {} diff --git a/backend/src/message.gateway.ts b/backend/src/message.gateway.ts new file mode 100644 index 0000000..ccb9192 --- /dev/null +++ b/backend/src/message.gateway.ts @@ -0,0 +1,21 @@ +import { SubscribeMessage, WebSocketGateway } from '@nestjs/websockets'; +import { Socket } from 'socket.io'; +import { ExpressSession } from './types/session'; + +declare module 'http' { + interface IncomingMessage { + session: ExpressSession; + } +} + +@WebSocketGateway({ namespace: '/message' }) +export class MessageGateway { + @SubscribeMessage('message') + handleMessage(client: Socket, payload: any): string { + const user = client.request.session.user; + if (!user) { + return; + } + return 'Hello world!'; + } +} From acf924c4c32a3e034e90cb96a50d865bf02e6e09 Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Thu, 25 Nov 2021 16:51:04 +0900 Subject: [PATCH 154/172] =?UTF-8?q?Feat=20:=20=ED=95=98=EB=82=98=EC=9D=98?= =?UTF-8?q?=20=EB=A9=94=EC=8B=9C=EC=A7=80=EC=97=90=20=EB=8C=80=ED=95=9C=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EB=A7=8C=20=EA=B0=80=EC=A0=B8=EC=98=A4?= =?UTF-8?q?=EB=8A=94=20API=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit messageId를 통해 하나의 메시지에 대한 값만 가져오는 API를 작성하였습니다. --- backend/src/message/message.controller.ts | 15 +++++++++++++++ backend/src/message/message.service.ts | 10 ++++++++++ 2 files changed, 25 insertions(+) diff --git a/backend/src/message/message.controller.ts b/backend/src/message/message.controller.ts index 3df8de8..1e7d24b 100644 --- a/backend/src/message/message.controller.ts +++ b/backend/src/message/message.controller.ts @@ -34,6 +34,21 @@ export class MessageController { return ResponseEntity.ok(newMessage); } + @Get('/getone') + async findMessageByMessageId( + @Session() session: ExpressSession, + @Query('messageId') messageId: number, + @Query('channelId') channelId: number, + ) { + const sender = session.user; + const selectedMessage = await this.messageService.findMessagesByMessageId( + sender.id, + channelId, + messageId, + ); + return ResponseEntity.ok(selectedMessage); + } + @Get() async findMessagesByChannelId( @Session() session: ExpressSession, diff --git a/backend/src/message/message.service.ts b/backend/src/message/message.service.ts index 4f46c25..5a5f1f7 100644 --- a/backend/src/message/message.service.ts +++ b/backend/src/message/message.service.ts @@ -38,4 +38,14 @@ export class MessageService { const messages = await this.messageRepository.findByChannelId(channelId); return messages.map(MessageDto.fromEntity); } + + async findMessagesByMessageId( + senderId: number, + channelId: number, + messageId: number, + ) { + await this.userServerService.checkUserChannelAccess(senderId, channelId); + const message = await this.messageRepository.findOne(messageId); + return MessageDto.fromEntity(message); + } } From e992ae4e57b17c96c262351a22f6d44b4ec10fac Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Thu, 25 Nov 2021 16:52:03 +0900 Subject: [PATCH 155/172] =?UTF-8?q?Feat=20:=20ThreadSection=20front?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 선택한 Message에 대해 MessageSection 우측에 정보를 출력하는 ThreadSection을 작성하였습니다. --- .../Main/ContentsSection/ContentsSection.tsx | 9 +- .../Main/ContentsSection/MessageSection.tsx | 11 +- .../Main/ContentsSection/ThreadSection.tsx | 234 +++++++++++++++++- frontend/src/components/Main/MainStore.tsx | 4 + 4 files changed, 251 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/Main/ContentsSection/ContentsSection.tsx b/frontend/src/components/Main/ContentsSection/ContentsSection.tsx index f3b4304..43ac853 100644 --- a/frontend/src/components/Main/ContentsSection/ContentsSection.tsx +++ b/frontend/src/components/Main/ContentsSection/ContentsSection.tsx @@ -1,16 +1,23 @@ import React from 'react'; import styled from 'styled-components'; import MessageSection from './MessageSection'; +import ThreadSection from './ThreadSection'; const Container = styled.div` - flex: 1; + flex: 1 0 0; height: 100%; + + display: flex; + flex-direction: row; + justify-content: space-around; + align-items: center; `; function ContentsSection(): JSX.Element { return ( + ); } diff --git a/frontend/src/components/Main/ContentsSection/MessageSection.tsx b/frontend/src/components/Main/ContentsSection/MessageSection.tsx index c476222..f96a859 100644 --- a/frontend/src/components/Main/ContentsSection/MessageSection.tsx +++ b/frontend/src/components/Main/ContentsSection/MessageSection.tsx @@ -6,7 +6,7 @@ import { MessageData, MessageRequestBody } from '../../../types/messags'; import fetchData from '../../../utils/fetchMethods'; const Container = styled.div` - width: 100%; + width: 50%; height: 100%; background-color: white; @@ -14,6 +14,7 @@ const Container = styled.div` display: flex; flex-direction: column; justify-content: flex-start; + border-right: 1px solid black; `; const MessageSectionHeader = styled.div` @@ -171,7 +172,7 @@ const MessageTextarea = styled.textarea` `; function MessageSection(): JSX.Element { - const { selectedChannel } = useContext(MainStoreContext); + const { selectedChannel, setSelectedMessageData } = useContext(MainStoreContext); const [messageList, setMessageList] = useState([]); const textDivRef = useRef(null); const tmpChannelName = '# ChannelName'; @@ -218,11 +219,15 @@ function MessageSection(): JSX.Element { } }; + const onClickMessageItemBlock = (data: MessageData) => { + setSelectedMessageData(data); + }; + const MessageItemList = messageList.map((val: MessageData): JSX.Element => { const { id, contents, createdAt, sender } = val; const { nickname, profile } = sender; return ( - + onClickMessageItemBlock(val)}> diff --git a/frontend/src/components/Main/ContentsSection/ThreadSection.tsx b/frontend/src/components/Main/ContentsSection/ThreadSection.tsx index 84e9578..61c8a7a 100644 --- a/frontend/src/components/Main/ContentsSection/ThreadSection.tsx +++ b/frontend/src/components/Main/ContentsSection/ThreadSection.tsx @@ -1,13 +1,241 @@ -import React from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import styled from 'styled-components'; +import { MessageData } from '../../../types/messags'; +import fetchData from '../../../utils/fetchMethods'; + +import { BoostCamMainIcons } from '../../../utils/SvgIcons'; +import { MainStoreContext } from '../MainStore'; + +const { Close } = BoostCamMainIcons; const Container = styled.div` - flex: 1; + flex: 1 0 0; height: 100%; + background-color: white; +`; + +const ThreadSectionHeader = styled.div` + width: 100%; + height: 50px; + + font-size: 18px; + + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + border-bottom: 1px solid gray; +`; + +const ChannelName = styled.div` + margin-left: 15px; + padding: 8px 12px; + border-radius: 10px; +`; + +const ThreadSpan = styled.span` + font-weight: 600; +`; + +const ChannelNameSpan = styled.span` + margin-left: 5px; + font-size: 15px; +`; + +const ThreadSectionBody = styled.div` + width: 100%; + overflow-y: auto; + + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + + &::-webkit-scrollbar { + width: 10px; + } + &::-webkit-scrollbar-thumb { + background-color: #999999; + border-radius: 10px; + } + &::-webkit-scrollbar-track { + background-color: #cccccc; + border-radius: 10px; + } +`; + +const MessageItemBlock = styled.div` + width: 100%; + display: flex; + flex-direction: row; + align-items: flex-start; + + padding: 10px 0px; + + border-bottom: 1px solid gray; +`; + +const CommentItemBlock = styled.div` + width: 100%; + display: flex; + flex-direction: row; + align-items: flex-start; + + &:hover { + background-color: #f0e7e7; + } +`; + +const CommentItemIcon = styled.div<{ imgUrl: string }>` + width: 36px; + height: 36px; + margin: 10px; + background-image: url(${(props) => props.imgUrl}); + background-size: cover; + background-repeat: no-repeat; + border-radius: 8px; +`; + +const CommentItem = styled.div` + width: 90%; + padding: 8px 0px; + + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; +`; + +const CommentItemHeader = styled.div` + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; +`; + +const CommentSender = styled.span` + font-weight: 600; + font-size: 15px; +`; + +const CommentTimelog = styled.span` + font-size: 12px; + margin-left: 15px; +`; + +const CommentContents = styled.span` + font-size: 15px; +`; + +const TextareaDiv = styled.div` + min-height: 105px; + max-height: 250px; + background-color: #ece9e9; + + display: flex; + flex-direction: column; + justify-content: space-around; + align-items: center; +`; + +const CommentTextarea = styled.textarea` + width: 90%; + height: 22px; + max-height: 200px; + border: none; + outline: none; + resize: none; + background: none; + + font-size: 15px; + + padding: 10px; + border: 1px solid gray; + border-radius: 5px; + + background-color: white; + + &::-webkit-scrollbar { + width: 10px; + } + &::-webkit-scrollbar-thumb { + background-color: #999999; + border-radius: 10px; + } + &::-webkit-scrollbar-track { + background-color: #cccccc; + border-radius: 10px; + } +`; + +const CloseIcon = styled(Close)` + width: 30px; + height: 30px; + fill: #a69c96; + margin-right: 15px; `; function ThreadSection(): JSX.Element { - return ThreadSection; + const { selectedMessageData } = useContext(MainStoreContext); + + const buildCommentElement = (data: MessageData | undefined) => { + if (!data) return <>; + const { contents, createdAt, sender } = data; + const { nickname, profile } = sender; + return ( + + + + + {nickname} + {createdAt} + + {contents} + + + ); + }; + + const buildMessageElement = (data: MessageData | undefined) => { + if (!data) return <>; + const { contents, createdAt, sender } = data; + const { nickname, profile } = sender; + return ( + + + + + {nickname} + {createdAt} + + {contents} + + + ); + }; + + useEffect(() => { + console.log(selectedMessageData); + }, [selectedMessageData]); + + const mainMessage = buildMessageElement(selectedMessageData); + + return ( + + + + 쓰레드 + ChannelName + + + + {mainMessage} + + + + + ); } export default ThreadSection; diff --git a/frontend/src/components/Main/MainStore.tsx b/frontend/src/components/Main/MainStore.tsx index fb8f449..c6a876e 100644 --- a/frontend/src/components/Main/MainStore.tsx +++ b/frontend/src/components/Main/MainStore.tsx @@ -1,5 +1,6 @@ import React, { createContext, useEffect, useState } from 'react'; import { CamData, ChannelData, MyServerData } from '../../types/main'; +import { MessageData } from '../../types/messags'; export const MainStoreContext = createContext(null); @@ -11,6 +12,7 @@ function MainStore(props: MainStoreProps): JSX.Element { const { children } = props; const [selectedServer, setSelectedServer] = useState(); const [selectedChannel, setSelectedChannel] = useState(''); + const [selectedMessageData, setSelectedMessageData] = useState(); const [rightClickedChannelId, setRightClickedChannelId] = useState(''); const [rightClickedChannelName, setRightClickedChannelName] = useState(''); const [serverChannelList, setServerChannelList] = useState([]); @@ -74,6 +76,7 @@ function MainStore(props: MainStoreProps): JSX.Element { modalContents, selectedServer, selectedChannel, + selectedMessageData, rightClickedChannelId, rightClickedChannelName, serverChannelList, @@ -83,6 +86,7 @@ function MainStore(props: MainStoreProps): JSX.Element { setModalContents, setSelectedServer, setSelectedChannel, + setSelectedMessageData, setRightClickedChannelId, setRightClickedChannelName, setServerChannelList, From ccc4a0e9a8429a30b41308f0476e0af00660db5f Mon Sep 17 00:00:00 2001 From: korung3195 Date: Thu, 25 Nov 2021 17:02:23 +0900 Subject: [PATCH 156/172] =?UTF-8?q?Feat=20:=20cam=20=EC=B5=9C=EB=8C=80=20?= =?UTF-8?q?=EC=9E=85=EC=9E=A5=20=EC=9D=B8=EC=9B=90=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cam에 한번에 입장할 수 있는 최대 인원을 5명으로 설정하였습니다. --- backend/src/cam/cam-inner.service.ts | 19 +++++------ backend/src/cam/cam.controller.ts | 15 ++------- backend/src/cam/cam.service.ts | 18 ++++++++-- frontend/src/components/Cam/Cam.tsx | 49 ++++++++++++++++------------ 4 files changed, 55 insertions(+), 46 deletions(-) diff --git a/backend/src/cam/cam-inner.service.ts b/backend/src/cam/cam-inner.service.ts index fa87938..3e53153 100644 --- a/backend/src/cam/cam-inner.service.ts +++ b/backend/src/cam/cam-inner.service.ts @@ -10,6 +10,8 @@ type RoomInfo = { userNickname: string; }; +const MAX_PEOPLE = 5; + @Injectable() export class CamInnerService { private map: Map>; @@ -23,10 +25,6 @@ export class CamInnerService { this.sharedScreen = new Map(); } - getRoomList() { - return this.map; - } - getRoomNicknameList(roomId: string): RoomInfo[] { const roomInfo: CamMap[] = this.map.get(roomId); return roomInfo.map((data) => { @@ -35,10 +33,6 @@ export class CamInnerService { }); } - isRoomExist(roomId: string): boolean { - return this.map.has(roomId); - } - createRoom(roomId: string): boolean { if (this.map.get(roomId)) return false; this.map.set(roomId, []); @@ -53,8 +47,8 @@ export class CamInnerService { userNickname: string, status: Status, ): boolean { - if (!this.map.get(roomId)) return false; - this.map.get(roomId).push({ userId, socketId, userNickname, status }); + const room = this.map.get(roomId); + room.push({ userId, socketId, userNickname, status }); return true; } @@ -109,4 +103,9 @@ export class CamInnerService { return null; } + + checkRoomAvailable(roomId: RoomId) { + const room = this.map.get(roomId); + return room && room.length < MAX_PEOPLE; + } } diff --git a/backend/src/cam/cam.controller.ts b/backend/src/cam/cam.controller.ts index 4b3b7be..6b954d5 100644 --- a/backend/src/cam/cam.controller.ts +++ b/backend/src/cam/cam.controller.ts @@ -1,11 +1,4 @@ -import { - Controller, - Post, - Body, - Get, - Param, - NotFoundException, -} from '@nestjs/common'; +import { Controller, Post, Body, Get, Param } from '@nestjs/common'; import ResponseEntity from '../common/response-entity'; import { CreateCamDto } from './cam.dto'; @@ -29,10 +22,6 @@ export class CamController { ): Promise> { const cam = await this.camService.findOne(url); - if (cam) { - return ResponseEntity.ok(cam); - } else { - throw new NotFoundException(); - } + return ResponseEntity.ok(cam); } } diff --git a/backend/src/cam/cam.service.ts b/backend/src/cam/cam.service.ts index d5074b8..1439e3b 100644 --- a/backend/src/cam/cam.service.ts +++ b/backend/src/cam/cam.service.ts @@ -1,8 +1,10 @@ import { BadRequestException, + ForbiddenException, forwardRef, Inject, Injectable, + NotFoundException, } from '@nestjs/common'; import { v4 } from 'uuid'; @@ -23,8 +25,20 @@ export class CamService { this.camRepository.clear(); } - findOne(url: string): Promise { - return this.camRepository.findOne({ url: url }); + async findOne(url: string): Promise { + const cam = await this.camRepository.findOne({ url: url }); + + if (!cam) { + throw new NotFoundException(); + } + + const available = this.camInnerService.checkRoomAvailable(url); + + if (!available) { + throw new ForbiddenException(); + } + + return cam; } async createCam(cam: CreateCamDto): Promise { diff --git a/frontend/src/components/Cam/Cam.tsx b/frontend/src/components/Cam/Cam.tsx index ff746aa..a7f6a5b 100644 --- a/frontend/src/components/Cam/Cam.tsx +++ b/frontend/src/components/Cam/Cam.tsx @@ -1,17 +1,17 @@ import React, { useEffect, useRef, useState } from 'react'; import styled from 'styled-components'; -import ButtonBar from './ButtonBar/ButtonBar'; -import ChattingTab from './Chatting/ChattingTab'; +import ButtonBar from './Menu/ButtonBar'; +import ChattingTab from './Menu/ChattingTab'; import MainScreen from './Screen/MainScreen'; import CamStore from './CamStore'; -import UserListTab from './UserList/UserListTab'; +import UserListTab from './Menu/UserListTab'; import ToggleStore from './ToggleStore'; import { UserInfo } from '../../types/cam'; import STTStore from './STT/STTStore'; import SharedScreenStore from './SharedScreen/SharedScreenStore'; -import NickNameForm from './Nickname/NickNameForm'; -import CamNotFound from './CamNotFound'; +import CamNickNameInputPage from './Page/CamNickNameInputPage'; +import CamNotFoundPage from './Page/CamNotFoundPage'; const Container = styled.div` width: 100vw; @@ -37,7 +37,7 @@ const UpperTab = styled.div` function Cam(): JSX.Element { const [userInfo, setUserInfo] = useState({ roomId: null, nickname: null }); - const [isRoomExist, setIsRoomExist] = useState(false); + const [statusCode, setStatusCode] = useState(0); const camRef = useRef(null); @@ -51,11 +51,7 @@ function Cam(): JSX.Element { const json = await response.json(); - if (json.statusCode === 200) { - setIsRoomExist(true); - } else { - setIsRoomExist(false); - } + setStatusCode(json.statusCode); }; useEffect(() => { @@ -67,12 +63,23 @@ function Cam(): JSX.Element { setUserInfo((prev) => ({ ...prev, roomId })); }, []); - if (isRoomExist) { - return ( - - {!userInfo?.nickname ? ( - - ) : ( + switch (statusCode) { + case 0: + return
로?딩
; + case 403: + return
방이 꽉?찬
; + case 404: + return ; + case 200: + if (!userInfo?.nickname) { + return ( + + + + ); + } + return ( + @@ -87,11 +94,11 @@ function Cam(): JSX.Element { - )} - - ); +
+ ); + default: + return
뭔가 오류
; } - return ; } export default Cam; From bc370000a9325388fc59b36486ba9df9fcd9dfdd Mon Sep 17 00:00:00 2001 From: korung3195 Date: Thu, 25 Nov 2021 17:03:29 +0900 Subject: [PATCH 157/172] =?UTF-8?q?Refactor=20:=20=EB=94=94=EB=A0=89?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 더 직관적인 디렉토리 구조를 위해 Cam/Menu 디렉토리를 생성하여 ButtonBar, ChattingTab, UserListTab 관련 모듈을 이동하였습니다. --- frontend/src/components/Cam/{ButtonBar => Menu}/ButtonBar.tsx | 2 +- frontend/src/components/Cam/{Chatting => Menu}/ChattingTab.tsx | 0 .../src/components/Cam/{Nickname => Menu}/NicknameModal.tsx | 0 frontend/src/components/Cam/{UserList => Menu}/UserListTab.tsx | 0 4 files changed, 1 insertion(+), 1 deletion(-) rename frontend/src/components/Cam/{ButtonBar => Menu}/ButtonBar.tsx (98%) rename frontend/src/components/Cam/{Chatting => Menu}/ChattingTab.tsx (100%) rename frontend/src/components/Cam/{Nickname => Menu}/NicknameModal.tsx (100%) rename frontend/src/components/Cam/{UserList => Menu}/UserListTab.tsx (100%) diff --git a/frontend/src/components/Cam/ButtonBar/ButtonBar.tsx b/frontend/src/components/Cam/Menu/ButtonBar.tsx similarity index 98% rename from frontend/src/components/Cam/ButtonBar/ButtonBar.tsx rename to frontend/src/components/Cam/Menu/ButtonBar.tsx index aa1b0b9..befd571 100644 --- a/frontend/src/components/Cam/ButtonBar/ButtonBar.tsx +++ b/frontend/src/components/Cam/Menu/ButtonBar.tsx @@ -7,7 +7,7 @@ import type { Status } from '../../../types/cam'; import { ToggleStoreContext } from '../ToggleStore'; import { STTStoreContext } from '../STT/STTStore'; import { SharedScreenStoreContext } from '../SharedScreen/SharedScreenStore'; -import NicknameModal from '../Nickname/NicknameModal'; +import NicknameModal from './NicknameModal'; const { MicIcon, diff --git a/frontend/src/components/Cam/Chatting/ChattingTab.tsx b/frontend/src/components/Cam/Menu/ChattingTab.tsx similarity index 100% rename from frontend/src/components/Cam/Chatting/ChattingTab.tsx rename to frontend/src/components/Cam/Menu/ChattingTab.tsx diff --git a/frontend/src/components/Cam/Nickname/NicknameModal.tsx b/frontend/src/components/Cam/Menu/NicknameModal.tsx similarity index 100% rename from frontend/src/components/Cam/Nickname/NicknameModal.tsx rename to frontend/src/components/Cam/Menu/NicknameModal.tsx diff --git a/frontend/src/components/Cam/UserList/UserListTab.tsx b/frontend/src/components/Cam/Menu/UserListTab.tsx similarity index 100% rename from frontend/src/components/Cam/UserList/UserListTab.tsx rename to frontend/src/components/Cam/Menu/UserListTab.tsx From 79d68da2f51e9c191759138f584ac6f2abee9f0d Mon Sep 17 00:00:00 2001 From: sinhyuk Date: Thu, 25 Nov 2021 17:17:37 +0900 Subject: [PATCH 158/172] =?UTF-8?q?Test=20:=20server=20service=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - findByCode() 함수 테스트코드를 추가하였습니다. - findCode() 함수 테스트코드를 추가하였습니다. - updateServer() 함수 테스트코드를 추가하였습니다. - refreshCode() 함수 테스트코드를 추가하였습니다. --- backend/src/server/server.service.spec.ts | 117 +++++++++++++++++++++- backend/src/server/server.service.ts | 25 ++++- 2 files changed, 137 insertions(+), 5 deletions(-) diff --git a/backend/src/server/server.service.spec.ts b/backend/src/server/server.service.spec.ts index 543c2b4..e99a895 100644 --- a/backend/src/server/server.service.spec.ts +++ b/backend/src/server/server.service.spec.ts @@ -1,7 +1,7 @@ import { HttpStatus } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { DeleteResult } from 'typeorm'; +import { DeleteResult, UpdateResult } from 'typeorm'; import { UserServer } from '../user-server/user-server.entity'; import { UserServerRepository } from '../user-server/user-server.repository'; import { UserServerService } from '../user-server/user-server.service'; @@ -10,6 +10,7 @@ import RequestServerDto from './dto/request-server.dto'; import { Server } from './server.entity'; import { ServerRepository } from './server.repository'; import { ServerService } from './server.service'; +import { v4 } from 'uuid'; const mockUserServerRepository = () => ({ save: jest.fn(), @@ -25,6 +26,7 @@ const mockServerRepository = () => ({ findOneWithUsers: jest.fn(), findOneWithOwner: jest.fn(), delete: jest.fn(), + update: jest.fn(), }); type MockUserServerRepository = Partial< @@ -73,6 +75,79 @@ describe('ServerService', () => { refreshVariables(); }); + describe('findByCode()', () => { + it('정상적인 코드로 조회할 경우', async () => { + serverRepository.findOne.mockResolvedValue(existsServer); + + const server = await serverService.findByCode(existsServer.code); + + expect(server.id).toBe(existsServerId); + expect(server.description).toBe(existsServer.description); + expect(server.name).toBe(existsServer.name); + expect(server.imgUrl).toBe(existsServer.imgUrl); + expect(server.code).toBe(existsServer.code); + }); + + it('존재하지 않는 코드로 조회할 경우', async () => { + serverRepository.findOne.mockResolvedValue(existsServer); + + try { + await serverService.findByCode(v4()); + } catch (error) { + expect(error.response.message).toBe('존재하지 않는 서버입니다.'); + expect(error.response.error).toBe('Bad Request'); + expect(error.response.statusCode).toBe(HttpStatus.BAD_REQUEST); + } + }); + }); + + describe('findCode()', () => { + it('정상적인 값을 입력할 경우', async () => { + serverRepository.findOne.mockResolvedValue(existsServer); + + const code = await serverService.findCode(existsServerId); + + expect(code).toBe(existsServer.code); + }); + + it('서버가 존재하지 않을 경우', async () => { + const nonExistsId = 0; + serverRepository.findOne.mockResolvedValue(undefined); + + try { + await serverService.findCode(nonExistsId); + } catch (error) { + expect(error.response.message).toBe('존재하지 않는 서버입니다.'); + expect(error.response.error).toBe('Bad Request'); + expect(error.response.statusCode).toBe(HttpStatus.BAD_REQUEST); + } + }); + }); + + describe('refreshCode()', () => { + it('코드 재생성에 성공할 경우', async () => { + serverRepository.findOne.mockResolvedValue(existsServer); + const originCode = existsServer.code; + + const code = await serverService.refreshCode(existsServerId); + + expect(code).not.toBe(originCode); + }); + + it('서버가 존재하지 않을 경우', async () => { + const nonExistsId = 0; + serverRepository.findOne.mockResolvedValue(undefined); + + try { + await serverService.refreshCode(nonExistsId); + } catch (error) { + expect(error.response.message).toBe('존재하지 않는 서버입니다.'); + expect(error.response.error).toBe('Bad Request'); + expect(error.response.statusCode).toBe(HttpStatus.BAD_REQUEST); + } + }); + }); + describe('create()', () => { it('정상적인 값을 저장할 경우', async () => { serverRepository.save.mockResolvedValue(newServer); @@ -105,6 +180,45 @@ describe('ServerService', () => { }); }); + describe('updateServer()', () => { + it('정상적인 요청을 할 경우', async () => { + const serverUpdateResult = new UpdateResult(); + serverUpdateResult.affected = 1; + + serverRepository.findOneWithOwner.mockResolvedValue(existsServer); + serverRepository.update.mockResolvedValue(serverUpdateResult); + + const updateResult: UpdateResult = await serverService.updateServer( + existsServerId, + requestServerDto, + user, + '', + ); + + expect(updateResult.affected).toBe(1); + }); + + it('변경 권한이 없을 경우', async () => { + const userNotOwner = new User(); + userNotOwner.id = 0; + + serverRepository.findOneWithOwner.mockResolvedValue(existsServer); + + try { + await serverService.updateServer( + existsServerId, + requestServerDto, + userNotOwner, + '', + ); + } catch (error) { + expect(error.response.message).toBe('변경 권한이 없습니다.'); + expect(error.response.error).toBe('Forbidden'); + expect(error.response.statusCode).toBe(HttpStatus.FORBIDDEN); + } + }); + }); + describe('deleteServer()', () => { it('정상적인 값을 입력할 경우', async () => { const deleteResult = new DeleteResult(); @@ -165,5 +279,6 @@ describe('ServerService', () => { existsServer.id = existsServerId; existsServer.owner = user; existsServer.userServer = [existsUserServer]; + existsServer.code = v4(); }; }); diff --git a/backend/src/server/server.service.ts b/backend/src/server/server.service.ts index 684bd0b..515717a 100644 --- a/backend/src/server/server.service.ts +++ b/backend/src/server/server.service.ts @@ -14,6 +14,7 @@ import RequestServerDto from './dto/request-server.dto'; import { UserServerService } from '../user-server/user-server.service'; import { ServerRepository } from './server.repository'; import ServerWithUsersDto from './dto/response-server-users.dto'; +import { UpdateResult } from 'typeorm'; @Injectable() export class ServerService { @@ -31,17 +32,33 @@ export class ServerService { return this.serverRepository.findOne({ id: id }); } - findByCode(code: string): Promise { - return this.serverRepository.findOne({ code }); + async findByCode(code: string): Promise { + const server = await this.serverRepository.findOne({ code }); + + if (!server) { + throw new BadRequestException('존재하지 않는 서버입니다.'); + } + + return server; } async findCode(id: number): Promise { const server = await this.serverRepository.findOne(id); + + if (!server) { + throw new BadRequestException('존재하지 않는 서버입니다.'); + } + return server.code; } async refreshCode(id: number): Promise { const server = await this.serverRepository.findOne(id); + + if (!server) { + throw new BadRequestException('존재하지 않는 서버입니다.'); + } + server.code = v4(); this.serverRepository.save(server); return server.code; @@ -68,7 +85,7 @@ export class ServerService { requestServer: RequestServerDto, user: User, imgUrl: string | undefined, - ): Promise { + ): Promise { const server = await this.serverRepository.findOneWithOwner(id); if (server.owner.id !== user.id) { @@ -81,7 +98,7 @@ export class ServerService { newServer.name = newServer.name || server.name; newServer.description = newServer.description || server.description; - this.serverRepository.update(id, newServer); + return this.serverRepository.update(id, newServer); } async deleteServer(id: number, user: User) { From 6abe9b91ca6d8ab5102fcb099ed96a21532e4d34 Mon Sep 17 00:00:00 2001 From: korung3195 Date: Thu, 25 Nov 2021 17:22:02 +0900 Subject: [PATCH 159/172] =?UTF-8?q?Feat=20:=20cam=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=98=A4=EB=A5=98=20=ED=95=B8=EB=93=A4=EB=A7=81=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 각 status에 따라서 서로 다른 page를 출력하는 기능을 구현하고, 관련 static file들을 추가하였습니다. --- .../{user-default.jpg => pepes/pepe-1.jpg} | Bin .../{not-found.jpg => pepes/pepe-2.jpg} | Bin .../{nickname-form.jpg => pepes/pepe-3.jpg} | Bin frontend/public/pepes/pepe-4.jpg | Bin 0 -> 60702 bytes frontend/public/pepes/pepe-5.jpg | Bin 0 -> 38768 bytes frontend/src/components/Cam/Cam.tsx | 15 ++++---- .../CamDefaultPage.tsx} | 27 +++++++------- .../src/components/Cam/Page/CamErrorPage.tsx | 24 +++++++++++++ .../components/Cam/Page/CamLoadingPage.tsx | 24 +++++++++++++ .../CamNickNameInputPage.tsx} | 34 ++++-------------- .../Cam/Page/CamNotAvailablePage.tsx | 24 +++++++++++++ .../components/Cam/Page/CamNotFoundPage.tsx | 24 +++++++++++++ .../components/Cam/Screen/DefaultScreen.tsx | 2 +- 13 files changed, 122 insertions(+), 52 deletions(-) rename frontend/public/{user-default.jpg => pepes/pepe-1.jpg} (100%) rename frontend/public/{not-found.jpg => pepes/pepe-2.jpg} (100%) rename frontend/public/{nickname-form.jpg => pepes/pepe-3.jpg} (100%) create mode 100644 frontend/public/pepes/pepe-4.jpg create mode 100644 frontend/public/pepes/pepe-5.jpg rename frontend/src/components/Cam/{CamNotFound.tsx => Page/CamDefaultPage.tsx} (51%) create mode 100644 frontend/src/components/Cam/Page/CamErrorPage.tsx create mode 100644 frontend/src/components/Cam/Page/CamLoadingPage.tsx rename frontend/src/components/Cam/{Nickname/NickNameForm.tsx => Page/CamNickNameInputPage.tsx} (69%) create mode 100644 frontend/src/components/Cam/Page/CamNotAvailablePage.tsx create mode 100644 frontend/src/components/Cam/Page/CamNotFoundPage.tsx diff --git a/frontend/public/user-default.jpg b/frontend/public/pepes/pepe-1.jpg similarity index 100% rename from frontend/public/user-default.jpg rename to frontend/public/pepes/pepe-1.jpg diff --git a/frontend/public/not-found.jpg b/frontend/public/pepes/pepe-2.jpg similarity index 100% rename from frontend/public/not-found.jpg rename to frontend/public/pepes/pepe-2.jpg diff --git a/frontend/public/nickname-form.jpg b/frontend/public/pepes/pepe-3.jpg similarity index 100% rename from frontend/public/nickname-form.jpg rename to frontend/public/pepes/pepe-3.jpg diff --git a/frontend/public/pepes/pepe-4.jpg b/frontend/public/pepes/pepe-4.jpg new file mode 100644 index 0000000000000000000000000000000000000000..db9f61c92445aac7c928df722c4ab06794fc388d GIT binary patch literal 60702 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로?딩; + return ; case 403: - return
방이 꽉?찬
; + return ; case 404: return ; case 200: if (!userInfo?.nickname) { - return ( - - - - ); + return ; } return ( @@ -97,7 +96,7 @@ function Cam(): JSX.Element { ); default: - return
뭔가 오류
; + return ; } } diff --git a/frontend/src/components/Cam/CamNotFound.tsx b/frontend/src/components/Cam/Page/CamDefaultPage.tsx similarity index 51% rename from frontend/src/components/Cam/CamNotFound.tsx rename to frontend/src/components/Cam/Page/CamDefaultPage.tsx index 6c882da..e95603a 100644 --- a/frontend/src/components/Cam/CamNotFound.tsx +++ b/frontend/src/components/Cam/Page/CamDefaultPage.tsx @@ -1,6 +1,11 @@ import React from 'react'; import styled from 'styled-components'; +type CamDefaultPageProps = { + backgroundSrc: string; + children: React.ReactChild | React.ReactChild[]; +}; + const Container = styled.div` position: fixed; width: 100vw; @@ -11,17 +16,7 @@ const Container = styled.div` display: flex; justify-content: center; align-items: center; -`; - -const Title = styled.div` - background-color: rgba(0, 0, 0, 0.7); - width: 100%; - height: 20%; - display: flex; - justify-content: center; - align-items: center; - color: white; - font-size: 44px; + background-color: white; `; const Background = styled.img` @@ -30,15 +25,17 @@ const Background = styled.img` height: auto; margin: 0 auto; opacity: 0.1; + z-index: -1; `; -function CamNotFound(): JSX.Element { +function CamDefaultPage(props: CamDefaultPageProps): JSX.Element { + const { children, backgroundSrc } = props; return ( - - 존재하지 않는 방입니다. + + {children} ); } -export default CamNotFound; +export default CamDefaultPage; diff --git a/frontend/src/components/Cam/Page/CamErrorPage.tsx b/frontend/src/components/Cam/Page/CamErrorPage.tsx new file mode 100644 index 0000000..60dbc89 --- /dev/null +++ b/frontend/src/components/Cam/Page/CamErrorPage.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import styled from 'styled-components'; +import CamDefaultPage from './CamDefaultPage'; + +const Title = styled.div` + background-color: rgba(0, 0, 0, 0.7); + width: 100%; + height: 20%; + display: flex; + justify-content: center; + align-items: center; + color: white; + font-size: 44px; +`; + +function CamErrorPage(): JSX.Element { + return ( + + 알 수 없는 오류가 발생했습니다. + + ); +} + +export default CamErrorPage; diff --git a/frontend/src/components/Cam/Page/CamLoadingPage.tsx b/frontend/src/components/Cam/Page/CamLoadingPage.tsx new file mode 100644 index 0000000..820e554 --- /dev/null +++ b/frontend/src/components/Cam/Page/CamLoadingPage.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import styled from 'styled-components'; +import CamDefaultPage from './CamDefaultPage'; + +const Title = styled.div` + background-color: rgba(0, 0, 0, 0.7); + width: 100%; + height: 20%; + display: flex; + justify-content: center; + align-items: center; + color: white; + font-size: 44px; +`; + +function CamLoadingPage(): JSX.Element { + return ( + + Loading... + + ); +} + +export default CamLoadingPage; diff --git a/frontend/src/components/Cam/Nickname/NickNameForm.tsx b/frontend/src/components/Cam/Page/CamNickNameInputPage.tsx similarity index 69% rename from frontend/src/components/Cam/Nickname/NickNameForm.tsx rename to frontend/src/components/Cam/Page/CamNickNameInputPage.tsx index 0b2daee..6c2725f 100644 --- a/frontend/src/components/Cam/Nickname/NickNameForm.tsx +++ b/frontend/src/components/Cam/Page/CamNickNameInputPage.tsx @@ -1,19 +1,7 @@ import React from 'react'; import styled from 'styled-components'; import { UserInfo } from '../../../types/cam'; - -const Container = styled.div` - position: fixed; - width: 100vw; - height: 100vh; - left: 0px; - right: 0px; - - display: flex; - justify-content: center; - align-items: center; - background-color: white; -`; +import CamDefaultPage from './CamDefaultPage'; const Form = styled.form` background-color: rgba(0, 0, 0, 0.7); @@ -48,20 +36,11 @@ const SubmitButton = styled.button` } `; -const Background = styled.img` - position: absolute; - width: 70%; - height: auto; - margin: 0 auto; - opacity: 0.1; - z-index: -1; -`; - -type NickNameFormProps = { +type CamNickNameInputPageProps = { setUserInfo: React.Dispatch>; }; -function NickNameForm(props: NickNameFormProps): JSX.Element { +function CamNickNameInputPage(props: CamNickNameInputPageProps): JSX.Element { const { setUserInfo } = props; const onSubmitNicknameForm = (e: React.FormEvent) => { @@ -75,14 +54,13 @@ function NickNameForm(props: NickNameFormProps): JSX.Element { }; return ( - - + 입력 - + ); } -export default NickNameForm; +export default CamNickNameInputPage; diff --git a/frontend/src/components/Cam/Page/CamNotAvailablePage.tsx b/frontend/src/components/Cam/Page/CamNotAvailablePage.tsx new file mode 100644 index 0000000..21d919a --- /dev/null +++ b/frontend/src/components/Cam/Page/CamNotAvailablePage.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import styled from 'styled-components'; +import CamDefaultPage from './CamDefaultPage'; + +const Title = styled.div` + background-color: rgba(0, 0, 0, 0.7); + width: 100%; + height: 20%; + display: flex; + justify-content: center; + align-items: center; + color: white; + font-size: 44px; +`; + +function CamNotAvailablePage(): JSX.Element { + return ( + + 참여 인원을 초과하였습니다. + + ); +} + +export default CamNotAvailablePage; diff --git a/frontend/src/components/Cam/Page/CamNotFoundPage.tsx b/frontend/src/components/Cam/Page/CamNotFoundPage.tsx new file mode 100644 index 0000000..4632cc0 --- /dev/null +++ b/frontend/src/components/Cam/Page/CamNotFoundPage.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import styled from 'styled-components'; +import CamDefaultPage from './CamDefaultPage'; + +const Title = styled.div` + background-color: rgba(0, 0, 0, 0.7); + width: 100%; + height: 20%; + display: flex; + justify-content: center; + align-items: center; + color: white; + font-size: 44px; +`; + +function CamNotFoundPage(): JSX.Element { + return ( + + 존재하지 않는 방입니다. + + ); +} + +export default CamNotFoundPage; diff --git a/frontend/src/components/Cam/Screen/DefaultScreen.tsx b/frontend/src/components/Cam/Screen/DefaultScreen.tsx index ad3c668..089ea4f 100644 --- a/frontend/src/components/Cam/Screen/DefaultScreen.tsx +++ b/frontend/src/components/Cam/Screen/DefaultScreen.tsx @@ -15,7 +15,7 @@ const DefaultImg = styled.img` function DefaultScreen(): JSX.Element { return ( - + ); } From 728cff3faca82b9830e8e930c9eb4f5699c273ff Mon Sep 17 00:00:00 2001 From: korung3195 Date: Thu, 25 Nov 2021 17:25:58 +0900 Subject: [PATCH 160/172] =?UTF-8?q?Fix=20:=20CamListItem=20=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=EA=B4=80=EB=A0=A8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존에 a 태그가 하위에 위치해서 정확히 클릭해야만 링크로 이동할 수 있어서, a태그를 더 위로 올려서 ux의 향상을 시도했습니다. --- .../src/components/Main/Cam/CamListItem.tsx | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/Main/Cam/CamListItem.tsx b/frontend/src/components/Main/Cam/CamListItem.tsx index 490f70e..ac87e57 100644 --- a/frontend/src/components/Main/Cam/CamListItem.tsx +++ b/frontend/src/components/Main/Cam/CamListItem.tsx @@ -10,7 +10,7 @@ type CamListItemProps = { url: string; }; -const Container = styled.div` +const Container = styled.a` width: 100%; height: 25px; @@ -22,6 +22,12 @@ const Container = styled.div` box-sizing: border-box; padding: 15px 0px 15px 25px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: inherit; + text-decoration: none; + &:hover { cursor: pointer; } @@ -35,13 +41,8 @@ const HashIcon = styled(Hash)` fill: #a69c96; `; -const CamNameSpan = styled.a` +const CamNameSpan = styled.span` padding: 5px 0px 5px 5px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - color: inherit; - text-decoration: none; `; function CamListItem(props: CamListItemProps): JSX.Element { @@ -49,9 +50,9 @@ function CamListItem(props: CamListItemProps): JSX.Element { const camURL = `/cam?roomid=${url}`; return ( - + - {name} + {name} ); } From 32a15920e93ecb8d5118c570cef21c06613537f1 Mon Sep 17 00:00:00 2001 From: "pjs7160@gmail.com" <49611158+Suppplier@users.noreply.github.com> Date: Thu, 25 Nov 2021 17:45:50 +0900 Subject: [PATCH 161/172] =?UTF-8?q?Feat=20:=20Thread=EC=97=90=EC=84=9C=20C?= =?UTF-8?q?omment=EB=A5=BC=20=EB=B3=B4=EB=82=B4=EA=B3=A0=20=EB=B0=9B?= =?UTF-8?q?=EC=9D=84=20=EC=88=98=20=EC=9E=88=EB=8A=94=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ThreadSection의 textarea를 통해 Comment를 보낼 수 있고, 해당 Message의 Comment들을 볼 수 있습니다. - Socket 통신을 위하여 MessageSection에서 messageList 상태를 MainSection으로 끌어올렸습니다. --- .../Main/ContentsSection/ContentsSection.tsx | 10 ++- .../Main/ContentsSection/MessageSection.tsx | 24 ++---- .../Main/ContentsSection/ThreadSection.tsx | 82 +++++++++++++++++-- frontend/src/components/Main/MainSection.tsx | 23 +++++- frontend/src/components/Main/MainStore.tsx | 2 +- frontend/src/types/comment.ts | 17 ++++ frontend/src/types/{messags.ts => message.ts} | 2 +- 7 files changed, 128 insertions(+), 32 deletions(-) create mode 100644 frontend/src/types/comment.ts rename frontend/src/types/{messags.ts => message.ts} (81%) diff --git a/frontend/src/components/Main/ContentsSection/ContentsSection.tsx b/frontend/src/components/Main/ContentsSection/ContentsSection.tsx index 43ac853..6238259 100644 --- a/frontend/src/components/Main/ContentsSection/ContentsSection.tsx +++ b/frontend/src/components/Main/ContentsSection/ContentsSection.tsx @@ -1,5 +1,6 @@ import React from 'react'; import styled from 'styled-components'; +import { MessageData } from '../../../types/message'; import MessageSection from './MessageSection'; import ThreadSection from './ThreadSection'; @@ -13,10 +14,15 @@ const Container = styled.div` align-items: center; `; -function ContentsSection(): JSX.Element { +type ContentsSectionProps = { + messageList: MessageData[]; +}; + +function ContentsSection(props: ContentsSectionProps): JSX.Element { + const { messageList } = props; return ( - + ); diff --git a/frontend/src/components/Main/ContentsSection/MessageSection.tsx b/frontend/src/components/Main/ContentsSection/MessageSection.tsx index f96a859..bf65659 100644 --- a/frontend/src/components/Main/ContentsSection/MessageSection.tsx +++ b/frontend/src/components/Main/ContentsSection/MessageSection.tsx @@ -2,7 +2,7 @@ import React, { useContext, useEffect, useRef, useState } from 'react'; import styled from 'styled-components'; import { MainStoreContext } from '../MainStore'; -import { MessageData, MessageRequestBody } from '../../../types/messags'; +import { MessageData, MessageRequestBody } from '../../../types/message'; import fetchData from '../../../utils/fetchMethods'; const Container = styled.div` @@ -171,28 +171,22 @@ const MessageTextarea = styled.textarea` } `; -function MessageSection(): JSX.Element { +type MessageSectionProps = { + messageList: MessageData[]; +}; + +function MessageSection(props: MessageSectionProps): JSX.Element { const { selectedChannel, setSelectedMessageData } = useContext(MainStoreContext); - const [messageList, setMessageList] = useState([]); + const { messageList } = props; const textDivRef = useRef(null); const tmpChannelName = '# ChannelName'; - const getMessageList = async () => { - const responseData = await fetchData('GET', `/api/messages?channelId=${selectedChannel}`); - - if (responseData) { - responseData.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10)); - setMessageList(responseData); - } - }; - const sendMessage = async (contents: string) => { const requestBody: MessageRequestBody = { channelId: selectedChannel, contents, }; await fetchData('POST', '/api/messages', requestBody); - getMessageList(); }; const onKeyDownMessageTextarea = (e: React.KeyboardEvent) => { @@ -240,10 +234,6 @@ function MessageSection(): JSX.Element { ); }); - useEffect(() => { - getMessageList(); - }, [selectedChannel]); - return ( diff --git a/frontend/src/components/Main/ContentsSection/ThreadSection.tsx b/frontend/src/components/Main/ContentsSection/ThreadSection.tsx index 61c8a7a..3fc062c 100644 --- a/frontend/src/components/Main/ContentsSection/ThreadSection.tsx +++ b/frontend/src/components/Main/ContentsSection/ThreadSection.tsx @@ -1,6 +1,7 @@ -import React, { useContext, useEffect, useState } from 'react'; +import React, { useContext, useEffect, useRef, useState } from 'react'; import styled from 'styled-components'; -import { MessageData } from '../../../types/messags'; +import { CommentData, CommentRequestBody } from '../../../types/comment'; +import { MessageData } from '../../../types/message'; import fetchData from '../../../utils/fetchMethods'; import { BoostCamMainIcons } from '../../../utils/SvgIcons'; @@ -12,11 +13,16 @@ const Container = styled.div` flex: 1 0 0; height: 100%; background-color: white; + + display: flex; + flex-direction: column; + justify-content: flex-start; `; const ThreadSectionHeader = styled.div` width: 100%; - height: 50px; + flex: 1 1 0; + max-height: 50px; font-size: 18px; @@ -45,6 +51,7 @@ const ChannelNameSpan = styled.span` const ThreadSectionBody = styled.div` width: 100%; + flex: 5 0 0; overflow-y: auto; display: flex; @@ -177,14 +184,16 @@ const CloseIcon = styled(Close)` `; function ThreadSection(): JSX.Element { - const { selectedMessageData } = useContext(MainStoreContext); + const { selectedMessageData, selectedChannel } = useContext(MainStoreContext); + const textDivRef = useRef(null); + const [commentsList, setCommentsList] = useState([]); const buildCommentElement = (data: MessageData | undefined) => { if (!data) return <>; - const { contents, createdAt, sender } = data; + const { id, contents, createdAt, sender } = data; const { nickname, profile } = sender; return ( - + @@ -215,11 +224,63 @@ function ThreadSection(): JSX.Element { ); }; + const getMessageList = async () => { + if (!selectedMessageData) return; + const responseData = await fetchData( + 'GET', + `/api/comments?messageId=${selectedMessageData.id}`, + ); + if (responseData) { + responseData.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10)); + setCommentsList(responseData); + } + }; + + const sendComment = async (contents: string) => { + const requestBody: CommentRequestBody = { + channelId: parseInt(selectedChannel, 10), + messageId: parseInt(selectedMessageData.id, 10), + contents, + }; + await fetchData('POST', '/api/comments', requestBody); + getMessageList(); + }; + + const onKeyDownCommentTextarea = (e: React.KeyboardEvent) => { + const { key, currentTarget, shiftKey } = e; + const msg = currentTarget.value.trim(); + const divRef = textDivRef.current; + + currentTarget.style.height = '15px'; + currentTarget.style.height = `${currentTarget.scrollHeight - 15}px`; + if (divRef) { + divRef.style.height = `105px`; + divRef.style.height = `${90 + currentTarget.scrollHeight - 27}px`; + } + + if (!shiftKey && key === 'Enter') { + e.preventDefault(); + if (!msg.length) currentTarget.value = ''; + else { + sendComment(currentTarget.value); + currentTarget.value = ''; + } + currentTarget.style.height = '21px'; + if (divRef) divRef.style.height = `105px`; + } + }; + useEffect(() => { + getMessageList(); console.log(selectedMessageData); }, [selectedMessageData]); + useEffect(() => { + console.log(commentsList); + }, [commentsList]); + const mainMessage = buildMessageElement(selectedMessageData); + const CommentItemList = commentsList.map(buildCommentElement); return ( @@ -230,9 +291,12 @@ function ThreadSection(): JSX.Element { - {mainMessage} - - + + {mainMessage} + {CommentItemList} + + + ); diff --git a/frontend/src/components/Main/MainSection.tsx b/frontend/src/components/Main/MainSection.tsx index cf818bf..43d9d2d 100644 --- a/frontend/src/components/Main/MainSection.tsx +++ b/frontend/src/components/Main/MainSection.tsx @@ -1,9 +1,12 @@ -import React, { useEffect } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import styled from 'styled-components'; import RoomListSection from './RoomListSection'; import ContentsSection from './ContentsSection/ContentsSection'; import MainHeader from './MainHeader'; +import { MessageData } from '../../types/message'; +import fetchData from '../../utils/fetchMethods'; +import { MainStoreContext } from './MainStore'; const Container = styled.div` width: 100%; @@ -25,6 +28,22 @@ const MainBody = styled.div` `; function MainSection(): JSX.Element { + const { selectedChannel } = useContext(MainStoreContext); + const [messageList, setMessageList] = useState([]); + + const getMessageList = async () => { + const responseData = await fetchData('GET', `/api/messages?channelId=${selectedChannel}`); + + if (responseData) { + responseData.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10)); + setMessageList(responseData); + } + }; + + useEffect(() => { + getMessageList(); + }, [selectedChannel]); + useEffect(() => {}, []); return ( @@ -32,7 +51,7 @@ function MainSection(): JSX.Element { - + ); diff --git a/frontend/src/components/Main/MainStore.tsx b/frontend/src/components/Main/MainStore.tsx index c6a876e..1612905 100644 --- a/frontend/src/components/Main/MainStore.tsx +++ b/frontend/src/components/Main/MainStore.tsx @@ -1,6 +1,6 @@ import React, { createContext, useEffect, useState } from 'react'; import { CamData, ChannelData, MyServerData } from '../../types/main'; -import { MessageData } from '../../types/messags'; +import { MessageData } from '../../types/message'; export const MainStoreContext = createContext(null); diff --git a/frontend/src/types/comment.ts b/frontend/src/types/comment.ts new file mode 100644 index 0000000..caa248a --- /dev/null +++ b/frontend/src/types/comment.ts @@ -0,0 +1,17 @@ +import { MessageSender } from './message'; + +type CommentRequestBody = { + channelId: number; + messageId: number; + contents: string; +}; + +type CommentData = { + id: string; + channdId: string; + contents: string; + createdAt: string; + sender: MessageSender; +}; + +export type { CommentRequestBody, CommentData }; diff --git a/frontend/src/types/messags.ts b/frontend/src/types/message.ts similarity index 81% rename from frontend/src/types/messags.ts rename to frontend/src/types/message.ts index 9cc8603..03ba5e7 100644 --- a/frontend/src/types/messags.ts +++ b/frontend/src/types/message.ts @@ -17,4 +17,4 @@ type MessageData = { sender: MessageSender; }; -export type { MessageRequestBody, MessageData }; +export type { MessageRequestBody, MessageSender, MessageData }; From 021bd65ad14020f972c4acd8ba30b576c344f985 Mon Sep 17 00:00:00 2001 From: K Date: Thu, 25 Nov 2021 17:52:35 +0900 Subject: [PATCH 162/172] Feat : findChannelsByUserId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit userId를 이용하여 조인한 모든 채널에 대해서 가져오는 함수입니다. --- backend/src/user-channel/user-channel.service.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/src/user-channel/user-channel.service.ts b/backend/src/user-channel/user-channel.service.ts index a7f699a..88aedc8 100644 --- a/backend/src/user-channel/user-channel.service.ts +++ b/backend/src/user-channel/user-channel.service.ts @@ -71,4 +71,12 @@ export class UserChannelService { serverId, ); } + + async findChannelsByUserId(userId: number) { + const userChannels = await this.userChannelRepository.find({ + select: ['id'], + where: { user: { id: userId } }, + }); + return userChannels.map((uc) => uc.id.toString()); + } } From 5714a7e765fb4798d37cb35aa97be55197f1068a Mon Sep 17 00:00:00 2001 From: K Date: Thu, 25 Nov 2021 17:54:02 +0900 Subject: [PATCH 163/172] =?UTF-8?q?Feat=20:=20Socket=20=ED=86=B5=EC=8B=A0?= =?UTF-8?q?=20=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버와 클라이언트의 통신을 확인하였습니다. joinChannels는 접속한 모든 채널을 Room으로 조인합니다. --- backend/src/message.gateway.ts | 46 +++++++++++++++++++--- frontend/src/components/Main/MainStore.tsx | 9 +++++ 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/backend/src/message.gateway.ts b/backend/src/message.gateway.ts index ccb9192..106f404 100644 --- a/backend/src/message.gateway.ts +++ b/backend/src/message.gateway.ts @@ -1,6 +1,12 @@ -import { SubscribeMessage, WebSocketGateway } from '@nestjs/websockets'; -import { Socket } from 'socket.io'; +import { + SubscribeMessage, + WebSocketGateway, + WebSocketServer, +} from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; +import { MessageDto } from './message/message.dto'; import { ExpressSession } from './types/session'; +import { UserChannelService } from './user-channel/user-channel.service'; declare module 'http' { interface IncomingMessage { @@ -10,12 +16,40 @@ declare module 'http' { @WebSocketGateway({ namespace: '/message' }) export class MessageGateway { - @SubscribeMessage('message') - handleMessage(client: Socket, payload: any): string { + @WebSocketServer() + private server: Server; + + constructor(private userChannelService: UserChannelService) {} + + @SubscribeMessage('joinChannels') + async handleConnect(client: Socket, payload: any) { + if (!this.checkLoginSession(client)) { + client.disconnect(); + return; + } const user = client.request.session.user; - if (!user) { + const channels = await this.userChannelService.findChannelsByUserId( + user.id, + ); + + client.join(channels); + } + + @SubscribeMessage('joinChannel') + handleMessage(client: Socket, payload: { channelId: number }) { + if (!this.checkLoginSession(client)) { return; } - return 'Hello world!'; + const channelRoom = payload.channelId.toString(); + client.join(channelRoom); + } + + private checkLoginSession(client: Socket): boolean { + const user = client.request.session.user; + return !!user; + } + + emitMessage(channelId: number, message: MessageDto) { + this.server.to(`${channelId}`).emit('sendMessage', message); } } diff --git a/frontend/src/components/Main/MainStore.tsx b/frontend/src/components/Main/MainStore.tsx index fb8f449..29a5672 100644 --- a/frontend/src/components/Main/MainStore.tsx +++ b/frontend/src/components/Main/MainStore.tsx @@ -1,4 +1,5 @@ import React, { createContext, useEffect, useState } from 'react'; +import { io } from 'socket.io-client'; import { CamData, ChannelData, MyServerData } from '../../types/main'; export const MainStoreContext = createContext(null); @@ -7,6 +8,10 @@ type MainStoreProps = { children: React.ReactChild[] | React.ReactChild; }; +const socket = io('/message', { + withCredentials: true, +}); + function MainStore(props: MainStoreProps): JSX.Element { const { children } = props; const [selectedServer, setSelectedServer] = useState(); @@ -60,6 +65,10 @@ function MainStore(props: MainStoreProps): JSX.Element { } }; + useEffect(() => { + socket.emit('joinChannels'); + }, []); + useEffect(() => { if (selectedServer) { getServerChannelList(); From 3153e64b29bfd00a3a6b75cc33f59fd0f25d122a Mon Sep 17 00:00:00 2001 From: korung3195 Date: Thu, 25 Nov 2021 18:01:53 +0900 Subject: [PATCH 164/172] =?UTF-8?q?Feat=20:=20cam=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=ED=98=84=EC=9E=AC=20url=EC=9D=84=20=EB=B3=B5=EC=82=AC=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cam에서는 현재 url 자체가 초대 코드이므로 클립보드에 복사하는 버튼을 추가하였습니다. --- frontend/public/pepes/pepe-5.jpg | Bin 38768 -> 55395 bytes frontend/src/assets/icons/copy.svg | 1 + .../src/components/Cam/Menu/ButtonBar.tsx | 10 ++++++++++ frontend/src/utils/SvgIcons.ts | 3 ++- 4 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 frontend/src/assets/icons/copy.svg diff --git a/frontend/public/pepes/pepe-5.jpg b/frontend/public/pepes/pepe-5.jpg index 3c5f1bd601ad04cf98cced26bbc2930d04307501..c37752f44405fe270ffceb647879be92bafc1c7a 100644 GIT binary patch literal 55395 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 \ No newline at end of file diff --git a/frontend/src/components/Cam/Menu/ButtonBar.tsx b/frontend/src/components/Cam/Menu/ButtonBar.tsx index befd571..494d945 100644 --- a/frontend/src/components/Cam/Menu/ButtonBar.tsx +++ b/frontend/src/components/Cam/Menu/ButtonBar.tsx @@ -20,6 +20,7 @@ const { ExitIcon, STTIcon, STTDisabledIcon, + CopyIcon, } = ButtonBarIcons; const Container = styled.div<{ isMouseOnCamPage: boolean }>` @@ -120,6 +121,11 @@ function ButtonBar(props: ButtonBarProps): JSX.Element { setMouseOnCamPage(false); }; + const onClickCopyURLButton = async () => { + const url = window.location.href; + await navigator.clipboard.writeText(url); + }; + useEffect(() => { if (camRef?.current) { camRef.current.onmouseover = handleMouseOverCamPage; @@ -144,6 +150,10 @@ function ButtonBar(props: ButtonBarProps): JSX.Element { +