diff --git a/apps/media/src/wsException.filter.ts b/apps/media/src/wsException.filter.ts index 82478a6b..18816763 100644 --- a/apps/media/src/wsException.filter.ts +++ b/apps/media/src/wsException.filter.ts @@ -1,18 +1,15 @@ import { Catch, ArgumentsHost } from '@nestjs/common'; import { BaseWsExceptionFilter, WsException } from '@nestjs/websockets'; -@Catch(WsException) +@Catch() export class WSExceptionFilter extends BaseWsExceptionFilter { - catch(exception: any, host: ArgumentsHost) { + catch(exception: WsException, host: ArgumentsHost) { const client = host.switchToWs().getClient(); const errorMessage = exception.message || 'An unknown error occurred'; const errorResponse = { status: 'error', - error: { - code: 500, - message: errorMessage, - }, + error: { code: 500, message: errorMessage }, }; client.emit('error', errorResponse); diff --git a/apps/web/src/api/auth.ts b/apps/web/src/api/auth.ts index 2dd11995..adae80d6 100644 --- a/apps/web/src/api/auth.ts +++ b/apps/web/src/api/auth.ts @@ -1,5 +1,3 @@ -import { useParams, useSearch } from '@tanstack/react-router'; - import axiosInstance from '@/api/axios'; import { ENV } from '@/constants/env'; diff --git a/apps/web/src/components/common/Alert/index.tsx b/apps/web/src/components/common/Alert/index.tsx new file mode 100644 index 00000000..8706e678 --- /dev/null +++ b/apps/web/src/components/common/Alert/index.tsx @@ -0,0 +1,24 @@ +import { cva } from 'class-variance-authority'; +import { ReactNode } from 'react'; + +const alertVariants = cva('flex items-center justify-center', { + variants: { + type: { + info: 'text-black', + error: 'text-red-500', + }, + }, + defaultVariants: { + type: 'info', + }, +}); +interface AlertProps { + children?: ReactNode; + type?: 'info' | 'error'; +} + +function Alert({ children, type = 'info' }: AlertProps) { + return
{children}
; +} + +export default Alert; diff --git a/apps/web/src/components/live/StreamView/List/VideoPlayer.tsx b/apps/web/src/components/live/StreamView/List/VideoPlayer.tsx index 9f058fec..dbb7eb61 100644 --- a/apps/web/src/components/live/StreamView/List/VideoPlayer.tsx +++ b/apps/web/src/components/live/StreamView/List/VideoPlayer.tsx @@ -21,7 +21,7 @@ const videoVariants = cva('absolute h-full w-full object-cover transition-opacit }); export interface VideoPlayerProps { - stream: MediaStream | null; + stream?: MediaStream | null; paused?: boolean; isMicOn?: boolean; avatarSize?: 'sm' | 'md' | 'lg'; @@ -43,7 +43,7 @@ function VideoPlayer({ useEffect(() => { if (!videoRef.current) return; - videoRef.current.srcObject = stream; + videoRef.current.srcObject = stream ?? null; setIsLoading(false); }, [stream, paused]); diff --git a/apps/web/src/components/live/index.tsx b/apps/web/src/components/live/index.tsx index 94613cc0..29373752 100644 --- a/apps/web/src/components/live/index.tsx +++ b/apps/web/src/components/live/index.tsx @@ -3,9 +3,11 @@ import { useEffect } from 'react'; import ControlBar from '@/components/live/ControlBar'; import StreamView from '@/components/live/StreamView'; +import { toast } from '@/core/toast'; import { useEndTicle } from '@/hooks/api/live'; import { useTicle } from '@/hooks/api/ticle'; import useMediasoup from '@/hooks/mediasoup/useMediasoup'; +import { renderError } from '@/utils/toast/renderMessage'; function MediaContainer() { useMediasoup(); @@ -23,7 +25,7 @@ function MediaContainer() { useEffect(() => { if (ticleData?.ticleStatus === 'closed') { - alert('종료된 티클입니다.'); // TODO: toast로 교체 + toast(renderError('종료된 티클입니다.')); navigate({ to: '/' }); } }, [ticleData?.ticleStatus, navigate]); diff --git a/apps/web/src/components/ticle/open/index.tsx b/apps/web/src/components/ticle/open/index.tsx index 7aa4b6e9..1a87584e 100644 --- a/apps/web/src/components/ticle/open/index.tsx +++ b/apps/web/src/components/ticle/open/index.tsx @@ -6,7 +6,9 @@ import { CreateTicleFormSchema, CreateTicleFormType } from '@repo/types'; import Button from '@/components/common/Button'; import TextArea from '@/components/common/TextArea'; import TextInput from '@/components/common/TextInput'; +import { toast } from '@/core/toast'; import { useCreateTicle } from '@/hooks/api/ticle'; +import { renderError } from '@/utils/toast/renderMessage'; import DateTimePicker from './DateTimePicker'; import FormBox from './FormBox'; @@ -36,7 +38,7 @@ function Open() { const { data } = await mutateAsync(submitData); navigate({ to: `/ticle/${data.ticleId}` }); } catch (_) { - // TODO: 에러 토스트 + toast(renderError('티클 개설에 실패했습니다.')); } }; diff --git a/apps/web/src/components/toast/Toast.tsx b/apps/web/src/components/toast/Toast.tsx index a5e829f5..8779774f 100644 --- a/apps/web/src/components/toast/Toast.tsx +++ b/apps/web/src/components/toast/Toast.tsx @@ -6,7 +6,7 @@ import { ToastProps } from '@/core/toast/type'; import useToast from '@/hooks/toast/useToast'; const containerVariants = cva( - 'z-0 mb-4 flex max-h-[800px] min-h-16 cursor-pointer justify-between overflow-hidden rounded-md bg-[#000000b3] text-white shadow-md [&.bounce-enter]:animate-bounce-in-bottom [&.bounce-exit]:animate-bounce-out-bottom', + 'z-0 mb-4 flex min-h-16 cursor-pointer justify-between overflow-hidden rounded-md bg-[#ffffffb3] text-white shadow-md [&.bounce-enter]:animate-bounce-in-bottom [&.bounce-exit]:animate-bounce-out-bottom', { variants: { closeOnClick: { diff --git a/apps/web/src/components/toast/ToastContainer.tsx b/apps/web/src/components/toast/ToastContainer.tsx index 90f67ed1..55076106 100644 --- a/apps/web/src/components/toast/ToastContainer.tsx +++ b/apps/web/src/components/toast/ToastContainer.tsx @@ -1,15 +1,19 @@ import Toast from '@/components/toast/Toast'; import { ToastCommonOptions } from '@/core/toast/type'; import useToastContainer from '@/hooks/toast/useToastContainer'; -import cn from '@/utils/cn'; const CONTAINER_CLASS = - 'fixed z-50 min-w-[200px] max-w-[320px] p-1 text-white top-2.5 left-1/2 transform -translate-x-1/2'; + 'fixed z-50 min-w-[200px] max-w-[300px] p-1 text-white top-2.5 left-1/2 transform -translate-x-1/2'; export type ToastContainerProps = ToastCommonOptions; -const ToastContainer = ({ autoClose = 2000, closeOnClick = true }: ToastContainerProps) => { +const ToastContainer = ({ + limit = 2, + autoClose = 2000, + closeOnClick = true, +}: ToastContainerProps) => { const { getToastToRender, containerRef, isToastActive } = useToastContainer({ + limit, autoClose, closeOnClick, }); @@ -17,17 +21,13 @@ const ToastContainer = ({ autoClose = 2000, closeOnClick = true }: ToastContaine return (
{getToastToRender((toastList) => ( -
- {toastList.map(({ content, props: toastProps }) => ( - + <> + {toastList.map(({ content, props }) => ( + {content} ))} -
+ ))}
); diff --git a/apps/web/src/components/user/UserProfileDialog.tsx b/apps/web/src/components/user/UserProfileDialog.tsx index 831c7e42..72fe8147 100644 --- a/apps/web/src/components/user/UserProfileDialog.tsx +++ b/apps/web/src/components/user/UserProfileDialog.tsx @@ -1,4 +1,4 @@ -import { Link, useNavigate } from '@tanstack/react-router'; +import { useNavigate } from '@tanstack/react-router'; import { useUserProfile } from '@/hooks/api/user'; diff --git a/apps/web/src/core/toast/type.ts b/apps/web/src/core/toast/type.ts index ae7af443..3df92cc1 100644 --- a/apps/web/src/core/toast/type.ts +++ b/apps/web/src/core/toast/type.ts @@ -5,6 +5,7 @@ export type ToastContent = ReactNode; export type ToastId = number | string; export interface ToastCommonOptions { + limit?: number; autoClose?: number; closeOnClick?: boolean; } diff --git a/apps/web/src/hooks/api/ticle.ts b/apps/web/src/hooks/api/ticle.ts index a02c8df4..eb791303 100644 --- a/apps/web/src/hooks/api/ticle.ts +++ b/apps/web/src/hooks/api/ticle.ts @@ -2,6 +2,8 @@ import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tansta import { useNavigate } from '@tanstack/react-router'; import { getTitleList, getTicle, createTicle, applyTicle, deleteTicle } from '@/api/ticle'; +import { toast } from '@/core/toast'; +import { renderSuccess } from '@/utils/toast/renderMessage'; interface GetTicleListParams { page?: number; @@ -41,6 +43,7 @@ export const useCreateTicle = () => { return useMutation({ mutationFn: createTicle, onSuccess: () => { + toast(renderSuccess('티클이 생성되었습니다.')); queryClient.invalidateQueries({ queryKey: ['ticleList'] }); queryClient.invalidateQueries({ queryKey: ['dashboardTicleList'] }); }, @@ -54,7 +57,7 @@ export const useApplyTicle = () => { return useMutation({ mutationFn: applyTicle, onSuccess: (_, ticleId) => { - alert('티클 신청이 완료되었습니다.'); // TODO: toast로 교체 + toast(renderSuccess('티클 신청이 완료되었습니다.')); navigate({ to: `/dashboard/apply` }); queryClient.invalidateQueries({ queryKey: ['ticleList'] }); queryClient.invalidateQueries({ queryKey: ['dashboardTicleList'] }); @@ -71,7 +74,7 @@ export const useDeleteTicle = () => { return useMutation({ mutationFn: deleteTicle, onSuccess: () => { - alert('티클이 삭제되었습니다.'); // TODO: toast로 교체 + toast(renderSuccess('티클이 삭제되었습니다.')); navigate({ to: `/` }); queryClient.invalidateQueries({ queryKey: ['ticleList'] }); queryClient.invalidateQueries({ queryKey: ['dashboardTicleList'] }); diff --git a/apps/web/src/hooks/mediasoup/useLocalStream.ts b/apps/web/src/hooks/mediasoup/useLocalStream.ts index b866cd86..9f1f4b6e 100644 --- a/apps/web/src/hooks/mediasoup/useLocalStream.ts +++ b/apps/web/src/hooks/mediasoup/useLocalStream.ts @@ -2,7 +2,9 @@ import { useEffect } from 'react'; import { MediaTypes } from '@repo/mediasoup'; import { useMediasoupAction } from '@/contexts/mediasoup/context'; +import { toast } from '@/core/toast'; import useMediaTracks from '@/hooks/useMediaTracks'; +import { renderError } from '@/utils/toast/renderMessage'; const DEFAULT_LOCAL_STREAM = { stream: null, @@ -41,12 +43,14 @@ const useLocalStream = () => { const track = await getCameraTrack(); if (!track) { - return; + throw new Error(); } return createProducer('video', track); - } catch (_) { + } catch (e) { + toast(renderError('카메라를 찾을 수 없습니다.')); closeStream('video'); + throw e; } }; @@ -55,29 +59,36 @@ const useLocalStream = () => { const track = await getAudioTrack(); if (!track) { - return; + throw new Error(); } - return createProducer('audio', track); - } catch (_) { + } catch (e) { + toast(renderError('마이크를 찾을 수 없습니다.')); closeStream('audio'); + throw e; } }; const startScreenStream = async () => { - const track = await getScreenTrack(); + try { + const track = await getScreenTrack(); - if (!track) { - return; - } + if (!track) { + throw new Error(); + } - track.onended = () => { - track.stop(); - closeStream('screen'); - closeProducer('screen'); - }; + track.onended = () => { + track.stop(); + closeStream('screen'); + closeProducer('screen'); + }; - return createProducer('screen', track); + return createProducer('screen', track); + } catch (e) { + toast(renderError('화면 공유를 시작할 수 없습니다.')); + closeStream('screen'); + throw e; + } }; const closeScreenStream = () => { diff --git a/apps/web/src/hooks/mediasoup/useMediasoup.ts b/apps/web/src/hooks/mediasoup/useMediasoup.ts index d44fdc34..8909ed12 100644 --- a/apps/web/src/hooks/mediasoup/useMediasoup.ts +++ b/apps/web/src/hooks/mediasoup/useMediasoup.ts @@ -66,13 +66,9 @@ const useMediasoup = () => { }; const setLocalStream = async (device: client.Device) => { - try { - await createSendTransport(device); + await createSendTransport(device); - await Promise.all([startCameraStream(), startMicStream()]); - } catch (_) { - // TODO: Error - } + Promise.all([startCameraStream(), startMicStream()]); }; const setRemoteStream = async (device: client.Device) => { diff --git a/apps/web/src/hooks/mediasoup/useSocket.ts b/apps/web/src/hooks/mediasoup/useSocket.ts index 3768d340..d7d9cf8c 100644 --- a/apps/web/src/hooks/mediasoup/useSocket.ts +++ b/apps/web/src/hooks/mediasoup/useSocket.ts @@ -4,6 +4,8 @@ import { io, Socket } from 'socket.io-client'; import { SOCKET_EVENTS } from '@repo/mediasoup'; import { ENV } from '@/constants/env'; +import { toast } from '@/core/toast'; +import { renderError } from '@/utils/toast/renderMessage'; const SOCKET_OPTIONS = { transports: ['websocket', 'polling'], @@ -38,6 +40,10 @@ const useSocket = (): UseSocketReturn => { const initSocketEvents = useCallback( (socket: Socket) => { + socket.on(SOCKET_EVENTS.error, (result) => { + toast(renderError(result.error.message)); + }); + socket.on(SOCKET_EVENTS.connect, () => { setIsConnected(true); setIsError(null); diff --git a/apps/web/src/hooks/toast/useToastContainer.ts b/apps/web/src/hooks/toast/useToastContainer.ts index 11e9a3ce..11c66736 100644 --- a/apps/web/src/hooks/toast/useToastContainer.ts +++ b/apps/web/src/hooks/toast/useToastContainer.ts @@ -1,4 +1,3 @@ -/* eslint-disable react-hooks/exhaustive-deps */ import { useEffect, useRef, @@ -99,11 +98,15 @@ const useToastContainer = (props: ToastContainerProps) => { }; const appendToast = (content: ToastContent, props: ToastProps) => { - const { toastId } = props; + const { toastId, limit } = props; toastToRender.set(toastId, { content, props }); - setToastIds((state) => [toastId, ...state]); + setToastIds((state) => { + const newToastIds = [toastId, ...state].slice(0, limit); + + return newToastIds; + }); }; const getToastToRender = (cb: (toastList: Toast[]) => ReactNode) => { diff --git a/apps/web/src/hooks/useMediaTracks.ts b/apps/web/src/hooks/useMediaTracks.ts index 61bd27e0..e3448e94 100644 --- a/apps/web/src/hooks/useMediaTracks.ts +++ b/apps/web/src/hooks/useMediaTracks.ts @@ -37,7 +37,6 @@ const useMediaTracks = () => { const stream = await getCameraStream({ video: { deviceId: selectedVideoDeviceId }, }); - const track = stream.getVideoTracks()[0]; if (!track) { diff --git a/apps/web/src/utils/errorHandler.ts b/apps/web/src/utils/errorHandler.ts index e097826b..44459cb3 100644 --- a/apps/web/src/utils/errorHandler.ts +++ b/apps/web/src/utils/errorHandler.ts @@ -1,12 +1,13 @@ import axios from 'axios'; +import { toast } from '@/core/toast'; +import { renderError } from '@/utils/toast/renderMessage'; + export const handleError = (error: unknown) => { - if (error instanceof Error) { - if (axios.isAxiosError(error)) { - const serverError = error.response?.data; - - // TODO: alert가 아닌 toast로 교체 - alert(serverError?.error.message || '오류가 발생했습니다.'); - } - } + if (!(error instanceof Error)) return; + + if (!axios.isAxiosError(error)) return; + + const serverError = error.response?.data; + toast(renderError(serverError?.error.message || '오류가 발생했습니다.')); }; diff --git a/apps/web/src/utils/toast/renderMessage.tsx b/apps/web/src/utils/toast/renderMessage.tsx new file mode 100644 index 00000000..b0b14d80 --- /dev/null +++ b/apps/web/src/utils/toast/renderMessage.tsx @@ -0,0 +1,9 @@ +import Alert from '@/components/common/Alert'; + +export const renderError = (message: string) => { + return {message}; +}; + +export const renderSuccess = (message: string) => { + return {message}; +};