diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index ce9b9d95..9cc5afb7 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -1,4 +1,4 @@ -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'; @@ -43,17 +43,21 @@ export class AuthController { @ApiResponse({ status: 302, description: '홈으로 리다이렉션' }) @ApiResponse({ status: 401 }) @UseGuards(LocalAuthGuard) - localLogin(@GetUserId() userId: number, @Res() response: Response) { - this.loginProcess(response, userId); + localLogin( + @GetUserId() userId: number, + @Query('redirect') redirect: string, + @Res() response: Response + ) { + this.loginProcess(response, userId, redirect); } @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.loginProcess(response, guestUser.id); + this.loginProcess(response, guestUser.id, redirect); } @Get('google/login') @@ -65,8 +69,12 @@ export class AuthController { @Get('google/callback') @UseGuards(GoogleAuthGuard) - googleAuthCallback(@GetUserId() userId: number, @Res() response: Response) { - this.loginProcess(response, userId); + googleAuthCallback( + @GetUserId() userId: number, + @Query('state') state: string, + @Res() response: Response + ) { + this.loginProcess(response, userId, state); } @Get('github/login') @@ -78,8 +86,12 @@ export class AuthController { @Get('github/callback') @UseGuards(GitHubAuthGuard) - githubAuthCallback(@GetUserId() userId: number, @Res() response: Response) { - this.loginProcess(response, userId); + githubAuthCallback( + @GetUserId() userId: number, + @Query('state') state: string, + @Res() response: Response + ) { + this.loginProcess(response, userId, state); } @Get('logout') @@ -90,13 +102,14 @@ export class AuthController { this.redirectToHome(response); } - private loginProcess(response: Response, userId: number) { + private loginProcess(response: Response, userId: number, path?: string) { const { accessToken } = this.authService.createJWT(userId); response.cookie('accessToken', accessToken, this.cookieConfig.getAuthCookieOptions()); - this.redirectToHome(response); + this.redirectToHome(response, path); } - private redirectToHome(response: Response) { - response.redirect(this.redirectUrl); + private redirectToHome(response: Response, path?: string) { + const redirectUrl = `${this.redirectUrl}${path || ''}`; + response.redirect(redirectUrl); } } diff --git a/apps/api/src/auth/github/github.strategy.ts b/apps/api/src/auth/github/github.strategy.ts index 6fdf01c6..e56592d7 100644 --- a/apps/api/src/auth/github/github.strategy.ts +++ b/apps/api/src/auth/github/github.strategy.ts @@ -1,7 +1,8 @@ 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'; @@ -19,6 +20,13 @@ export class GitHubStrategy extends PassportStrategy(Strategy, Provider.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; diff --git a/apps/api/src/auth/google/google.strategy.ts b/apps/api/src/auth/google/google.strategy.ts index a4dd0aa3..5252a18a 100644 --- a/apps/api/src/auth/google/google.strategy.ts +++ b/apps/api/src/auth/google/google.strategy.ts @@ -1,6 +1,8 @@ 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'; @@ -20,6 +22,14 @@ export class GoogleStrategy extends PassportStrategy(Strategy, Provider.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; 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/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 a2ec54c6..34f15d05 100644 --- a/apps/api/src/ticle/ticle.service.ts +++ b/apps/api/src/ticle/ticle.service.ts @@ -134,7 +134,8 @@ 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']) .where('ticle.id = :id', { id: ticleId }) .getOne(); @@ -143,7 +144,7 @@ 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, @@ -226,4 +227,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/media/package.json b/apps/media/package.json index cdbe2361..6450a768 100644 --- a/apps/media/package.json +++ b/apps/media/package.json @@ -26,6 +26,7 @@ "@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/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/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/src/api/auth.ts b/apps/web/src/api/auth.ts index 9f0a5fb9..2dd11995 100644 --- a/apps/web/src/api/auth.ts +++ b/apps/web/src/api/auth.ts @@ -1,3 +1,5 @@ +import { useParams, useSearch } from '@tanstack/react-router'; + import axiosInstance from '@/api/axios'; import { ENV } from '@/constants/env'; @@ -24,12 +26,4 @@ const logOut = () => { window.location.href = `${ENV.API_URL}/auth/logout`; }; -const guestLogin = () => { - window.location.href = `${ENV.API_URL}/auth/guest/login`; -}; - -const oauthLogin = (provider: 'google' | 'github') => { - window.location.href = `${ENV.API_URL}/auth/${provider}/login`; -}; - -export { logIn, signUp, oauthLogin, guestLogin, logOut }; +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/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/components/auth/GuestLogin.tsx b/apps/web/src/components/auth/GuestLogin.tsx index 7595ca79..4c1bff66 100644 --- a/apps/web/src/components/auth/GuestLogin.tsx +++ b/apps/web/src/components/auth/GuestLogin.tsx @@ -1,9 +1,12 @@ -import { guestLogin } from '@/api/auth'; 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 = () => { - guestLogin(); + window.location.href = loginUrl; }; return ( diff --git a/apps/web/src/components/auth/OAuthLogin.tsx b/apps/web/src/components/auth/OAuthLogin.tsx index a91c8351..a6e2b782 100644 --- a/apps/web/src/components/auth/OAuthLogin.tsx +++ b/apps/web/src/components/auth/OAuthLogin.tsx @@ -1,9 +1,10 @@ import { cva } from 'class-variance-authority'; import { useState } from 'react'; -import { oauthLogin } from '@/api/auth'; 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'; @@ -32,10 +33,13 @@ interface OAuthLoginProps { 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); - oauthLogin(type); + window.location.href = loginUrl; }; const isCurrentLoading = loadingOAuthType === type; diff --git a/apps/web/src/components/common/Header/User.tsx b/apps/web/src/components/common/Header/User.tsx index 75dafab1..06a34a29 100644 --- a/apps/web/src/components/common/Header/User.tsx +++ b/apps/web/src/components/common/Header/User.tsx @@ -1,10 +1,9 @@ /* eslint-disable react-refresh/only-export-components */ import { Link } from '@tanstack/react-router'; -import axios from 'axios'; import { Provider } from '@repo/types'; import UserProfileOfMeDialog from '@/components/user/UserProfileOfMeDialog'; -import { useUserProfileOfMe } from '@/hooks/api/user'; +import useAuthInfo from '@/hooks/useAuthInfo'; import useModal from '@/hooks/useModal'; import Avatar from '../Avatar'; @@ -18,33 +17,30 @@ export const LOGIN_TYPE: Record = { }; function User() { - const { data, error } = useUserProfileOfMe(); - const { isOpen, onOpen, onClose } = useModal(); - - const isUnauthorized = axios.isAxiosError(error) && error.response?.status === 401; + const { isLoading, isAuthenticated, authInfo } = useAuthInfo(); - const loginType = data?.provider && LOGIN_TYPE[data.provider]; + const { isOpen, onOpen, onClose } = useModal(); const AuthorizedContent = () => ( <>
- - {data?.nickname} + + {authInfo?.nickname}
- {isOpen && data && loginType && ( + {isOpen && authInfo && ( )} ); const UnauthorizedContent = () => ( - +
@@ -53,7 +49,7 @@ function User() { return ( ); } diff --git a/apps/web/src/components/common/Header/index.tsx b/apps/web/src/components/common/Header/index.tsx index 2942950d..b55db9d8 100644 --- a/apps/web/src/components/common/Header/index.tsx +++ b/apps/web/src/components/common/Header/index.tsx @@ -20,7 +20,6 @@ function Header() { 대시보드 - {/* 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/StreamView/AudioStreams/AudioPlayer.tsx b/apps/web/src/components/live/StreamView/AudioStreams/AudioPlayer.tsx index c0efb787..9a18eed8 100644 --- a/apps/web/src/components/live/StreamView/AudioStreams/AudioPlayer.tsx +++ b/apps/web/src/components/live/StreamView/AudioStreams/AudioPlayer.tsx @@ -2,7 +2,9 @@ import { useEffect, useRef } from 'react'; import { VideoPlayerProps } from '@/components/live/StreamView/List/VideoPlayer'; -function AudioPlayer({ stream, paused = false }: VideoPlayerProps) { +type AudioPlayerProps = Omit; + +function AudioPlayer({ stream, paused = false }: AudioPlayerProps) { const audioRef = useRef(null); useEffect(() => { diff --git a/apps/web/src/components/live/StreamView/List/Pinned.tsx b/apps/web/src/components/live/StreamView/List/Pinned.tsx index 694c7703..2d2d4af1 100644 --- a/apps/web/src/components/live/StreamView/List/Pinned.tsx +++ b/apps/web/src/components/live/StreamView/List/Pinned.tsx @@ -22,6 +22,7 @@ function PinnedGrid({ }: PinnedListProps) { const { paginatedItems: subPaginatedStreams, ...subPaginationControlsProps } = usePagination({ itemsPerPage: ITEMS_PER_SUB_GRID, + pinnedStream: pinnedVideoStreamData, }); return ( @@ -34,7 +35,9 @@ function PinnedGrid({
diff --git a/apps/web/src/components/live/StreamView/List/SubVideoGrid.tsx b/apps/web/src/components/live/StreamView/List/SubVideoGrid.tsx index 6bf7f338..b24bbdde 100644 --- a/apps/web/src/components/live/StreamView/List/SubVideoGrid.tsx +++ b/apps/web/src/components/live/StreamView/List/SubVideoGrid.tsx @@ -64,6 +64,8 @@ function SubVideoGrid({ avatarSize="sm" paused={streamData.paused} isMicOn={getAudioMutedState(streamData)} + nickname={streamData.nickname} + mediaType={streamData.consumer?.appData?.mediaTypes} />
))} diff --git a/apps/web/src/components/live/StreamView/List/VideoGrid.tsx b/apps/web/src/components/live/StreamView/List/VideoGrid.tsx index d4cb5f37..c91ed481 100644 --- a/apps/web/src/components/live/StreamView/List/VideoGrid.tsx +++ b/apps/web/src/components/live/StreamView/List/VideoGrid.tsx @@ -35,6 +35,8 @@ function VideoGrid({ videoStreamData, onVideoClick, getAudioMutedState }: VideoG stream={streamData.stream} paused={streamData.paused} isMicOn={getAudioMutedState(streamData)} + nickname={streamData.nickname} + mediaType={streamData.consumer?.appData?.mediaTypes} /> ))} diff --git a/apps/web/src/components/live/StreamView/List/VideoPlayer.tsx b/apps/web/src/components/live/StreamView/List/VideoPlayer.tsx index bb852732..84b0e7af 100644 --- a/apps/web/src/components/live/StreamView/List/VideoPlayer.tsx +++ b/apps/web/src/components/live/StreamView/List/VideoPlayer.tsx @@ -24,7 +24,8 @@ export interface VideoPlayerProps { paused?: boolean; isMicOn?: boolean; avatarSize?: 'sm' | 'md' | 'lg'; - mediaType?: 'video' | 'screen'; + mediaType?: string; + nickname: string; } function VideoPlayer({ @@ -33,8 +34,8 @@ function VideoPlayer({ mediaType = 'video', isMicOn = false, avatarSize = 'md', + nickname, }: VideoPlayerProps) { - const NAME = '김티클'; // TODO: 이름 받아오기 const [isLoading, setIsLoading] = useState(true); const videoRef = useRef(null); @@ -65,7 +66,7 @@ function VideoPlayer({ )}
- {NAME} + {nickname}
)} @@ -86,8 +87,11 @@ function VideoPlayer({ Your browser does not support the video. ) : ( -
- +
+
))}
diff --git a/apps/web/src/components/live/StreamView/index.tsx b/apps/web/src/components/live/StreamView/index.tsx index e6104b29..20b1082d 100644 --- a/apps/web/src/components/live/StreamView/index.tsx +++ b/apps/web/src/components/live/StreamView/index.tsx @@ -1,4 +1,5 @@ import { types } from 'mediasoup-client'; +import { MediaTypes } from '@repo/mediasoup'; import AudioStreams from '@/components/live/StreamView/AudioStreams'; import PinnedGrid from '@/components/live/StreamView/List/Pinned'; @@ -7,11 +8,12 @@ import useAudioState from '@/hooks/useAudioState'; import usePinnedVideo from '@/hooks/usePinnedVideo'; export interface StreamData { - consumer?: types.Consumer; + consumer?: types.Consumer<{ mediaTypes: MediaTypes; nickname: string }>; socketId: string; kind: types.MediaKind; stream: MediaStream | null; paused: boolean; + nickname: string; } const StreamView = () => { diff --git a/apps/web/src/components/ticle/detail/index.tsx b/apps/web/src/components/ticle/detail/index.tsx index 8b645de8..b23f7bee 100644 --- a/apps/web/src/components/ticle/detail/index.tsx +++ b/apps/web/src/components/ticle/detail/index.tsx @@ -1,22 +1,28 @@ -import { useNavigate, useParams } from '@tanstack/react-router'; +import { useParams } from '@tanstack/react-router'; import CalendarIc from '@/assets/icons/calendar.svg?react'; import ClockIc from '@/assets/icons/clock.svg?react'; +import TrashIc from '@/assets/icons/trash.svg?react'; import Avatar from '@/components/common/Avatar'; import Badge from '@/components/common/Badge'; import Button from '@/components/common/Button'; import UserProfileDialog from '@/components/user/UserProfileDialog'; -import { useApplyTicle, useTicle } from '@/hooks/api/ticle'; +import { useApplyTicle, useDeleteTicle, useTicle } from '@/hooks/api/ticle'; import useModal from '@/hooks/useModal'; import { formatDateTimeRange } from '@/utils/date'; function Detail() { - const { ticleId } = useParams({ from: '/ticle/$ticleId' }); + const { ticleId } = useParams({ from: '/_authenticated/ticle/$ticleId' }); const { data } = useTicle(ticleId); - const { mutate } = useApplyTicle(); + const { mutate: applyMutate } = useApplyTicle(); + const { mutate: deleteMutate } = useDeleteTicle(); const handleApplyButtonClick = () => { - mutate(ticleId); + applyMutate(ticleId); + }; + + const handleDeleteButtonClick = () => { + deleteMutate(ticleId); }; const { isOpen, onOpen, onClose } = useModal(); @@ -78,7 +84,18 @@ function Detail() { - + {data.isOwner ? ( + + ) : ( + + )} ); } diff --git a/apps/web/src/components/ticle/list/TicleCard.tsx b/apps/web/src/components/ticle/list/TicleCard.tsx index e53b5920..c6bedd55 100644 --- a/apps/web/src/components/ticle/list/TicleCard.tsx +++ b/apps/web/src/components/ticle/list/TicleCard.tsx @@ -22,12 +22,14 @@ const TicleCard = ({ }: TicleCardProps) => { return (
-
-

{title}

-
- {tags.map((tag) => ( - {tag} - ))} +
+
+

{title}

+
+ {tags.map((tag) => ( + {tag} + ))} +
diff --git a/apps/web/src/components/user/UserProfileDialog.tsx b/apps/web/src/components/user/UserProfileDialog.tsx index 85411fdc..831c7e42 100644 --- a/apps/web/src/components/user/UserProfileDialog.tsx +++ b/apps/web/src/components/user/UserProfileDialog.tsx @@ -34,7 +34,7 @@ function UserProfileDialog({ isOpen, onClose, speakerId, nickname }: UserProfile

개설한 티클 목록

-
+
{data.ticleInfo.map((info) => (