diff --git a/README.md b/README.md index 6a92f51a..757e19e3 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,18 @@ 메인 배너
-### [:ledger: 팀 노션](https://www.notion.so/simeunseo/9-Ticle-12e599a6f0d2804682ccd2251248a435?pvs=4) | [:mag: 위키](https://github.com/boostcampwm-2024/web21-boostproject/wiki) | [🎨 피그마](https://www.figma.com/design/nw74detTvjXGrDP2cfdmwp/TICLE-%EB%94%94%EC%9E%90%EC%9D%B8?node-id=32-4477&t=3FCCnBpgQXMZs63X-1) | [🗓️ 스프린트 백로그](https://github.com/orgs/boostcampwm-2024/projects/82/views/7) | [🧪 스토리북](https://673a0cccd15a760db778c591-kxdpixadfg.chromatic.com/?path=/docs/common-dialog--docs) +### [:ledger: 팀 노션](https://www.notion.so/simeunseo/9-Ticle-12e599a6f0d2804682ccd2251248a435?pvs=4) | [:mag: 위키](https://github.com/boostcampwm-2024/web21-boostproject/wiki) | [🎨 피그마](https://www.figma.com/design/nw74detTvjXGrDP2cfdmwp/TICLE-%EB%94%94%EC%9E%90%EC%9D%B8?node-id=32-4477&t=3FCCnBpgQXMZs63X-1) | [🗓️ 스프린트 백로그](https://github.com/orgs/boostcampwm-2024/projects/82/views/7) | [🧪 스토리북](https://673a0cccd15a760db778c591-ttyfhdnavn.chromatic.com/) # 🖧 시스템 아키텍처 -아키텍처 +![Cloudcraft Image (4)](https://github.com/user-attachments/assets/1e5874ee-2485-4e89-90a0-cebb47621c77) # 🏃 작업 진행 상황 -- [🆕 4주차 발표자료](https://simeunseo.notion.site/4-1ccf63bab4b14fd8b249f5d7c7cd7e53?pvs=4) +- [🆕 5주차 발표자료](https://www.figma.com/slides/kNBDNutRalcIkuGWo3LHd9/5%EC%A3%BC%EC%B0%A8-%EB%8D%B0%EB%AA%A8-%EB%B0%9C%ED%91%9C%EC%9E%90%EB%A3%8C?node-id=1-23&t=LH1JpAKm2xhJUq9q-1) +- [4주차 발표자료](https://simeunseo.notion.site/4-1ccf63bab4b14fd8b249f5d7c7cd7e53?pvs=4) - [3주차 발표자료](https://simeunseo.notion.site/3-0df689ca7cd3407b89a93284854a54b8?pvs=4) - [2주차 발표자료](https://simeunseo.notion.site/2-137599a6f0d2809fa498fa1cc31d97f9?pvs=4) - [1주차 발표자료](https://simeunseo.notion.site/1-130599a6f0d2804597e0c55e8ee33920?pvs=4) @@ -28,29 +29,33 @@ 카메라와 음성을 통해 발표자와 참여자가 실시간으로 지식을 공유할 수 있습니다. 화면 공유 기능으로 더욱 효과적인 지식 전달이 가능합니다. -화면공유 +![티클 시작](https://github.com/user-attachments/assets/f0c3f9b3-cbda-49b2-a6a3-4c77fd569129) -### **✔️ 티클 신청 시스템** +### **티클 목록** - 참여자는 티클 리스트에서 원하는 티클을 선택하고 신청할 수 있습니다. - 발표자는 자신의 티클 관리 페이지에서 신청자를 확인하고 발표를 시작할 수 있습니다. + 티클 목록을 확인하고 정렬, 필터링할 수 있습니다. -티클 리스트(메인) -
-개설한 티클 관리 -신청한 티클 관리 -
+![티클목록4](https://github.com/user-attachments/assets/ac4cbba2-5552-43d3-8361-4159f9c3a48b) + +### **✔️ 티클 개설** + + 발표자는 원하는 티클을 개설할 수 있습니다. + +![티클개설4](https://github.com/user-attachments/assets/56b33a06-a85d-49a2-b830-c46d44879ce2) -### **✔️ 티클 소개 페이지** +### **✔️ 티클 관리 대시보드** - 티클 내용과 발표자 정보를 제공합니다. + 참여자는 신청한 티클을 대시보드에서 확인하고 참가할 수 있습니다. + 발표자는 대시보드에서 신청자를 확인하고 발표를 시작할 수 있습니다. -티클 개설하기 +![대시보드4](https://github.com/user-attachments/assets/d6f57d72-135e-4e36-bba9-784a1fd368a2) ### **✔️ AI 요약 기능** CLOVA API를 이용해 티클 내용을 요약하여 제공합니다. +![image](https://github.com/user-attachments/assets/825a5ea0-e873-40e1-a866-5706bde4bf5a) + # ✍️ 학습 정리 | 분야 | 기술 | @@ -99,7 +104,7 @@ Web FE·BE - Web FE·BE + Web FE Web FE Web BE Web BE diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 4b571613..9cc5afb7 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -1,10 +1,11 @@ -import { Body, Controller, Get, Post, Res, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Post, Query, Res, UseGuards } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ThrottlerGuard } from '@nestjs/throttler'; import { Response } from 'express'; import { GetUserId } from '@/common/decorator/get-userId.decorator'; +import { CookieConfig } from '@/config/cookie.config'; import { AuthService } from './auth.service'; import { LocalLoginRequestDto } from './dto/localLoginRequest.dto'; @@ -17,10 +18,15 @@ import { LocalAuthGuard } from './local/local-auth.guard'; @Controller('auth') @ApiTags('Auth') export class AuthController { + private readonly redirectUrl: string; + constructor( - private authService: AuthService, - private configService: ConfigService - ) {} + private readonly authService: AuthService, + private readonly configService: ConfigService, + private readonly cookieConfig: CookieConfig + ) { + this.redirectUrl = this.configService.get('LOGIN_REDIRECT_URL'); + } @Post('signup') @ApiOperation({ summary: '로컬 회원가입' }) @@ -37,17 +43,21 @@ export class AuthController { @ApiResponse({ status: 302, description: '홈으로 리다이렉션' }) @ApiResponse({ status: 401 }) @UseGuards(LocalAuthGuard) - localLogin(@GetUserId() userId: number, @Res() response: Response) { - this.cookieInsertJWT(response, userId); + localLogin( + @GetUserId() userId: number, + @Query('redirect') redirect: string, + @Res() response: Response + ) { + this.loginProcess(response, userId, redirect); } - @Post('guest/login') + @Get('guest/login') @ApiOperation({ summary: '게스트 로그인' }) @ApiResponse({ status: 302, description: '홈으로 리다이렉션' }) @UseGuards(ThrottlerGuard) - async guestLogin(@Res() response: Response) { + async guestLogin(@Query('redirect') redirect: string, @Res() response: Response) { const guestUser = await this.authService.createGuestUser(); - this.cookieInsertJWT(response, guestUser.id); + this.loginProcess(response, guestUser.id, redirect); } @Get('google/login') @@ -59,8 +69,12 @@ export class AuthController { @Get('google/callback') @UseGuards(GoogleAuthGuard) - googleAuthCallback(@GetUserId() userId: number, @Res() response: Response) { - this.cookieInsertJWT(response, userId); + googleAuthCallback( + @GetUserId() userId: number, + @Query('state') state: string, + @Res() response: Response + ) { + this.loginProcess(response, userId, state); } @Get('github/login') @@ -72,26 +86,30 @@ export class AuthController { @Get('github/callback') @UseGuards(GitHubAuthGuard) - githubAuthCallback(@GetUserId() userId: number, @Res() response: Response) { - this.cookieInsertJWT(response, userId); + githubAuthCallback( + @GetUserId() userId: number, + @Query('state') state: string, + @Res() response: Response + ) { + this.loginProcess(response, userId, state); } - private setAuthCookie(response: Response, accessToken: string) { - response.cookie('accessToken', accessToken, { - httpOnly: true, - secure: this.configService.get('NODE_ENV') === 'production', - sameSite: 'lax', - path: '/', - }); + @Get('logout') + @ApiOperation({ summary: '로그아웃' }) + @ApiResponse({ status: 302, description: '홈으로 리다이렉션' }) + logout(@Res() response: Response) { + response.clearCookie('accessToken', this.cookieConfig.getAuthCookieOptions()); + this.redirectToHome(response); } - private cookieInsertJWT( - response: Response, - userId: number, - redirectUrl: string = this.configService.get('LOGIN_REDIRECT_URL') - ) { + private loginProcess(response: Response, userId: number, path?: string) { const { accessToken } = this.authService.createJWT(userId); - this.setAuthCookie(response, accessToken); + response.cookie('accessToken', accessToken, this.cookieConfig.getAuthCookieOptions()); + this.redirectToHome(response, path); + } + + private redirectToHome(response: Response, path?: string) { + const redirectUrl = `${this.redirectUrl}${path || ''}`; response.redirect(redirectUrl); } } diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 695fbbe5..827b4e39 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -3,6 +3,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; +import { CookieConfig } from '@/config/cookie.config'; import { UserModule } from '@/user/user.module'; import { AuthController } from './auth.controller'; @@ -27,6 +28,13 @@ import { LocalStrategy } from './local/local.strategy'; }), ], controllers: [AuthController], - providers: [AuthService, LocalStrategy, JwtStrategy, GitHubStrategy, GoogleStrategy], + providers: [ + AuthService, + LocalStrategy, + JwtStrategy, + GitHubStrategy, + GoogleStrategy, + CookieConfig, + ], }) export class AuthModule {} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 315a586e..2ecfe0d0 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -1,6 +1,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; +import { Provider } from '@repo/types'; import { CreateSocialUserDto } from '@/user/dto/createSocialUser.dto'; import { UserService } from '@/user/user.service'; @@ -15,7 +16,7 @@ export class AuthService { ) {} async signupLocal(signupRequestDto: LocalSignupRequestDto) { - return this.userService.createLocalUser({ provider: 'local', ...signupRequestDto }); + return this.userService.createLocalUser({ provider: Provider.local, ...signupRequestDto }); } async validateLocalLogin(username: string, inputPassword: string) { @@ -32,17 +33,20 @@ export class AuthService { async createGuestUser() { const randomNum = Math.floor(Math.random() * 10000); + const response = await fetch('https://api.thecatapi.com/v1/images/search'); + const catImageUrl = (await response.json())[0].url; + const guestUser = { username: `guest_${randomNum}`, password: `guest_password_${randomNum}`, email: `guet_email@guest.com`, nickname: `guest_${randomNum}`, introduce: `게스트 사용자입니다. `, - profileImageUrl: `https://cataas.com/cat?${Date.now()}`, + profileImageUrl: catImageUrl, }; const user = await this.userService.findUserByUsername(guestUser.username); if (!user) { - return this.userService.createLocalUser({ provider: 'guest', ...guestUser }); + return this.userService.createLocalUser({ provider: Provider.guest, ...guestUser }); } return user; } diff --git a/apps/api/src/auth/github/github.strategy.ts b/apps/api/src/auth/github/github.strategy.ts index cf8c4502..e56592d7 100644 --- a/apps/api/src/auth/github/github.strategy.ts +++ b/apps/api/src/auth/github/github.strategy.ts @@ -1,12 +1,14 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; -import { Profile, Strategy } from 'passport-github2'; +import { Request } from 'express'; +import { Profile, Strategy, StrategyOption } from 'passport-github2'; +import { Provider } from '@repo/types'; import { AuthService } from '../auth.service'; @Injectable() -export class GitHubStrategy extends PassportStrategy(Strategy, 'github') { +export class GitHubStrategy extends PassportStrategy(Strategy, Provider.github) { constructor( private configService: ConfigService, private authService: AuthService @@ -18,12 +20,19 @@ export class GitHubStrategy extends PassportStrategy(Strategy, 'github') { scope: ['user:email'], }); } + authenticate(req: Request, options: StrategyOption) { + const returnUrl = req.query.redirect as string; + if (returnUrl) { + options.state = returnUrl; + } + return super.authenticate(req, options); + } async validate(accessToken: string, refreshToken: string, profile: Profile) { const { id, username, emails, photos } = profile; const user = { - provider: 'github', + provider: Provider.github, socialId: id, nickname: username, email: emails[0].value, diff --git a/apps/api/src/auth/google/google.strategy.ts b/apps/api/src/auth/google/google.strategy.ts index 0d32ed2f..5252a18a 100644 --- a/apps/api/src/auth/google/google.strategy.ts +++ b/apps/api/src/auth/google/google.strategy.ts @@ -1,12 +1,15 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; +import { Request } from 'express'; +import { StrategyOption } from 'passport-github2'; import { Profile, Strategy } from 'passport-google-oauth20'; +import { Provider } from '@repo/types'; import { AuthService } from '../auth.service'; @Injectable() -export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { +export class GoogleStrategy extends PassportStrategy(Strategy, Provider.google) { constructor( private configService: ConfigService, private authService: AuthService @@ -19,11 +22,19 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { }); } + authenticate(req: Request, options: StrategyOption) { + const returnUrl = req.query.redirect as string; + if (returnUrl) { + options.state = returnUrl; + } + return super.authenticate(req, options); + } + async validate(accessToken: string, refreshToken: string, profile: Profile): Promise { const { id, displayName, emails, photos } = profile; const user = { - provider: 'google', + provider: Provider.google, socialId: id, nickname: displayName, email: emails[0].value, diff --git a/apps/api/src/config/cookie.config.ts b/apps/api/src/config/cookie.config.ts new file mode 100644 index 00000000..60fc1f18 --- /dev/null +++ b/apps/api/src/config/cookie.config.ts @@ -0,0 +1,18 @@ +// config/cookie.config.ts +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { CookieOptions } from 'express'; + +@Injectable() +export class CookieConfig { + constructor(private configService: ConfigService) {} + + getAuthCookieOptions(): CookieOptions { + return { + httpOnly: true, + secure: this.configService.get('NODE_ENV') === 'production', + sameSite: 'lax', + path: '/', + }; + } +} diff --git a/apps/api/src/dashboard/dashboard.controller.ts b/apps/api/src/dashboard/dashboard.controller.ts index 91c03aff..fbd84d9a 100644 --- a/apps/api/src/dashboard/dashboard.controller.ts +++ b/apps/api/src/dashboard/dashboard.controller.ts @@ -31,8 +31,19 @@ export class DashboardController { return await this.dashboardService.getApplicants(ticleId); } - @Post('start') - startTicle(@Param('ticleId') ticleId: number) {} + @Post(':ticleId/start') + @UseGuards(JwtAuthGuard) + async startTicle(@GetUserId() userId: number, @Param('ticleId') ticleId: number) { + await this.dashboardService.startTicle(userId, ticleId); + return 'success ticle start'; + } + + @Post(':ticleId/end') + @UseGuards(JwtAuthGuard) + async endTicle(@GetUserId() userId: number, @Param('ticleId') ticleId: number) { + await this.dashboardService.endTicle(userId, ticleId); + return 'success ticle end'; + } @Post('join') joinTicle(@Param('ticleId') ticleId: number) {} diff --git a/apps/api/src/dashboard/dashboard.service.ts b/apps/api/src/dashboard/dashboard.service.ts index 5a36e659..78b89263 100644 --- a/apps/api/src/dashboard/dashboard.service.ts +++ b/apps/api/src/dashboard/dashboard.service.ts @@ -1,7 +1,7 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { TicleStatus } from '@repo/types'; +import { ErrorMessage, TicleStatus } from '@repo/types'; import { Applicant } from '@/entity/applicant.entity'; import { Ticle } from '@/entity/ticle.entity'; @@ -31,7 +31,13 @@ export class DashboardService { .take(pageSize); if (status) { - queryBuilder.andWhere('ticle.ticleStatus = :status', { status }); + if (status === TicleStatus.OPEN) { + queryBuilder.andWhere('ticle.ticleStatus IN (:...statuses)', { + statuses: [TicleStatus.OPEN, TicleStatus.IN_PROGRESS], + }); + } else { + queryBuilder.andWhere('ticle.ticleStatus = :status', { status }); + } } const [ticles, totalItems] = await queryBuilder.getManyAndCount(); @@ -70,7 +76,13 @@ export class DashboardService { .take(pageSize); if (status) { - queryBuilder.andWhere('ticle.ticleStatus = :status', { status }); + if (status === TicleStatus.OPEN) { + queryBuilder.andWhere('ticle.ticleStatus IN (:...statuses)', { + statuses: [TicleStatus.OPEN, TicleStatus.IN_PROGRESS], + }); + } else { + queryBuilder.andWhere('ticle.ticleStatus = :status', { status }); + } } const [applicants, totalItems] = await queryBuilder.getManyAndCount(); @@ -102,4 +114,46 @@ export class DashboardService { }, }); } + + async startTicle(userId: number, ticleId: number) { + const ticle = await this.ticleRepository.findOne({ + where: { id: ticleId }, + relations: ['speaker'], + }); + + if (!ticle) { + throw new NotFoundException(ErrorMessage.TICLE_NOT_FOUND); + } + if (ticle.speaker.id !== userId) { + throw new BadRequestException(ErrorMessage.CANNOT_START_TICLE); + } + if (ticle.ticleStatus !== TicleStatus.OPEN) { + throw new BadRequestException(ErrorMessage.CANNOT_START_TICLE); + } + + ticle.ticleStatus = TicleStatus.IN_PROGRESS; + await this.ticleRepository.save(ticle); + return; + } + + async endTicle(userId: number, ticleId: number) { + const ticle = await this.ticleRepository.findOne({ + where: { id: ticleId }, + relations: ['speaker'], + }); + + if (!ticle) { + throw new NotFoundException(ErrorMessage.TICLE_NOT_FOUND); + } + if (ticle.speaker.id !== userId) { + throw new BadRequestException(ErrorMessage.CANNOT_END_TICLE); + } + if (ticle.ticleStatus !== TicleStatus.IN_PROGRESS) { + throw new BadRequestException(ErrorMessage.CANNOT_END_TICLE); + } + + ticle.ticleStatus = TicleStatus.CLOSED; + await this.ticleRepository.save(ticle); + return; + } } diff --git a/apps/api/src/entity/applicant.entity.ts b/apps/api/src/entity/applicant.entity.ts index f5c33823..8c215112 100644 --- a/apps/api/src/entity/applicant.entity.ts +++ b/apps/api/src/entity/applicant.entity.ts @@ -16,7 +16,7 @@ export class Applicant { @PrimaryGeneratedColumn({ type: 'bigint' }) id: number; - @ManyToOne(() => Ticle, (ticle) => ticle.applicants) + @ManyToOne(() => Ticle, (ticle) => ticle.applicants, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'ticle_id' }) ticle: Ticle; diff --git a/apps/api/src/entity/summary.entity.ts b/apps/api/src/entity/summary.entity.ts index 0986cf52..6cd3f050 100644 --- a/apps/api/src/entity/summary.entity.ts +++ b/apps/api/src/entity/summary.entity.ts @@ -26,7 +26,7 @@ export class Summary { @CreateDateColumn({ type: 'timestamp', name: 'created_at' }) createdAt: Date; - @OneToOne(() => Ticle, (ticle) => ticle.summary) + @OneToOne(() => Ticle, (ticle) => ticle.summary, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'ticle_id' }) ticle: Ticle; } diff --git a/apps/api/src/entity/ticle.entity.ts b/apps/api/src/entity/ticle.entity.ts index 145d8dbc..c79d50bc 100644 --- a/apps/api/src/entity/ticle.entity.ts +++ b/apps/api/src/entity/ticle.entity.ts @@ -74,4 +74,7 @@ export class Ticle { }, }) tags: Tag[]; + + @Column({ type: 'varchar', name: 'profile_image_url', nullable: true }) + profileImageUrl: string; } diff --git a/apps/api/src/entity/user.entity.ts b/apps/api/src/entity/user.entity.ts index 43cf31e0..c58c56a7 100644 --- a/apps/api/src/entity/user.entity.ts +++ b/apps/api/src/entity/user.entity.ts @@ -6,6 +6,7 @@ import { PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; +import { Provider } from '@repo/types'; import { Applicant } from './applicant.entity'; import { Ticle } from './ticle.entity'; @@ -34,7 +35,7 @@ export class User { profileImageUrl: string; @Column({ type: 'varchar', default: 'local' }) - provider: string; + provider: Provider; @Column({ type: 'varchar', name: 'social_id', nullable: true }) socialId: string; diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index db7fac58..7f98c78e 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -29,12 +29,20 @@ async function bootstrap() { transform: true, }) ); + app.useGlobalInterceptors(new ResponseInterceptor()); app.useGlobalFilters(new HttpExceptionFilter(), new DBExceptionFilter()); app.setGlobalPrefix('api'); const configService = app.get(ConfigService); - const port = configService.get('API_SERVER_PORT') ?? 3000; + + const clientUrl = configService.get('CLIENT_URL') ?? 'http://localhost:5173'; + const port = configService.get('API_SERVER_PORT') ?? 3065; + + app.enableCors({ + origin: [clientUrl], + credentials: true, + }); await app.listen(port); } diff --git a/apps/api/src/ticle/dto/ticleDetailDto.ts b/apps/api/src/ticle/dto/ticleDetailDto.ts index c063711d..c80ef6a3 100644 --- a/apps/api/src/ticle/dto/ticleDetailDto.ts +++ b/apps/api/src/ticle/dto/ticleDetailDto.ts @@ -7,6 +7,12 @@ export class TickleDetailResponseDto { }) speakerName: string; + @ApiProperty({ + example: 1, + description: '발표자 유저 아이디', + }) + speakerId: number; + @ApiProperty({ example: 'kim@example.com', description: '발표자 이메일', diff --git a/apps/api/src/ticle/ticle.controller.ts b/apps/api/src/ticle/ticle.controller.ts index f2085ed8..4c5312d7 100644 --- a/apps/api/src/ticle/ticle.controller.ts +++ b/apps/api/src/ticle/ticle.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; import { CreateTicleSchema } from '@repo/types'; import { JwtAuthGuard } from '@/auth/jwt/jwt-auth.guard'; @@ -50,4 +50,10 @@ export class TicleController { applyToTicle(@GetUserId() userId: number, @Param('ticleId') ticleId: number) { return this.ticleService.applyTicle(ticleId, userId); } + + @Delete(':ticleId') + @UseGuards(JwtAuthGuard) + deleteTicle(@GetUserId() userId: number, @Param('ticleId') ticleId: number) { + return this.ticleService.deleteTicle(userId, ticleId); + } } diff --git a/apps/api/src/ticle/ticle.service.ts b/apps/api/src/ticle/ticle.service.ts index c6b16d3f..eef85028 100644 --- a/apps/api/src/ticle/ticle.service.ts +++ b/apps/api/src/ticle/ticle.service.ts @@ -27,7 +27,7 @@ export class TicleService { ) {} async createTicle(createTicleDto: CreateTicleDto, userId: number): Promise { - const user = await this.userRepository.findOne({ where: { id: userId } }); + const user = await this.getUserById(userId); const { existingTags, tagsToCreate } = await this.findExistingTags(createTicleDto.tags); const newTags = await this.createNewTags(tagsToCreate); @@ -134,7 +134,16 @@ export class TicleService { .leftJoinAndSelect('ticle.tags', 'tags') .leftJoinAndSelect('ticle.speaker', 'speaker') .leftJoinAndSelect('ticle.applicants', 'applicants') - .select(['ticle', 'tags', 'speaker.id', 'speaker.profileImageUrl', 'applicants']) + .leftJoinAndSelect('applicants.user', 'user') + .select([ + 'ticle', + 'tags', + 'speaker.id', + 'speaker.profileImageUrl', + 'applicants', + 'user.id', + 'ticle.ticleStatus', + ]) .where('ticle.id = :id', { id: ticleId }) .getOne(); @@ -143,10 +152,11 @@ export class TicleService { } const { tags, speaker, ...ticleData } = ticle; - const alreadyApplied = ticle.applicants.some((applicnat) => applicnat.id === userId); + const alreadyApplied = ticle.applicants.some((applicant) => applicant.user.id === userId); return { ...ticleData, + speakerId: ticle.speaker.id, tags: tags.map((tag) => tag.name), speakerImgUrl: speaker.profileImageUrl, isOwner: speaker.id === userId, @@ -166,6 +176,7 @@ export class TicleService { 'ticle.endTime', 'ticle.speakerName', 'ticle.createdAt', + 'ticle.profileImageUrl', ]) .addSelect('GROUP_CONCAT(DISTINCT tags.name)', 'tagNames') .addSelect('COUNT(DISTINCT applicant.id)', 'applicantCount') @@ -173,8 +184,8 @@ export class TicleService { .leftJoin('ticle.tags', 'tags') .leftJoin('ticle.applicants', 'applicant') .leftJoin('ticle.speaker', 'speaker') - .where('ticle.ticleStatus = :status', { - status: isOpen ? TicleStatus.OPEN : TicleStatus.CLOSED, + .where('ticle.ticleStatus IN (:...statuses)', { + statuses: isOpen ? [TicleStatus.OPEN, TicleStatus.IN_PROGRESS] : [TicleStatus.CLOSED], }) .groupBy('ticle.id'); @@ -224,4 +235,22 @@ export class TicleService { }, }; } + + async deleteTicle(userId: number, ticleId: number) { + const ticle = await this.ticleRepository.findOne({ + where: { id: ticleId }, + relations: ['speaker'], + }); + + if (!ticle) { + throw new NotFoundException(ErrorMessage.TICLE_NOT_FOUND); + } + + if (ticle.speaker.id !== userId) { + throw new BadRequestException(ErrorMessage.CANNOT_DELETE_OTHERS_TICLE); + } + + await this.ticleRepository.remove(ticle); + return; + } } diff --git a/apps/api/src/user/dto/createLocalUser.dto.ts b/apps/api/src/user/dto/createLocalUser.dto.ts index 7460d4eb..fbe976a7 100644 --- a/apps/api/src/user/dto/createLocalUser.dto.ts +++ b/apps/api/src/user/dto/createLocalUser.dto.ts @@ -1,8 +1,10 @@ +import { Provider } from '@repo/types'; + export class CreateLocalUserDto { username: string; password: string; email: string; - provider: string; + provider: Provider; nickname?: string; introduce?: string; profileImageUrl?: string; diff --git a/apps/api/src/user/dto/createSocialUser.dto.ts b/apps/api/src/user/dto/createSocialUser.dto.ts index 5a22f6b7..481a1228 100644 --- a/apps/api/src/user/dto/createSocialUser.dto.ts +++ b/apps/api/src/user/dto/createSocialUser.dto.ts @@ -1,6 +1,8 @@ +import { Provider } from '@repo/types'; + export class CreateSocialUserDto { email: string; - provider: string; + provider: Provider; socialId: string; nickname?: string; introduce?: string; diff --git a/apps/api/src/user/dto/userProfileDto.ts b/apps/api/src/user/dto/userProfileDto.ts new file mode 100644 index 00000000..3387496f --- /dev/null +++ b/apps/api/src/user/dto/userProfileDto.ts @@ -0,0 +1,44 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Provider } from '@repo/types'; + +interface TicleInfo { + title: string; + ticleId: number; +} + +export class UserProfileDto { + @ApiProperty({ + example: '1', + description: '유저 아이디', + }) + id: number; + + @ApiProperty({ + example: 'simeunseo', + description: '유저 닉네임', + }) + nickname: string; + + @ApiProperty({ + example: 'https://avatars.githubusercontent.com/u/55528304?v=4', + description: '유저 프로필 사진', + }) + profileImageUrl: string; + + @ApiProperty({ + example: 'github', + description: '유저 소셜 로그인 프로바이더', + }) + provider: Provider; + + @ApiProperty({ + example: [ + { + title: '야, 너두 부캠할 수 있어', + ticleId: 1, + }, + ], + description: '유저가 개설한 티클 목록', + }) + ticleInfo: TicleInfo[]; +} diff --git a/apps/api/src/user/dto/userProfileOfMeDto.ts b/apps/api/src/user/dto/userProfileOfMeDto.ts new file mode 100644 index 00000000..b32b1f1d --- /dev/null +++ b/apps/api/src/user/dto/userProfileOfMeDto.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Provider } from '@repo/types'; + +export class UserProfileOfMeDto { + @ApiProperty({ + example: '1', + description: '유저 아이디', + }) + id: number; + + @ApiProperty({ + example: 'simeunseo', + description: '유저 닉네임', + }) + nickname: string; + + @ApiProperty({ + example: 'https://avatars.githubusercontent.com/u/55528304?v=4', + description: '유저 프로필 사진', + }) + profileImageUrl: string; + + @ApiProperty({ + example: 'github', + description: '유저 소셜 로그인 프로바이더', + }) + provider: Provider; +} diff --git a/apps/api/src/user/user.controller.ts b/apps/api/src/user/user.controller.ts index 8687a877..299e6f5b 100644 --- a/apps/api/src/user/user.controller.ts +++ b/apps/api/src/user/user.controller.ts @@ -1,12 +1,22 @@ -import { Controller, Get, Patch } from '@nestjs/common'; +import { Controller, Get, Param, UseGuards } from '@nestjs/common'; + +import { JwtAuthGuard } from '@/auth/jwt/jwt-auth.guard'; +import { GetUserId } from '@/common/decorator/get-userId.decorator'; + +import { UserService } from './user.service'; @Controller('user') export class UserController { - constructor() {} + constructor(private readonly userService: UserService) {} - @Get('profile') - getUserProfile() {} + @Get('me') + @UseGuards(JwtAuthGuard) + async getUserProfile(@GetUserId() userId: number) { + return await this.userService.findUserProfileOfMeByUserId(userId); + } - @Patch('profile') - patchUserProfile() {} + @Get(':userId') + async patchUserProfile(@Param('userId') userId: number) { + return await this.userService.findUserProfileByUserId(userId); + } } diff --git a/apps/api/src/user/user.service.ts b/apps/api/src/user/user.service.ts index 36e17fc9..15685c88 100644 --- a/apps/api/src/user/user.service.ts +++ b/apps/api/src/user/user.service.ts @@ -1,13 +1,15 @@ -import { ConflictException, Injectable } from '@nestjs/common'; +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import * as bcrypt from 'bcrypt'; import { Repository } from 'typeorm'; -import { ErrorMessage } from '@repo/types'; +import { ErrorMessage, Provider } from '@repo/types'; import { User } from '@/entity/user.entity'; import { CreateLocalUserDto } from './dto/createLocalUser.dto'; import { CreateSocialUserDto } from './dto/createSocialUser.dto'; +import { UserProfileDto } from './dto/userProfileDto'; +import { UserProfileOfMeDto } from './dto/userProfileOfMeDto'; @Injectable() export class UserService { @@ -55,7 +57,7 @@ export class UserService { return user; } - async findUserBySocialIdAndProvider(socialId: string, provider: string): Promise { + async findUserBySocialIdAndProvider(socialId: string, provider: Provider): Promise { const user = await this.userRepository.findOne({ where: { socialId, provider }, }); @@ -64,4 +66,43 @@ export class UserService { } return user; } + + async findUserProfileOfMeByUserId(userId: number): Promise { + const user = await this.userRepository.findOne({ + where: { id: userId }, + select: ['id', 'nickname', 'profileImageUrl', 'provider'], + }); + + if (!user) { + throw new NotFoundException(ErrorMessage.USER_NOT_FOUND); + } + + return user; + } + + async findUserProfileByUserId(userId: number): Promise { + const user = await this.userRepository + .createQueryBuilder('user') + .leftJoin('user.ticles', 'ticles') + .addSelect(['ticles.title', 'ticles.id']) + .where('user.id = :userId', { userId: userId }) + .getOne(); + + if (!user) { + throw new NotFoundException(ErrorMessage.USER_NOT_FOUND); + } + + const ticleInfo = user.ticles.map((ticle) => ({ + title: ticle.title, + ticleId: ticle.id, + })); + + return { + id: user.id, + nickname: user.nickname, + profileImageUrl: user.profileImageUrl, + provider: user.provider, + ticleInfo: ticleInfo, + }; + } } diff --git a/apps/media/package.json b/apps/media/package.json index 96fcfebd..e89c63a1 100644 --- a/apps/media/package.json +++ b/apps/media/package.json @@ -27,6 +27,8 @@ "@nestjs/platform-express": "^10.0.0", "@nestjs/platform-socket.io": "^10.4.7", "@nestjs/websockets": "^10.4.7", + "@repo/lint": "workspace:*", + "@repo/tsconfig": "workspace:*", "@repo/mediasoup": "workspace:*", "@repo/tsconfig": "workspace:*", "@repo/types": "workspace:*", diff --git a/apps/media/src/mediasoup/mediasoup.service.ts b/apps/media/src/mediasoup/mediasoup.service.ts index 5ddba83c..e2437e95 100644 --- a/apps/media/src/mediasoup/mediasoup.service.ts +++ b/apps/media/src/mediasoup/mediasoup.service.ts @@ -38,6 +38,7 @@ export class MediasoupService implements OnModuleInit { }); this.workers.push(worker); + return worker; } @@ -63,9 +64,11 @@ export class MediasoupService implements OnModuleInit { joinRoom(roomId: string, socketId: string, nickname: string) { const room = this.roomService.getRoom(roomId); + if (room.hasPeer(socketId)) { throw new WsException(ErrorMessage.PEER_ALREADY_EXISTS_IN_ROOM); } + room.addPeer(socketId, nickname); return room.getRouter().rtpCapabilities; @@ -75,6 +78,7 @@ export class MediasoupService implements OnModuleInit { const room = this.roomService.getRoom(roomId); const router = room.getRouter(); const transport = await router.createWebRtcTransport(this.mediasoupConfig.webRtcTransport); + room.getPeer(socketId).addTransport(transport); return { @@ -121,12 +125,61 @@ export class MediasoupService implements OnModuleInit { return { nickname: peer.nickname, producerId: producer.id, paused: producer.paused }; } + disconnect(socketId: string) { + const roomIds = this.roomService.deletePeer(socketId); + + return roomIds; + } + + getProducers(roomId: string, socketId: string) { + const room = this.roomService.getRoom(roomId); + + const peers = [...room.peers.values()]; + + const filtered = peers.filter((peer) => peer.socketId !== socketId); + + const result = filtered.flatMap((peer) => + [...peer.producers.values()].map(({ id, kind, appData, paused }) => { + return { + producerId: id, + peerId: peer.socketId, + nickname: peer.nickname, + kind, + appData: appData as server.GetProducersRes['appData'], + paused, + }; + }) + ); + + return [...new Set(result)]; + } + + changeProducerStatus(socketId: string, changeProducerState: server.ChangeProducerStateDto) { + const { producerId, status, roomId } = changeProducerState; + const room = this.roomService.getRoom(roomId); + const peer = room.peers.get(socketId); + const producer = peer.getProducer(producerId); + + if (status === STREAM_STATUS.pause) { + producer.pause(); + } else { + producer.resume(); + } + + return producerId; + } + async consume( socketId: string, - producerId: string, - roomId: string, - transportId: string, - rtpCapabilities: types.RtpCapabilities + { + peerId, + producerId, + rtpCapabilities, + roomId, + transportId, + nickname, + appData, + }: server.CreateConsumerDto ) { const room = this.roomService.getRoom(roomId); const peer = room.getPeer(socketId); @@ -141,16 +194,34 @@ export class MediasoupService implements OnModuleInit { const consumer = await transport.consume({ producerId, rtpCapabilities, + paused: true, + appData, }); consumer.on('producerclose', () => { - peer.consumers.delete(consumer.id); consumer.close(); + peer.consumers.delete(consumer.id); + }); + + consumer.on('producerpause', () => { + consumer.pause(); + }); + + consumer.on('producerresume', () => { + if (consumer.kind !== 'audio') { + return; + } + + consumer.resume(); }); peer.addConsumer(consumer); return { + peerId, + appData, + nickname, + paused: consumer.paused, consumerId: consumer.id, producerId: consumer.producerId, kind: consumer.kind, @@ -158,76 +229,68 @@ export class MediasoupService implements OnModuleInit { }; } - getProducers(roomId: string, socketId: string) { - const room = this.roomService.getRoom(roomId); + async createConsumers(data: server.CreateConsumersDto) { + const { socketId, roomId, rtpCapabilities, transportId, producers } = data; - const peers = [...room.peers.values()]; + const targets = producers.filter((producer) => producer.peerId !== socketId); - const filtered = peers.filter((peer) => peer.socketId !== socketId); + if (targets.length === 0) { + return []; + } - const result = filtered.flatMap((peer) => - [...peer.producers.values()].map(({ id, kind, appData, paused }) => { - return { - producerId: id, - peerId: peer.socketId, - nickname: peer.nickname, - kind, - appData: appData, - paused, - }; - }) + return Promise.all( + producers.map((producer) => + this.consume(socketId, { + peerId: producer.peerId, + appData: producer.appData, + producerId: producer.producerId, + nickname: producer.nickname, + rtpCapabilities, + roomId, + transportId, + }) + ) ); - - return [...new Set(result)]; } - disconnect(socketId: string) { - const roomIds = this.roomService.deletePeer(socketId); - - return roomIds; - } - - disconnectProducer(roomId: string, producerId: string, socketId: string) { + async closeProducer(roomId: string, producerId: string, socketId: string) { const room = this.roomService.getRoom(roomId); const peer = room.peers.get(socketId); - const producer = peer.getProducer(producerId); - producer.close(); + + peer.deleteProducer(producerId); + return producerId; } - changeProducerStatus(socketId: string, changeProducerState: server.ChangeProducerStateDto) { - const { producerId, status, roomId } = changeProducerState; + pauseConsumer(socketId: string, consumerId: string, roomId: string) { const room = this.roomService.getRoom(roomId); const peer = room.peers.get(socketId); - const producer = peer.getProducer(producerId); + const consumer = peer.getConsumer(consumerId); - const updateStatus = () => { - if (status === STREAM_STATUS.pause) { - producer.pause(); - } else { - producer.resume(); - } - }; + consumer.pause(); - updateStatus(); - return producerId; + return { paused: true, consumerId, producerId: consumer.producerId }; } - changeConsumerStatus(socketId: string, changeConsumerState: server.ChangeConsumerStateDto) { - const { consumerId, status, roomId } = changeConsumerState; + resumeConsumer(socketId: string, consumerId: string, roomId: string) { const room = this.roomService.getRoom(roomId); const peer = room.peers.get(socketId); const consumer = peer.getConsumer(consumerId); - const updateStatus = () => { - if (status === STREAM_STATUS.pause) { - consumer.pause(); - } else { - consumer.resume(); - } - }; + if (consumer.producerPaused) { + return { paused: true, consumerId, producerId: consumer.producerId }; + } + + consumer.resume(); + + return { paused: false, consumerId, producerId: consumer.producerId }; + } + + pauseConsumers(socketId: string, roomId: string, consumerIds: string[]) { + return consumerIds.map((consumerId) => this.pauseConsumer(socketId, consumerId, roomId)); + } - updateStatus(); - return consumerId; + resumeConsumers(socketId: string, roomId: string, consumerIds: string[]) { + return consumerIds.map((consumerId) => this.resumeConsumer(socketId, consumerId, roomId)); } } diff --git a/apps/media/src/room/peer.ts b/apps/media/src/room/peer.ts index 581327aa..b3111916 100644 --- a/apps/media/src/room/peer.ts +++ b/apps/media/src/room/peer.ts @@ -1,6 +1,6 @@ import { WsException } from '@nestjs/websockets'; -import { ErrorMessage } from '@repo/types'; import { types } from 'mediasoup'; +import { ErrorMessage } from '@repo/types'; export class Peer { socketId: string; @@ -34,7 +34,7 @@ export class Peer { (consumer) => consumer.producerId === producerId ); - return !Boolean(consumer); + return !consumer; } addProducer(producer: types.Producer) { @@ -53,6 +53,46 @@ export class Peer { return this.consumers.get(consumerId); } + deleteProducer(producerId: string) { + const producer = this.producers.get(producerId); + + if (!producer) { + return; + } + + producer.close(); + + this.producers.delete(producerId); + } + + getConsumerByProducerId(producerId: string) { + const consumer = Array.from(this.consumers.values()).find( + (consumer) => consumer.producerId === producerId + ); + + return consumer; + } + + pauseConsumerByProducerId(producerId: string) { + const consumer = this.getConsumerByProducerId(producerId); + + if (!consumer) { + return; + } + + consumer.pause(); + } + + resumeConsumerByProducerId(producerId: string) { + const consumer = this.getConsumerByProducerId(producerId); + + if (!consumer) { + return; + } + + consumer.resume(); + } + close() { this.consumers.forEach((consumer) => consumer.close()); this.producers.forEach((producer) => producer.close()); diff --git a/apps/media/src/room/room.service.ts b/apps/media/src/room/room.service.ts index d4f91ad5..d4a366d3 100644 --- a/apps/media/src/room/room.service.ts +++ b/apps/media/src/room/room.service.ts @@ -1,9 +1,9 @@ import { Injectable } from '@nestjs/common'; import { WsException } from '@nestjs/websockets'; import { Router } from 'mediasoup/node/lib/RouterTypes'; +import { ErrorMessage } from '@repo/types'; import { Room } from './room'; -import { ErrorMessage } from '@repo/types'; @Injectable() export class RoomService { diff --git a/apps/media/src/room/room.ts b/apps/media/src/room/room.ts index 26a87e14..e2485668 100644 --- a/apps/media/src/room/room.ts +++ b/apps/media/src/room/room.ts @@ -1,8 +1,8 @@ import { WsException } from '@nestjs/websockets'; import { Router } from 'mediasoup/node/lib/RouterTypes'; +import { ErrorMessage } from '@repo/types'; import { Peer } from './peer'; -import { ErrorMessage } from '@repo/types'; export class Room { id: string; diff --git a/apps/media/src/signaling/signaling.gateway.ts b/apps/media/src/signaling/signaling.gateway.ts index 3bdf45e6..7c9481c0 100644 --- a/apps/media/src/signaling/signaling.gateway.ts +++ b/apps/media/src/signaling/signaling.gateway.ts @@ -1,17 +1,17 @@ +import { UseFilters } from '@nestjs/common'; import { ConnectedSocket, MessageBody, OnGatewayDisconnect, SubscribeMessage, WebSocketGateway, - WsException, } from '@nestjs/websockets'; +import { types } from 'mediasoup'; import { Socket } from 'socket.io'; import { SOCKET_EVENTS, STREAM_STATUS } from '@repo/mediasoup'; import type { client, server } from '@repo/mediasoup'; import { MediasoupService } from '@/mediasoup/mediasoup.service'; -import { UseFilters } from '@nestjs/common'; import { WSExceptionFilter } from '@/wsException.filter'; @WebSocketGateway() @@ -63,8 +63,9 @@ export class SignalingGateway implements OnGatewayDisconnect { async handleProduce( @ConnectedSocket() client: Socket, @MessageBody() createProducerDto: server.CreateProducerDto - ): Promise { + ) { const { transportId, kind, rtpParameters, roomId, appData } = createProducerDto; + const producerData = await this.mediasoupService.produce( client.id, kind, @@ -88,23 +89,7 @@ export class SignalingGateway implements OnGatewayDisconnect { return createProducerRes; } - @SubscribeMessage(SOCKET_EVENTS.consume) - async handleConsume( - @ConnectedSocket() client: Socket, - @MessageBody() createConsumerDto: server.CreateConsumerDto - ): Promise { - const { transportId, producerId, roomId, rtpCapabilities } = createConsumerDto; - - return this.mediasoupService.consume( - client.id, - producerId, - roomId, - transportId, - rtpCapabilities - ); - } - - @SubscribeMessage(SOCKET_EVENTS.getProducer) + @SubscribeMessage(SOCKET_EVENTS.getProducers) getProducers( @ConnectedSocket() client: Socket, @MessageBody() getProducerDto: server.GetProducersDto @@ -125,7 +110,7 @@ export class SignalingGateway implements OnGatewayDisconnect { @MessageBody('roomId') roomId: string, @MessageBody('producerId') producerId: string ) { - this.mediasoupService.disconnectProducer(roomId, producerId, client.id); + this.mediasoupService.closeProducer(roomId, producerId, client.id); client.to(roomId).emit(SOCKET_EVENTS.producerClosed, { producerId }); } @@ -148,13 +133,47 @@ export class SignalingGateway implements OnGatewayDisconnect { return { producerId }; } - @SubscribeMessage(SOCKET_EVENTS.consumerStatusChange) - pauseConsumer( + @SubscribeMessage(SOCKET_EVENTS.consume) + async handleConsume( @ConnectedSocket() client: Socket, - @MessageBody() changeConsumerState: server.ChangeConsumerStateDto + @MessageBody() createConsumerDto: server.CreateConsumerDto + ): Promise { + return this.mediasoupService.consume(client.id, createConsumerDto); + } + + @SubscribeMessage(SOCKET_EVENTS.createConsumers) + async createConsumers( + @ConnectedSocket() client: Socket, + @MessageBody('roomId') roomId: string, + @MessageBody('transportId') transportId: string, + @MessageBody('rtpCapabilities') rtpCapabilities: types.RtpCapabilities + ) { + const producers = this.mediasoupService.getProducers(roomId, client.id); + + return this.mediasoupService.createConsumers({ + roomId, + socketId: client.id, + producers, + rtpCapabilities, + transportId, + }); + } + + @SubscribeMessage(SOCKET_EVENTS.pauseConsumers) + pauseConsumers( + @ConnectedSocket() client: Socket, + @MessageBody('roomId') roomId: string, + @MessageBody('consumerIds') consumerIds: string[] + ) { + return this.mediasoupService.pauseConsumers(client.id, roomId, consumerIds); + } + + @SubscribeMessage(SOCKET_EVENTS.resumeConsumers) + resumeConsumers( + @ConnectedSocket() client: Socket, + @MessageBody('roomId') roomId: string, + @MessageBody('consumerIds') consumerIds: string[] ) { - const { consumerId } = changeConsumerState; - this.mediasoupService.changeConsumerStatus(client.id, changeConsumerState); - return consumerId; + return this.mediasoupService.resumeConsumers(client.id, roomId, consumerIds); } } diff --git a/apps/web/.eslintrc.cjs b/apps/web/.eslintrc.cjs index d254cfe8..6ec7ab99 100644 --- a/apps/web/.eslintrc.cjs +++ b/apps/web/.eslintrc.cjs @@ -11,7 +11,7 @@ module.exports = { require.resolve('@repo/lint'), 'plugin:react/recommended', 'plugin:react-hooks/recommended', - 'plugin:jsx-a11y/recommended', + // 'plugin:jsx-a11y/recommended', 'plugin:react/jsx-runtime', ], plugins: ['react-refresh'], diff --git a/apps/web/index.html b/apps/web/index.html index 631fc2e5..1b550546 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -2,7 +2,7 @@ - + - Ticle + + TICLE
diff --git a/apps/web/package.json b/apps/web/package.json index f261b92c..56eaff63 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,7 +34,8 @@ "react-hook-form": "^7.53.2", "socket.io-client": "^4.8.1", "tailwind-merge": "^2.5.4", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zustand": "^5.0.1" }, "devDependencies": { "@chromatic-com/storybook": "^3.2.2", diff --git a/apps/web/public/favicon.png b/apps/web/public/favicon.png new file mode 100644 index 00000000..cc8c6387 Binary files /dev/null and b/apps/web/public/favicon.png differ diff --git a/apps/web/src/__mocks__/handlers/auth.ts b/apps/web/src/__mocks__/handlers/auth.ts index 7e4a0331..4a8b7771 100644 --- a/apps/web/src/__mocks__/handlers/auth.ts +++ b/apps/web/src/__mocks__/handlers/auth.ts @@ -39,13 +39,3 @@ export const logIn: HttpResponseResolver = async ({ request }, }); }; - -export const signOut: HttpResponseResolver = async () => { - return HttpResponse.json(null, { - status: 302, - headers: { - Location: '/', - 'Set-Cookie': `accessToken=; Path=/; HttpOnly, refreshToken=; Path=/; HttpOnly`, - }, - }); -}; diff --git a/apps/web/src/api/auth.ts b/apps/web/src/api/auth.ts index 7fc75798..2dd11995 100644 --- a/apps/web/src/api/auth.ts +++ b/apps/web/src/api/auth.ts @@ -1,4 +1,7 @@ +import { useParams, useSearch } from '@tanstack/react-router'; + import axiosInstance from '@/api/axios'; +import { ENV } from '@/constants/env'; type SignUpDto = { username: string; @@ -19,12 +22,8 @@ const signUp = async (body: SignUpDto) => { return data; }; -const signOut = async () => { - await axiosInstance.post('/auth/logout'); -}; - -const oauthLogin = async (provider: 'google' | 'github') => { - await axiosInstance.get(`/auth/${provider}/login`); +const logOut = () => { + window.location.href = `${ENV.API_URL}/auth/logout`; }; -export { logIn, signUp, oauthLogin, signOut }; +export { logIn, signUp, logOut }; diff --git a/apps/web/src/api/ticle.ts b/apps/web/src/api/ticle.ts index 94103c22..f67a894b 100644 --- a/apps/web/src/api/ticle.ts +++ b/apps/web/src/api/ticle.ts @@ -47,4 +47,10 @@ const applyTicle = async (ticleId: string) => { return data; }; -export { getTitleList, getTicle, createTicle, applyTicle }; +const deleteTicle = async (ticleId: string) => { + const { data } = await axiosInstance.delete(`/ticle/${ticleId}`); + + return data; +}; + +export { getTitleList, getTicle, createTicle, applyTicle, deleteTicle }; diff --git a/apps/web/src/api/user.ts b/apps/web/src/api/user.ts new file mode 100644 index 00000000..edefbf7e --- /dev/null +++ b/apps/web/src/api/user.ts @@ -0,0 +1,24 @@ +import { + UserProfileOfMeResponse, + UserProfileOfMeSchema, + UserProfileResponse, + UserProfileSchema, +} from '@repo/types'; + +import request from '@/hooks/api/request'; + +export const getUserProfileOfMe = async () => { + return request({ + method: 'GET', + url: '/user/me', + schema: UserProfileOfMeSchema, + }); +}; + +export const getUserProfileByUserId = async (userId: number) => { + return request({ + method: 'GET', + url: `/user/${userId}`, + schema: UserProfileSchema, + }); +}; diff --git a/apps/web/src/assets/icons/chevron-right.svg b/apps/web/src/assets/icons/chevron-right.svg index d5745ea0..119e2f41 100644 --- a/apps/web/src/assets/icons/chevron-right.svg +++ b/apps/web/src/assets/icons/chevron-right.svg @@ -1,3 +1,3 @@ - - + + diff --git a/apps/web/src/assets/icons/github.svg b/apps/web/src/assets/icons/github.svg new file mode 100644 index 00000000..b3813426 --- /dev/null +++ b/apps/web/src/assets/icons/github.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/assets/icons/google.svg b/apps/web/src/assets/icons/google.svg new file mode 100644 index 00000000..d6ae6a45 --- /dev/null +++ b/apps/web/src/assets/icons/google.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/web/src/assets/icons/trash.svg b/apps/web/src/assets/icons/trash.svg new file mode 100644 index 00000000..755acb9d --- /dev/null +++ b/apps/web/src/assets/icons/trash.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/src/assets/images/ticle-character-badge.png b/apps/web/src/assets/images/ticle-character-badge.png new file mode 100644 index 00000000..cc8c6387 Binary files /dev/null and b/apps/web/src/assets/images/ticle-character-badge.png differ diff --git a/apps/web/src/assets/ticle.svg b/apps/web/src/assets/ticle.svg index e2a09801..ece6c2ee 100644 --- a/apps/web/src/assets/ticle.svg +++ b/apps/web/src/assets/ticle.svg @@ -1,3 +1,3 @@ - + diff --git a/apps/web/src/components/auth/GuestLogin.tsx b/apps/web/src/components/auth/GuestLogin.tsx new file mode 100644 index 00000000..4c1bff66 --- /dev/null +++ b/apps/web/src/components/auth/GuestLogin.tsx @@ -0,0 +1,23 @@ +import ChevronRight from '@/assets/icons/chevron-right.svg?react'; +import { ENV } from '@/constants/env'; +import { Route } from '@/routes/auth/oauth'; + +function GuestLogin() { + const { redirect } = Route.useSearch(); + const loginUrl = `${ENV.API_URL}/auth/guest/login?redirect=${redirect || ''}`; + const handleGuestLogin = () => { + window.location.href = loginUrl; + }; + + return ( + + 게스트 로그인 + + + ); +} + +export default GuestLogin; diff --git a/apps/web/src/components/auth/OAuthLogin.tsx b/apps/web/src/components/auth/OAuthLogin.tsx new file mode 100644 index 00000000..a6e2b782 --- /dev/null +++ b/apps/web/src/components/auth/OAuthLogin.tsx @@ -0,0 +1,68 @@ +import { cva } from 'class-variance-authority'; +import { useState } from 'react'; + +import GithubIc from '@/assets/icons/github.svg?react'; +import GoogleIc from '@/assets/icons/google.svg?react'; +import { ENV } from '@/constants/env'; +import { Route } from '@/routes/auth/oauth'; + +import Loading from '../common/Loading'; + +const oauthButton = cva('flex w-96 justify-center gap-3 rounded-lg py-4 shadow-normal', { + variants: { + type: { + github: 'bg-black text-white', + google: 'border border-main bg-white text-alt', + }, + }, + defaultVariants: { + type: 'github', + }, +}); + +const OAUTH_LABEL = { + github: 'Github', + google: 'Google', +} as const; + +type OAuthType = keyof typeof OAUTH_LABEL; + +interface OAuthLoginProps { + type: OAuthType; +} + +function OAuthLogin({ type }: OAuthLoginProps) { + const [loadingOAuthType, setLoadingOAuthType] = useState(null); + const { redirect } = Route.useSearch(); + + const loginUrl = `${ENV.API_URL}/auth/${type}/login?redirect=${redirect || ''}`; + + const onLoginBtnClick = (type: OAuthType) => { + setLoadingOAuthType(type); + window.location.href = loginUrl; + }; + + const isCurrentLoading = loadingOAuthType === type; + + return ( + + ); +} + +export default OAuthLogin; diff --git a/apps/web/src/components/auth/index.tsx b/apps/web/src/components/auth/index.tsx new file mode 100644 index 00000000..501b2def --- /dev/null +++ b/apps/web/src/components/auth/index.tsx @@ -0,0 +1,24 @@ +import TicleCharacterBadge from '@/assets/images/ticle-character-badge.png'; +import TicleLogo from '@/assets/ticle.svg?react'; + +import GuestLogin from './GuestLogin'; +import OAuthLogin from './OAuthLogin'; + +function Auth() { + return ( +
+
+ 티클 캐릭터 + + 작은 지식이 모여 큰 성장이 되는 곳 +
+
+ + + +
+
+ ); +} + +export default Auth; diff --git a/apps/web/src/components/common/Avatar/index.tsx b/apps/web/src/components/common/Avatar/index.tsx index 1d4c66ac..c32192f6 100644 --- a/apps/web/src/components/common/Avatar/index.tsx +++ b/apps/web/src/components/common/Avatar/index.tsx @@ -5,6 +5,7 @@ import PersonIc from '@/assets/icons/person.svg?react'; import cn from '@/utils/cn'; const AVATAR_SIZE = { + xs: 'xs', sm: 'sm', md: 'md', lg: 'lg', @@ -15,6 +16,7 @@ const avatarVariants = cva( { variants: { size: { + [AVATAR_SIZE.xs]: 'h-[30px] w-[30px]', [AVATAR_SIZE.sm]: 'h-[50px] w-[50px]', [AVATAR_SIZE.md]: 'h-[84px] w-[84px]', [AVATAR_SIZE.lg]: 'h-[100px] w-[100px]', diff --git a/apps/web/src/components/common/Dialog/index.tsx b/apps/web/src/components/common/Dialog/index.tsx index f515dab9..c800e8c7 100644 --- a/apps/web/src/components/common/Dialog/index.tsx +++ b/apps/web/src/components/common/Dialog/index.tsx @@ -2,7 +2,7 @@ import { ReactNode } from '@tanstack/react-router'; import { cva } from 'class-variance-authority'; import { AnimatePresence, motion } from 'framer-motion'; -import { useRef } from 'react'; +import { MouseEvent, useRef } from 'react'; import CloseIc from '@/assets/icons/close.svg?react'; import useOutsideClick from '@/hooks/useOutsideClick'; @@ -25,6 +25,10 @@ function DialogRoot({ isOpen, onClose, children, className }: DialogRootProps) { if (!isOpen) return null; + const handleInnerClick = (e: MouseEvent) => { + e.stopPropagation(); + }; + return ( @@ -41,6 +45,7 @@ function DialogRoot({ isOpen, onClose, children, className }: DialogRootProps) { className={cn('relative w-80 rounded-lg bg-white px-6 py-7 shadow-normal', className)} initial={{ opacity: 0, y: 50 }} animate={{ opacity: 1, y: 0 }} + onClick={handleInnerClick} > {children} diff --git a/apps/web/src/components/common/Header/User.tsx b/apps/web/src/components/common/Header/User.tsx new file mode 100644 index 00000000..06a34a29 --- /dev/null +++ b/apps/web/src/components/common/Header/User.tsx @@ -0,0 +1,57 @@ +/* eslint-disable react-refresh/only-export-components */ +import { Link } from '@tanstack/react-router'; +import { Provider } from '@repo/types'; + +import UserProfileOfMeDialog from '@/components/user/UserProfileOfMeDialog'; +import useAuthInfo from '@/hooks/useAuthInfo'; +import useModal from '@/hooks/useModal'; + +import Avatar from '../Avatar'; +import Button from '../Button'; + +export const LOGIN_TYPE: Record = { + github: 'Github 로그인', + google: 'Google 로그인', + guest: '게스트 로그인', + local: '티클 로그인', +}; + +function User() { + const { isLoading, isAuthenticated, authInfo } = useAuthInfo(); + + const { isOpen, onOpen, onClose } = useModal(); + + const AuthorizedContent = () => ( + <> +
+ + {authInfo?.nickname} +
+ {isOpen && authInfo && ( + + )} + + ); + + const UnauthorizedContent = () => ( + +
+ +
+ + ); + + return ( + + ); +} + +export default User; diff --git a/apps/web/src/components/common/Header/index.tsx b/apps/web/src/components/common/Header/index.tsx index 7f5d41da..b55db9d8 100644 --- a/apps/web/src/components/common/Header/index.tsx +++ b/apps/web/src/components/common/Header/index.tsx @@ -1,13 +1,14 @@ import { Link } from '@tanstack/react-router'; import TicleLogo from '@/assets/ticle.svg?react'; -import Button from '@/components/common/Button'; + +import User from './User'; const NAV_STYLE = 'hover:text-hover text-title1 text-alt transition [&.active]:text-primary'; function Header() { return ( -
+
- {/* TODO: User 로그인시 핸들링 */} -
- -
+
); } diff --git a/apps/web/src/components/common/Select/index.tsx b/apps/web/src/components/common/Select/index.tsx index 957200ed..2d832f89 100644 --- a/apps/web/src/components/common/Select/index.tsx +++ b/apps/web/src/components/common/Select/index.tsx @@ -1,10 +1,12 @@ import { cva } from 'class-variance-authority'; -import { useRef, useState, KeyboardEvent } from 'react'; +import { useRef, useState, KeyboardEvent, useLayoutEffect } from 'react'; import ChevronDownIc from '@/assets/icons/chevron-down.svg?react'; import ChevronUpIc from '@/assets/icons/chevron-up.svg?react'; import useOutsideClick from '@/hooks/useOutsideClick'; +import Portal from '../Portal'; + const selectVariants = cva( 'flex w-full cursor-pointer items-center justify-between gap-3.5 rounded-base bg-white px-3.5 py-2.5 text-body1 text-main', { @@ -24,6 +26,12 @@ export interface Option { label: string; value: string; } + +interface Position { + top: number; + left: number; + width: number; +} interface Select { options: Option[]; placeholder?: string; @@ -33,6 +41,42 @@ interface Select { function Select({ options, placeholder, selectedOption, onChange }: Select) { const [isOpen, setIsOpen] = useState(false); + const [position, setPosition] = useState({ + top: 0, + left: 0, + width: 0, + }); + + const selectRef = useRef(null); + const optionRef = useRef(null); + + const updatePosition = () => { + if (!selectRef.current) return; + + const selectRect = selectRef.current.getBoundingClientRect(); + const scrollTop = window.scrollY || document.documentElement.scrollTop; + const scrollLeft = window.scrollX || document.documentElement.scrollLeft; + + setPosition({ + top: selectRect.bottom + scrollTop, + left: selectRect.left + scrollLeft, + width: selectRect.width, + }); + }; + + useLayoutEffect(() => { + if (!isOpen) return; + + updatePosition(); + + window.addEventListener('scroll', updatePosition); + window.addEventListener('resize', updatePosition); + + return () => { + window.removeEventListener('scroll', updatePosition); + window.removeEventListener('resize', updatePosition); + }; + }, [isOpen]); const handleOptionChange = (option: Option) => { onChange?.(option); @@ -55,8 +99,7 @@ function Select({ options, placeholder, selectedOption, onChange }: Select) { handleOptionChange(option); }; - const selectRef = useRef(null); - useOutsideClick(selectRef, handleSelectClose); + useOutsideClick(optionRef, handleSelectClose); return (
@@ -69,24 +112,32 @@ function Select({ options, placeholder, selectedOption, onChange }: Select) { {isOpen ?
); diff --git a/apps/web/src/components/dashboard/DashboardTab.tsx b/apps/web/src/components/dashboard/DashboardTab.tsx index e4014ac4..6c7abcc9 100644 --- a/apps/web/src/components/dashboard/DashboardTab.tsx +++ b/apps/web/src/components/dashboard/DashboardTab.tsx @@ -14,7 +14,7 @@ const DASHBOARD_ROUTES = { function DashboardTab() { const navigate = useNavigate(); - const isOpenedMatch = useMatch({ from: '/dashboard/open', shouldThrow: false }); + const isOpenedMatch = useMatch({ from: '/_authenticated/dashboard/open', shouldThrow: false }); const selectedTab = isOpenedMatch ? 'OPENED' : 'APPLIED'; const DASHBOARD_TAB_DATA: TabData[] = [ diff --git a/apps/web/src/components/dashboard/apply/TicleInfoCard.tsx b/apps/web/src/components/dashboard/apply/TicleInfoCard.tsx index cd6e75a5..b87ea007 100644 --- a/apps/web/src/components/dashboard/apply/TicleInfoCard.tsx +++ b/apps/web/src/components/dashboard/apply/TicleInfoCard.tsx @@ -39,7 +39,9 @@ function TicleInfoCard({

티클명

- {ticleTitle} + + {ticleTitle} +

진행 일시

diff --git a/apps/web/src/components/dashboard/open/TicleInfoCard.tsx b/apps/web/src/components/dashboard/open/TicleInfoCard.tsx index 702a941e..336d753c 100644 --- a/apps/web/src/components/dashboard/open/TicleInfoCard.tsx +++ b/apps/web/src/components/dashboard/open/TicleInfoCard.tsx @@ -43,7 +43,9 @@ function TicleInfoCard({ ticleId, ticleTitle, startTime, endTime, status }: Ticl

티클명

- {ticleTitle} + + {ticleTitle} +

진행 일시

diff --git a/apps/web/src/components/live/ControlBar/index.tsx b/apps/web/src/components/live/ControlBar/index.tsx index b88d6a52..c7c1dcd2 100644 --- a/apps/web/src/components/live/ControlBar/index.tsx +++ b/apps/web/src/components/live/ControlBar/index.tsx @@ -20,14 +20,15 @@ const ControlBar = () => { const { video, screen, audio } = useLocalStreamState(); const { disconnect } = useMediasoupAction(); - const { closeStream, pauseStream, resumeStream, startScreenStream } = useLocalStreamAction(); + const { closeStream, pauseStream, resumeStream, startScreenStream, closeScreenStream } = + useLocalStreamAction(); const toggleScreenShare = async () => { const { paused, stream } = screen; try { - if (stream && paused) { - closeStream('screen'); + if (stream && !paused) { + closeScreenStream(); } else { startScreenStream(); } @@ -87,7 +88,7 @@ const ControlBar = () => { ActiveIcon={ScreenOnIc} InactiveIcon={ScreenOffIc} onToggle={toggleScreenShare} - isActivated={!screen.paused} + isActivated={screen.paused} />
diff --git a/apps/web/src/components/live/StreamView/AudioPlayer.tsx b/apps/web/src/components/live/StreamView/AudioPlayer.tsx deleted file mode 100644 index 091c3c56..00000000 --- a/apps/web/src/components/live/StreamView/AudioPlayer.tsx +++ /dev/null @@ -1,18 +0,0 @@ -/* eslint-disable jsx-a11y/media-has-caption */ -import { useEffect, useRef } from 'react'; - -import { VideoPlayerProps } from './VideoPlayer'; - -function AudioPlayer({ stream, muted = false }: VideoPlayerProps) { - const audioRef = useRef(null); - - useEffect(() => { - if (audioRef.current && stream) { - audioRef.current.srcObject = stream; - } - }, [stream]); - - return