diff --git a/apps/chat/src/auth/jwt-auth.guard.ts b/apps/chat/src/auth/jwt-auth.guard.ts index 7e1d1f3f..03f29c82 100644 --- a/apps/chat/src/auth/jwt-auth.guard.ts +++ b/apps/chat/src/auth/jwt-auth.guard.ts @@ -8,10 +8,11 @@ import { ErrorStatus } from 'src/common/responses/exceptions/errorStatus'; export class JWTAuthGuard extends AuthGuard('jwt') { getRequest(context: ExecutionContext) { const client = context.switchToWs().getClient(); + const authorization = client.handshake.auth.accessToken || client.handshake.headers.accesstoken; return { headers: { - authorization: client.handshake.auth.accessToken, + authorization, }, }; } diff --git a/apps/client/.eslintignore b/apps/client/.eslintignore new file mode 100644 index 00000000..9f76e33a --- /dev/null +++ b/apps/client/.eslintignore @@ -0,0 +1,9 @@ +# shadcn/ui 컴포넌트 폴더 무시 +src/shared/ui/shadcn/* + +# node_modules는 기본적으로 무시되지만, 명시적으로 추가할 수도 있습니다 +node_modules/ + +# 다른 무시하고 싶은 파일/폴더들 +dist/ +build/ \ No newline at end of file diff --git a/apps/client/.eslintrc b/apps/client/.eslintrc index c62c6898..684ff1ea 100644 --- a/apps/client/.eslintrc +++ b/apps/client/.eslintrc @@ -1,35 +1,45 @@ { "parser": "@typescript-eslint/parser", + "parserOptions": { - "project": ["./apps/client/tsconfig.json"], + "project": ["./tsconfig.json"], "ecmaVersion": 12, "sourceType": "module", "ecmaFeatures": { "jsx": true } }, + "env": { "browser": true, "es2021": true }, - "extends": [ - "eslint:recommended", - "plugin:react/recommended", - "plugin:react-hooks/recommended", - "plugin:@typescript-eslint/recommended", - "prettier" - ], + + "extends": ["airbnb", "airbnb/hooks", "plugin:@typescript-eslint/recommended", "prettier"], + "settings": { "react": { "version": "detect" } }, + "plugins": ["prettier"], + "rules": { + // React 관련 규칙 "react/react-in-jsx-scope": "off", "react/no-unescaped-entities": "off", "react/prop-types": "off", - "react-hooks/exhaustive-deps": "warn", + "react/jsx-filename-extension": [ + "warn", + { + "extensions": [".js", ".jsx", ".ts", ".tsx"] + } + ], + "react/require-default-props": "off", + "react/jsx-props-no-spreading": "off", + + // TypeScript 관련 규칙 "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-unused-vars": [ "error", @@ -38,6 +48,23 @@ "varsIgnorePattern": "^_", // _ 로 시작하는 변수는 무시 "ignoreRestSiblings": true } + ], + + // Import/Export 관련 규칙 + "import/no-unresolved": "off", + "import/extensions": ["off"], + "import/prefer-default-export": "off", + "no-restricted-exports": "warn", + + // 접근성 관련 규칙 + "jsx-a11y/media-has-caption": "off", + + // 기타 규칙 + "no-param-reassign": [ + "warn", + { + "props": false + } ] } } diff --git a/apps/client/.gitignore b/apps/client/.gitignore index a547bf36..5bc5b2cb 100644 --- a/apps/client/.gitignore +++ b/apps/client/.gitignore @@ -22,3 +22,7 @@ dist-ssr *.njsproj *.sln *.sw? + +# Sonar +.sonar/ +.scannerwork/ \ No newline at end of file diff --git a/apps/client/package.json b/apps/client/package.json index 731560f5..7c4e3e1d 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint", - "preview": "vite preview" + "preview": "vite preview", + "sonar": "sonar-scanner" }, "dependencies": { "@radix-ui/react-avatar": "^1.1.1", @@ -33,17 +34,25 @@ "@types/node": "^20.3.1", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", "@vitejs/plugin-react-swc": "^3.5.0", "autoprefixer": "^10.4.20", "eslint": "*", + "eslint-config-airbnb": "^19.0.4", + "eslint-config-airbnb-typescript": "^18.0.0", "eslint-config-prettier": "*", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-prettier": "*", "eslint-plugin-react": "^7.37.2", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.14", "postcss": "^8.4.47", "prettier": "*", + "sonarqube-scanner": "^4.2.6", "tailwindcss": "^3.4.14", + "typescript": "*", "vite": "^5.4.10" } } diff --git a/apps/client/src/assets/fonts/PretendardVariable.woff2 b/apps/client/public/fonts/PretendardVariable.woff2 similarity index 100% rename from apps/client/src/assets/fonts/PretendardVariable.woff2 rename to apps/client/public/fonts/PretendardVariable.woff2 diff --git a/apps/client/sonar-project.properties b/apps/client/sonar-project.properties new file mode 100644 index 00000000..34fbe225 --- /dev/null +++ b/apps/client/sonar-project.properties @@ -0,0 +1,3 @@ +sonar.projectKey=CamOn +sonar.sources=. +sonar.host.url=http://localhost:9000 \ No newline at end of file diff --git a/apps/client/src/App.css b/apps/client/src/App.css deleted file mode 100644 index ee7ff7d6..00000000 --- a/apps/client/src/App.css +++ /dev/null @@ -1,4 +0,0 @@ -main { - padding-top: 74px; - height: 100%; -} diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx deleted file mode 100644 index 3256a8a7..00000000 --- a/apps/client/src/App.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Outlet } from 'react-router-dom'; -import Header from '@components/Header'; -import './App.css'; -import { AuthProvider } from '@contexts/AuthContext'; -import { Toaster } from '@components/ui/toaster'; -import FloatingButton from '@components/FloatingButton'; -import { ThemeProvider } from './contexts/ThemeContext'; - -function App() { - return ( - - -
-
- -
- - - - - ); -} - -export default App; diff --git a/apps/client/src/Router.tsx b/apps/client/src/Router.tsx deleted file mode 100644 index f621b769..00000000 --- a/apps/client/src/Router.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { createBrowserRouter } from 'react-router-dom'; -import App from './App'; -import Home from '@pages/Home'; -import Profile from '@pages/Profile'; -import Live from '@pages/Live'; -import Broadcast from '@pages/Broadcast'; -import Auth from '@pages/Auth'; -import Record from '@pages/Record'; -import ProtectedRoute from './ProtectedRoute'; - -const routerOptions = { - future: { - v7_startTransition: true, - v7_relativeSplatPath: true, - v7_fetcherPersist: true, - v7_normalizeFormMethod: true, - v7_partialHydration: true, - v7_skipActionErrorRevalidation: true, - }, -}; - -const router = createBrowserRouter( - [ - { - path: '/', - element: , - children: [ - { - path: '', - element: , - }, - { - path: 'live/:liveId', - element: , - }, - { - path: 'auth', - element: , - }, - { - path: '', - element: , - children: [ - { - path: 'profile', - element: , - }, - - { - path: 'record/:attendanceId', - element: , - }, - ], - }, - ], - }, - { - path: 'broadcast', - element: , - children: [ - { - path: '', - element: , - }, - ], - }, - ], - routerOptions, -); - -export default router; diff --git a/apps/client/src/app/layouts/Layout.tsx b/apps/client/src/app/layouts/Layout.tsx new file mode 100644 index 00000000..7520bce2 --- /dev/null +++ b/apps/client/src/app/layouts/Layout.tsx @@ -0,0 +1,18 @@ +import { Outlet } from 'react-router-dom'; +import { Toaster } from '@/shared/ui/shadcn/toaster'; +import { Header } from '@/widgets'; +import { FloatingButton } from '@/shared/ui'; +import { Providers } from '../providers'; + +export function Layout() { + return ( + +
+
+ +
+ + + + ); +} diff --git a/apps/client/src/app/layouts/index.ts b/apps/client/src/app/layouts/index.ts new file mode 100644 index 00000000..9fc685e2 --- /dev/null +++ b/apps/client/src/app/layouts/index.ts @@ -0,0 +1 @@ +export { Layout } from './Layout'; diff --git a/apps/client/src/app/providers/AuthProvider.tsx b/apps/client/src/app/providers/AuthProvider.tsx new file mode 100644 index 00000000..2829e9f2 --- /dev/null +++ b/apps/client/src/app/providers/AuthProvider.tsx @@ -0,0 +1,9 @@ +import { useMemo, useState } from 'react'; +import { AuthContext } from '@/shared/contexts'; +import { ProviderProps } from './types'; + +export function AuthProvider({ children }: ProviderProps) { + const [isLoggedIn, setIsLoggedIn] = useState(() => !!localStorage.getItem('accessToken')); + const value = useMemo(() => ({ isLoggedIn, setIsLoggedIn }), [isLoggedIn, setIsLoggedIn]); + return {children}; +} diff --git a/apps/client/src/app/providers/Providers.tsx b/apps/client/src/app/providers/Providers.tsx new file mode 100644 index 00000000..09c24660 --- /dev/null +++ b/apps/client/src/app/providers/Providers.tsx @@ -0,0 +1,11 @@ +import { ThemeProvider } from '@/app/providers/ThemeProvider'; +import { AuthProvider } from '@/app/providers/AuthProvider'; +import { ProviderProps } from './types'; + +export function Providers({ children }: ProviderProps) { + return ( + + {children} + + ); +} diff --git a/apps/client/src/app/providers/ThemeProvider.tsx b/apps/client/src/app/providers/ThemeProvider.tsx new file mode 100644 index 00000000..ad5f6be8 --- /dev/null +++ b/apps/client/src/app/providers/ThemeProvider.tsx @@ -0,0 +1,11 @@ +import { useMemo, useState } from 'react'; +import { ThemeContext } from '@/shared/contexts'; +import { ProviderProps } from './types'; + +type Theme = 'light' | 'dark' | null; + +export function ThemeProvider({ children }: ProviderProps) { + const [theme, setTheme] = useState(() => (localStorage.getItem('theme') as Theme) ?? null); + const value = useMemo(() => ({ theme, setTheme }), [theme, setTheme]); + return {children}; +} diff --git a/apps/client/src/app/providers/index.ts b/apps/client/src/app/providers/index.ts new file mode 100644 index 00000000..4c75a95d --- /dev/null +++ b/apps/client/src/app/providers/index.ts @@ -0,0 +1 @@ +export { Providers } from './Providers'; diff --git a/apps/client/src/app/providers/types.ts b/apps/client/src/app/providers/types.ts new file mode 100644 index 00000000..0820549d --- /dev/null +++ b/apps/client/src/app/providers/types.ts @@ -0,0 +1 @@ +export type ProviderProps = Readonly<{ children: React.ReactNode }>; diff --git a/apps/client/src/ProtectedRoute.tsx b/apps/client/src/app/routes/ProtectedRoute.tsx similarity index 83% rename from apps/client/src/ProtectedRoute.tsx rename to apps/client/src/app/routes/ProtectedRoute.tsx index 48d7b79f..bc7ee379 100644 --- a/apps/client/src/ProtectedRoute.tsx +++ b/apps/client/src/app/routes/ProtectedRoute.tsx @@ -1,6 +1,6 @@ import { useContext } from 'react'; -import { AuthContext } from '@contexts/AuthContext'; import { Navigate, Outlet } from 'react-router-dom'; +import { AuthContext } from '@/features/auth/model/AuthContext'; function ProtectedRoute() { const { isLoggedIn } = useContext(AuthContext); diff --git a/apps/client/src/app/routes/config/index.ts b/apps/client/src/app/routes/config/index.ts new file mode 100644 index 00000000..e8b2d712 --- /dev/null +++ b/apps/client/src/app/routes/config/index.ts @@ -0,0 +1 @@ +export { routerOptions } from './options'; diff --git a/apps/client/src/app/routes/config/options.ts b/apps/client/src/app/routes/config/options.ts new file mode 100644 index 00000000..9e6604ab --- /dev/null +++ b/apps/client/src/app/routes/config/options.ts @@ -0,0 +1,10 @@ +export const routerOptions = { + future: { + v7_startTransition: true, + v7_relativeSplatPath: true, + v7_fetcherPersist: true, + v7_normalizeFormMethod: true, + v7_partialHydration: true, + v7_skipActionErrorRevalidation: true, + }, +}; diff --git a/apps/client/src/app/routes/index.ts b/apps/client/src/app/routes/index.ts new file mode 100644 index 00000000..b5259a1a --- /dev/null +++ b/apps/client/src/app/routes/index.ts @@ -0,0 +1 @@ +export { router } from './router'; diff --git a/apps/client/src/app/routes/router.tsx b/apps/client/src/app/routes/router.tsx new file mode 100644 index 00000000..7049f81c --- /dev/null +++ b/apps/client/src/app/routes/router.tsx @@ -0,0 +1,77 @@ +import { createBrowserRouter } from 'react-router-dom'; +import { lazy, Suspense } from 'react'; +import { HomePage } from '@/pages/Home'; +import { AuthPage } from '@/pages/Auth'; +import { Layout } from '@/app/layouts'; +import ProtectedRoute from './ProtectedRoute'; +import { routerOptions } from './config'; +import { LoadingCharacter } from '@/shared/ui'; + +const LivePage = lazy(() => import('@/pages/Live')); +const BroadcastPage = lazy(() => import('@/pages/Broadcast')); +const ProfilePage = lazy(() => import('@/pages/Profile')); +const RecordPage = lazy(() => import('@/pages/Record')); + +export const router = createBrowserRouter( + [ + { + path: '/', + element: , + children: [ + { + path: '', + element: , + }, + { + path: 'live/:liveId', + element: ( + }> + + + ), + }, + { + path: 'auth', + element: , + }, + { + path: '', + element: , + children: [ + { + path: 'profile', + element: ( + }> + + + ), + }, + { + path: 'record/:attendanceId', + element: ( + }> + + + ), + }, + ], + }, + ], + }, + { + path: 'broadcast', + element: , + children: [ + { + path: '', + element: ( + }> + + + ), + }, + ], + }, + ], + routerOptions, +); diff --git a/apps/client/src/components/Footer/index.tsx b/apps/client/src/components/Footer/index.tsx deleted file mode 100644 index bfccfe88..00000000 --- a/apps/client/src/components/Footer/index.tsx +++ /dev/null @@ -1,12 +0,0 @@ -function Footer() { - return ( - - ); -} -export default Footer; diff --git a/apps/client/src/components/ui/input.tsx b/apps/client/src/components/ui/input.tsx deleted file mode 100644 index 4d10fe21..00000000 --- a/apps/client/src/components/ui/input.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import * as React from 'react'; - -import { cn } from '@/utils/utils'; - -const Input = React.forwardRef>( - ({ className, type, ...props }, ref) => { - return ( - - ); - }, -); -Input.displayName = 'Input'; - -export { Input }; diff --git a/apps/client/src/components/ui/toaster.tsx b/apps/client/src/components/ui/toaster.tsx deleted file mode 100644 index c09813a6..00000000 --- a/apps/client/src/components/ui/toaster.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { useToast } from '@/hooks/useToast'; -import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from '@/components/ui/toast'; - -export function Toaster() { - const { toasts } = useToast(); - - return ( - - {toasts.map(function ({ id, title, description, action, ...props }) { - return ( - -
- {title && {title}} - {description && {description}} -
- {action} - -
- ); - })} - -
- ); -} diff --git a/apps/client/src/contexts/AuthContext.tsx b/apps/client/src/contexts/AuthContext.tsx deleted file mode 100644 index 75591483..00000000 --- a/apps/client/src/contexts/AuthContext.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React, { createContext, useState } from 'react'; - -interface AuthContextInterface { - isLoggedIn: boolean; - setIsLoggedIn: React.Dispatch>; -} - -const initialState = { - isLoggedIn: !!localStorage.getItem('accessToken'), - setIsLoggedIn: () => {}, -}; - -export const AuthContext = createContext(initialState); - -export function AuthProvider({ children }: { children: React.ReactNode }) { - const [isLoggedIn, setIsLoggedIn] = useState(() => !!localStorage.getItem('accessToken')); - return {children}; -} diff --git a/apps/client/src/contexts/ThemeContext.tsx b/apps/client/src/contexts/ThemeContext.tsx deleted file mode 100644 index f27daea5..00000000 --- a/apps/client/src/contexts/ThemeContext.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { createContext, useState } from 'react'; - -type Theme = 'light' | 'dark' | null; - -interface ThemeContextInterface { - theme: Theme; - setTheme: React.Dispatch>; -} - -const currentTheme = localStorage.getItem('theme') ?? null; - -export const ThemeContext = createContext({ - theme: currentTheme as Theme, - setTheme: () => null, -}); - -export function ThemeProvider({ children }: { children: React.ReactNode }) { - const [theme, setTheme] = useState(() => (localStorage.getItem('theme') as Theme) ?? null); - return {children}; -} diff --git a/apps/client/src/features/auth/index.ts b/apps/client/src/features/auth/index.ts new file mode 100644 index 00000000..7079aff4 --- /dev/null +++ b/apps/client/src/features/auth/index.ts @@ -0,0 +1 @@ +export { useAuth, AuthContext } from './model'; diff --git a/apps/client/src/features/auth/model/AuthContext.tsx b/apps/client/src/features/auth/model/AuthContext.tsx new file mode 100644 index 00000000..307e5afc --- /dev/null +++ b/apps/client/src/features/auth/model/AuthContext.tsx @@ -0,0 +1,13 @@ +import { createContext } from 'react'; + +const initialState = { + isLoggedIn: !!localStorage.getItem('accessToken'), + setIsLoggedIn: () => {}, +}; + +type AuthContextValue = { + isLoggedIn: boolean; + setIsLoggedIn: React.Dispatch>; +}; + +export const AuthContext = createContext(initialState); diff --git a/apps/client/src/features/auth/model/index.ts b/apps/client/src/features/auth/model/index.ts new file mode 100644 index 00000000..d856ec03 --- /dev/null +++ b/apps/client/src/features/auth/model/index.ts @@ -0,0 +1,2 @@ +export { useAuth } from './useAuth'; +export { AuthContext } from './AuthContext'; diff --git a/apps/client/src/hooks/useAuth.ts b/apps/client/src/features/auth/model/useAuth.ts similarity index 89% rename from apps/client/src/hooks/useAuth.ts rename to apps/client/src/features/auth/model/useAuth.ts index 3812d212..537d62ab 100644 --- a/apps/client/src/hooks/useAuth.ts +++ b/apps/client/src/features/auth/model/useAuth.ts @@ -1,6 +1,6 @@ -import { AuthContext } from '@contexts/AuthContext'; import { useContext, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; +import { AuthContext } from '@/features/auth/model/AuthContext'; export const useAuth = () => { const navigate = useNavigate(); @@ -23,7 +23,7 @@ export const useAuth = () => { useEffect(() => { setIsLoggedIn(!!localStorage.getItem('accessToken')); - }, []); + }, [setIsLoggedIn]); return { requestLogIn, setLogIn, logout }; }; diff --git a/apps/client/src/features/broadcasting/index.ts b/apps/client/src/features/broadcasting/index.ts new file mode 100644 index 00000000..207ddd03 --- /dev/null +++ b/apps/client/src/features/broadcasting/index.ts @@ -0,0 +1,4 @@ +export { BroadcastPlayer, BroadcastTitle, RecordButton } from './ui'; +export { useProduce, useMedia, useScreenShare } from './model'; + +export type { Tracks } from './model'; diff --git a/apps/client/src/features/broadcasting/model/index.ts b/apps/client/src/features/broadcasting/model/index.ts new file mode 100644 index 00000000..f1b0b892 --- /dev/null +++ b/apps/client/src/features/broadcasting/model/index.ts @@ -0,0 +1,4 @@ +export { useMedia } from './useMedia'; +export { useProduce } from './mediasoup'; +export { useScreenShare } from './useScreenShare'; +export type { Tracks } from './trackTypes'; diff --git a/apps/client/src/constants/videoOptions.ts b/apps/client/src/features/broadcasting/model/mediasoup/encodingOptions.ts similarity index 61% rename from apps/client/src/constants/videoOptions.ts rename to apps/client/src/features/broadcasting/model/mediasoup/encodingOptions.ts index 1e261c0e..f9f37565 100644 --- a/apps/client/src/constants/videoOptions.ts +++ b/apps/client/src/features/broadcasting/model/mediasoup/encodingOptions.ts @@ -3,9 +3,3 @@ export const ENCODING_OPTIONS = [ { maxBitrate: 2500000, scaleResolutionDownBy: 1.5, maxFramerate: 30 }, { maxBitrate: 4000000, scaleResolutionDownBy: 1, maxFramerate: 30 }, ]; - -export const RESOLUTION_OPTIONS = { - high: { width: 1920, height: 1080 }, - medium: { width: 1280, height: 720 }, - low: { width: 854, height: 480 }, -}; diff --git a/apps/client/src/features/broadcasting/model/mediasoup/index.ts b/apps/client/src/features/broadcasting/model/mediasoup/index.ts new file mode 100644 index 00000000..ab31d452 --- /dev/null +++ b/apps/client/src/features/broadcasting/model/mediasoup/index.ts @@ -0,0 +1 @@ +export { useProduce } from './useProduce'; diff --git a/apps/client/src/features/broadcasting/model/mediasoup/produceHelpers.ts b/apps/client/src/features/broadcasting/model/mediasoup/produceHelpers.ts new file mode 100644 index 00000000..ed7d2c48 --- /dev/null +++ b/apps/client/src/features/broadcasting/model/mediasoup/produceHelpers.ts @@ -0,0 +1,63 @@ +import { Socket } from 'socket.io-client'; +import { Transport } from 'mediasoup-client/lib/types'; +import { TransportInfo } from '@/shared/types/mediasoupTypes'; +import { ENCODING_OPTIONS } from './encodingOptions'; + +export const getRoomId = (socket: Socket): Promise => + new Promise(resolve => { + socket.emit('createRoom', (response: { roomId: string }) => { + resolve(response.roomId); + }); + }); + +export const createProducer = async ( + socket: Socket, + roomId: string, + transport: Transport, + transportInfo: TransportInfo, + mediaStream: MediaStream, +) => { + const handleProduce = (parameters: any, callback: any) => { + socket.emit( + 'createProducer', + { + roomId, + transportId: transportInfo.transportId, + kind: parameters.kind, + rtpParameters: parameters.rtpParameters, + }, + (response: { producerId: string }) => { + callback({ id: response.producerId }); + }, + ); + }; + + transport.on('produce', handleProduce); + + const producers = new Map(); + + try { + await Promise.all( + mediaStream.getTracks().map(async track => { + const producerConfig: Record = { + track, + stopTracks: false, + }; + + if (track.kind === 'video') { + producerConfig.encodings = ENCODING_OPTIONS; + producerConfig.codecOptions = { + videoGoogleStartBitrate: 1000, + }; + } + + const producer = await transport.produce(producerConfig); + producers.set(track.kind, producer); + }), + ); + + return producers; + } finally { + transport.off('produce', handleProduce); + } +}; diff --git a/apps/client/src/features/broadcasting/model/mediasoup/useProduce.ts b/apps/client/src/features/broadcasting/model/mediasoup/useProduce.ts new file mode 100644 index 00000000..b1ba6266 --- /dev/null +++ b/apps/client/src/features/broadcasting/model/mediasoup/useProduce.ts @@ -0,0 +1,74 @@ +import { Socket } from 'socket.io-client'; +import { useEffect, useRef, useState } from 'react'; +import { Producer, Transport } from 'mediasoup-client/lib/types'; +import { createProducer, getRoomId } from './produceHelpers'; +import { connectTransport, createDevice, getRtpCapabilities } from '@/shared/lib'; + +type UseProduceProps = { + socket: Socket | null; + mediaStream: MediaStream | null; +}; + +type UseProduceReturn = { + producers: Map; + error: Error | null; + roomId: string; + transport: Transport | null; +}; + +export const useProduce = ({ socket, mediaStream }: UseProduceProps): UseProduceReturn => { + const [error, setError] = useState(null); + const [roomId, setRoomId] = useState(''); + const producersRef = useRef>(new Map()); + const transportRef = useRef(null); + + useEffect(() => { + if (!socket || !mediaStream) return undefined; + const initializeProducer = async () => { + const newRoomId = await getRoomId(socket); + if (!newRoomId) { + setError(new Error('roomId가 없습니다.')); + return; + } + setRoomId(newRoomId); + + const rtpCapabilities = await getRtpCapabilities(socket, newRoomId); + if (!rtpCapabilities) { + setError(new Error('rtpCapabilities가 없습니다.')); + return; + } + + const device = await createDevice(rtpCapabilities); + if (!device) { + setError(new Error('device가 없습니다.')); + return; + } + + const { transport: newTransport, transportInfo } = await connectTransport(socket, device, newRoomId, true); + if (!newTransport || !transportInfo) { + setError(new Error('transport 연결에 문제가 발생했습니다.')); + return; + } + transportRef.current = newTransport; + + const newProducers = await createProducer(socket, newRoomId, newTransport, transportInfo, mediaStream); + producersRef.current = newProducers; + }; + + initializeProducer(); + + return () => { + producersRef.current.forEach(producer => producer.close()); + if (transportRef.current) { + transportRef.current.close(); + } + }; + }, [socket, mediaStream]); + + return { + roomId, + transport: transportRef.current, + producers: producersRef.current, + error, + }; +}; diff --git a/apps/client/src/features/broadcasting/model/trackTypes.ts b/apps/client/src/features/broadcasting/model/trackTypes.ts new file mode 100644 index 00000000..7c9f057b --- /dev/null +++ b/apps/client/src/features/broadcasting/model/trackTypes.ts @@ -0,0 +1,5 @@ +export type Tracks = { + video: MediaStreamTrack | undefined; + mediaAudio: MediaStreamTrack | undefined; + screenAudio: MediaStreamTrack | undefined; +}; diff --git a/apps/client/src/hooks/useMedia.ts b/apps/client/src/features/broadcasting/model/useMedia.ts similarity index 100% rename from apps/client/src/hooks/useMedia.ts rename to apps/client/src/features/broadcasting/model/useMedia.ts diff --git a/apps/client/src/hooks/useScreenShare.ts b/apps/client/src/features/broadcasting/model/useScreenShare.ts similarity index 69% rename from apps/client/src/hooks/useScreenShare.ts rename to apps/client/src/features/broadcasting/model/useScreenShare.ts index ee90d69e..b7fc5261 100644 --- a/apps/client/src/hooks/useScreenShare.ts +++ b/apps/client/src/features/broadcasting/model/useScreenShare.ts @@ -1,10 +1,18 @@ -import { useEffect, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; -const useScreenShare = () => { +export const useScreenShare = () => { const screenStreamRef = useRef(null); const [screenShareError, setScreenShareError] = useState(null); const [isScreenSharing, setIsScreenSharing] = useState(false); + const endScreenShare = () => { + if (screenStreamRef.current) { + screenStreamRef.current.getTracks().forEach(track => track.stop()); + } + screenStreamRef.current = null; + setIsScreenSharing(false); + }; + const startScreenShare = async () => { try { const options = { @@ -13,8 +21,9 @@ const useScreenShare = () => { }; const mediaStream = await navigator.mediaDevices.getDisplayMedia(options); - setIsScreenSharing(true); + mediaStream.getVideoTracks()[0].onended = endScreenShare; screenStreamRef.current = mediaStream; + setIsScreenSharing(true); } catch (err) { if (err instanceof Error) { if (err.name !== 'NotAllowedError') { @@ -26,14 +35,6 @@ const useScreenShare = () => { } }; - const endScreenShare = async () => { - if (screenStreamRef.current) { - screenStreamRef.current.getTracks().forEach(track => track.stop()); - } - screenStreamRef.current = null; - setIsScreenSharing(false); - }; - const toggleScreenShare = () => { if (isScreenSharing) { endScreenShare(); @@ -42,25 +43,6 @@ const useScreenShare = () => { } }; - useEffect(() => { - if (isScreenSharing) { - startScreenShare(); - setIsScreenSharing(false); - } - - return () => { - endScreenShare(); - }; - }, []); - - useEffect(() => { - if (screenStreamRef.current) { - screenStreamRef.current.getVideoTracks()[0].onended = () => { - endScreenShare(); - }; - } - }, [screenStreamRef.current]); - return { screenStream: screenStreamRef.current, isScreenSharing, @@ -68,5 +50,3 @@ const useScreenShare = () => { toggleScreenShare, }; }; - -export default useScreenShare; diff --git a/apps/client/src/pages/Broadcast/BroadcastPlayer.tsx b/apps/client/src/features/broadcasting/ui/BroadcastPlayer/BroadcastPlayer.tsx similarity index 73% rename from apps/client/src/pages/Broadcast/BroadcastPlayer.tsx rename to apps/client/src/features/broadcasting/ui/BroadcastPlayer/BroadcastPlayer.tsx index 5b5dc059..6039076d 100644 --- a/apps/client/src/pages/Broadcast/BroadcastPlayer.tsx +++ b/apps/client/src/features/broadcasting/ui/BroadcastPlayer/BroadcastPlayer.tsx @@ -1,8 +1,8 @@ -import { RESOLUTION_OPTIONS } from '@/constants/videoOptions'; -import { Tracks } from '@/types/mediasoupTypes'; import { useEffect, useRef } from 'react'; +import { RESOLUTION_OPTIONS } from './resolutionOptions'; +import { Tracks } from '../../../../features/broadcasting/model/trackTypes'; -interface BroadcastPlayerProps { +type BroadcastPlayerProps = Readonly<{ mediaStream: MediaStream | null; screenStream: MediaStream | null; isVideoEnabled: boolean; @@ -10,9 +10,9 @@ interface BroadcastPlayerProps { isStreamReady: boolean; setIsStreamReady: (ready: boolean) => void; tracksRef: React.MutableRefObject; -} +}>; -function BroadcastPlayer({ +export function BroadcastPlayer({ mediaStream, screenStream, isVideoEnabled, @@ -41,23 +41,23 @@ function BroadcastPlayer({ }, [isScreenSharing, screenStream]); useEffect(() => { - tracksRef.current['mediaAudio'] = mediaStream?.getAudioTracks()[0]; - }, [mediaStream]); + tracksRef.current.mediaAudio = mediaStream?.getAudioTracks()[0]; + }, [mediaStream, tracksRef]); useEffect(() => { - tracksRef.current['screenAudio'] = screenStream?.getAudioTracks()[0]; - }, [screenStream]); + tracksRef.current.screenAudio = screenStream?.getAudioTracks()[0]; + }, [screenStream, tracksRef]); // 미디어스트림 캔버스에 넣기 useEffect(() => { const canvas = canvasRef.current; - if (!canvas) return; + if (!canvas) return undefined; - canvas.width = RESOLUTION_OPTIONS['high'].width; - canvas.height = RESOLUTION_OPTIONS['high'].height; + canvas.width = RESOLUTION_OPTIONS.high.width; + canvas.height = RESOLUTION_OPTIONS.high.height; const context = canvas.getContext('2d'); - if (!context) return; + if (!context) return undefined; context.imageSmoothingEnabled = true; context.imageSmoothingQuality = 'high'; @@ -70,21 +70,21 @@ function BroadcastPlayer({ const screenRatio = screenVideo.videoWidth / screenVideo.videoHeight; const canvasRatio = canvas.width / canvas.height; - const draw = { width: canvas.width, height: canvas.height, x: 0, y: 0 }; + const drawInfo = { width: canvas.width, height: canvas.height, x: 0, y: 0 }; if (screenRatio > canvasRatio) { // 화면이 더 넓은 경우 - draw.height = canvas.width / screenRatio; - draw.y = (canvas.height - draw.height) / 2; + drawInfo.height = canvas.width / screenRatio; + drawInfo.y = (canvas.height - drawInfo.height) / 2; } else { // 화면이 더 좁은 경우 - draw.width = canvas.height * screenRatio; - draw.x = (canvas.width - draw.width) / 2; + drawInfo.width = canvas.height * screenRatio; + drawInfo.x = (canvas.width - drawInfo.width) / 2; } context.fillStyle = '#000000'; context.fillRect(0, 0, canvas.width, canvas.height); - context.drawImage(screenVideo, draw.x, draw.y, draw.width, draw.height); + context.drawImage(screenVideo, drawInfo.x, drawInfo.y, drawInfo.width, drawInfo.height); if (isVideoEnabled && videoRef.current) { const pipWidth = canvas.width / 4; @@ -99,7 +99,8 @@ function BroadcastPlayer({ const startDrawing = async () => { draw(); - tracksRef.current['video'] = canvas.captureStream(30).getVideoTracks()[0]; + const [captureVideo] = canvas.captureStream(30).getVideoTracks(); + tracksRef.current.video = captureVideo; videoRef.current?.play(); screenShareRef.current?.play(); if (!isStreamReady) setIsStreamReady(true); @@ -114,7 +115,7 @@ function BroadcastPlayer({ cancelAnimationFrame(animationFrameRef.current); } }; - }, [isVideoEnabled, isScreenSharing, mediaStream, screenStream, isStreamReady]); + }, [isVideoEnabled, isScreenSharing, mediaStream, screenStream, isStreamReady, setIsStreamReady, tracksRef]); return (
@@ -134,8 +135,8 @@ function BroadcastPlayer({ /> ); } - -export default BroadcastPlayer; diff --git a/apps/client/src/features/broadcasting/ui/BroadcastPlayer/index.ts b/apps/client/src/features/broadcasting/ui/BroadcastPlayer/index.ts new file mode 100644 index 00000000..8385789a --- /dev/null +++ b/apps/client/src/features/broadcasting/ui/BroadcastPlayer/index.ts @@ -0,0 +1 @@ +export { BroadcastPlayer } from './BroadcastPlayer'; diff --git a/apps/client/src/features/broadcasting/ui/BroadcastPlayer/resolutionOptions.ts b/apps/client/src/features/broadcasting/ui/BroadcastPlayer/resolutionOptions.ts new file mode 100644 index 00000000..37397126 --- /dev/null +++ b/apps/client/src/features/broadcasting/ui/BroadcastPlayer/resolutionOptions.ts @@ -0,0 +1,5 @@ +export const RESOLUTION_OPTIONS = { + high: { width: 1920, height: 1080 }, + medium: { width: 1280, height: 720 }, + low: { width: 854, height: 480 }, +}; diff --git a/apps/client/src/features/broadcasting/ui/BroadcastTitle.tsx b/apps/client/src/features/broadcasting/ui/BroadcastTitle.tsx new file mode 100644 index 00000000..581cecb3 --- /dev/null +++ b/apps/client/src/features/broadcasting/ui/BroadcastTitle.tsx @@ -0,0 +1,73 @@ +import { useState } from 'react'; +import { useForm, SubmitHandler } from 'react-hook-form'; +import { Button } from '@/shared/ui/shadcn/button'; +import { axiosInstance } from '@/shared/api'; + +type Inputs = { + title: string; +}; + +type BroadcastTitleProps = Readonly<{ + currentTitle: string; + onTitleChange: (newTitle: string) => void; +}>; + +export function BroadcastTitle({ currentTitle, onTitleChange }: BroadcastTitleProps) { + const { + register, + handleSubmit, + formState: { errors }, + } = useForm(); + + const [isEditing, setIsEditing] = useState(false); + + const handleEditTitle = () => { + setIsEditing(true); + }; + + const onSubmit: SubmitHandler = data => { + axiosInstance.patch('/v1/broadcasts/title', { title: data.title }).then(response => { + if (!response.data.success) { + alert('제목 변경에 실패했습니다!'); + } else { + onTitleChange(data.title); + } + }); + setIsEditing(false); + }; + + if (isEditing) { + return ( +
+
+
+ + {(errors.title?.type === 'required' || errors.title?.type === 'maxLength') && ( +

+ {errors.title?.type === 'required' + ? '방송 제목을 입력해주세요' + : '방송 제목은 50자 이하로 입력해주세요'} +

+ )} +
+ +
+
+ ); + } + + return ( +
+
{currentTitle}
+ +
+ ); +} diff --git a/apps/client/src/pages/Broadcast/RecordButton.tsx b/apps/client/src/features/broadcasting/ui/RecordButton.tsx similarity index 87% rename from apps/client/src/pages/Broadcast/RecordButton.tsx rename to apps/client/src/features/broadcasting/ui/RecordButton.tsx index 1504bd89..baf541a4 100644 --- a/apps/client/src/pages/Broadcast/RecordButton.tsx +++ b/apps/client/src/features/broadcasting/ui/RecordButton.tsx @@ -1,20 +1,20 @@ -import Modal from '@/components/Modal'; -import { Button } from '@/components/ui/button'; import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { useForm } from 'react-hook-form'; import { Socket } from 'socket.io-client'; +import { Button } from '@/shared/ui/shadcn/button'; +import { Modal } from '@/shared/ui'; -interface FormInput { +type FormInput = { title: string; -} +}; -interface RecordButtonProps { +type RecordButtonProps = Readonly<{ socket: Socket | null; roomId: string; -} +}>; -function RecordButton({ socket, roomId }: RecordButtonProps) { +export function RecordButton({ socket, roomId }: RecordButtonProps) { const [isRecording, setIsRecording] = useState(false); const [isEditing, setIsEditing] = useState(false); @@ -28,14 +28,14 @@ function RecordButton({ socket, roomId }: RecordButtonProps) { const handleStartRecording = () => { if (!socket?.connected || !roomId) return; - socket.emit('startRecord', { roomId: roomId }, (response: { success: boolean }) => { + socket.emit('startRecord', { roomId }, (response: { success: boolean }) => { if (response.success) setIsRecording(true); }); }; const handleStopRecording = (data: FormInput) => { if (!socket?.connected || !roomId) return; - socket.emit('stopRecord', { roomId: roomId, title: data.title }, (response: { success: boolean }) => { + socket.emit('stopRecord', { roomId, title: data.title }, (response: { success: boolean }) => { if (response.success) { setIsEditing(false); setIsRecording(false); @@ -98,5 +98,3 @@ function RecordButton({ socket, roomId }: RecordButtonProps) { ); } - -export default RecordButton; diff --git a/apps/client/src/features/broadcasting/ui/index.ts b/apps/client/src/features/broadcasting/ui/index.ts new file mode 100644 index 00000000..5463a5fd --- /dev/null +++ b/apps/client/src/features/broadcasting/ui/index.ts @@ -0,0 +1,3 @@ +export { BroadcastPlayer } from './BroadcastPlayer'; +export { BroadcastTitle } from './BroadcastTitle'; +export { RecordButton } from './RecordButton'; diff --git a/apps/client/src/features/chatting/index.ts b/apps/client/src/features/chatting/index.ts new file mode 100644 index 00000000..f0836aa9 --- /dev/null +++ b/apps/client/src/features/chatting/index.ts @@ -0,0 +1 @@ +export { ChatContainer } from './ui'; diff --git a/apps/client/src/components/ChatContainer/index.tsx b/apps/client/src/features/chatting/ui/ChatContainer.tsx similarity index 68% rename from apps/client/src/components/ChatContainer/index.tsx rename to apps/client/src/features/chatting/ui/ChatContainer.tsx index c636e2b1..1578c906 100644 --- a/apps/client/src/components/ChatContainer/index.tsx +++ b/apps/client/src/features/chatting/ui/ChatContainer.tsx @@ -1,25 +1,25 @@ import { useState, useEffect, useRef, useContext } from 'react'; -import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@components/ui/card'; -import { Input } from '@components/ui/input'; -import { SmileIcon } from '@/components/Icons'; -import { useSocket } from '@hooks/useSocket'; -import ErrorCharacter from '@components/ErrorCharacter'; -import { AuthContext } from '@/contexts/AuthContext'; import { createPortal } from 'react-dom'; -import ChatEndModal from './ChatEndModal'; - -interface Chat { - camperId: string; - name: string; - message: string; -} +import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/shared/ui/shadcn/card'; +import { Input } from '@/shared/ui/shadcn/input'; +import { useSocket } from '@/shared/lib/useSocket'; +import { ErrorCharacter } from '@/shared/ui/character'; +import { AuthContext } from '@/features/auth/model/AuthContext'; +import { SmileIcon } from '@/shared/ui/Icons'; +import { ChatEndModal } from './ChatEndModal'; +import { Chat } from './types'; const chatServerUrl = import.meta.env.VITE_CHAT_SERVER_URL; -const ChatContainer = ({ roomId, isProducer }: { roomId: string; isProducer: boolean }) => { +type ChatContainerProps = Readonly<{ + roomId: string; + isProducer: boolean; +}>; + +export function ChatContainer({ roomId, isProducer }: ChatContainerProps) { const { isLoggedIn } = useContext(AuthContext); // 채팅 방 입장 - const [isJoinedRoom, setIsJoinedRoom] = useState(false); + const isJoinedRoomRef = useRef(false); // 채팅 전송 const { socket, isConnected, socketError } = useSocket(chatServerUrl); const [chattings, setChattings] = useState([]); @@ -35,57 +35,65 @@ const ChatContainer = ({ roomId, isProducer }: { roomId: string; isProducer: boo // 채팅 종료 const [showModal, setShowModal] = useState(false); - const setUpRoom = async (isProducer: boolean) => { - if (isProducer) { - socket?.emit('createRoom', { roomId: roomId }); - } else { - // 채팅방 입장 - socket?.emit('joinRoom', { roomId: roomId }, () => {}); - // 채팅방 종료 이벤트 - socket?.on('chatClosed', () => { - setShowModal(true); - }); - } - setIsJoinedRoom(true); - }; - const handleInputChange = (e: React.ChangeEvent) => { setInputValue(e.target.value); }; - const hanldeKeyDownEnter = (e: React.KeyboardEvent) => { - if (isComposing) return; - if (e.key === 'Enter') { - handleSendChat(); - } - }; - const handleSendChat = () => { if (inputValue.trim() && socket) { - socket.emit('chat', { roomId: roomId, message: inputValue }); + socket.emit('chat', { roomId, message: inputValue }); } setInputValue(''); }; - const handleReceiveChat = (response: Chat) => { - const { camperId, name, message } = response; - setChattings(prev => [...prev, { camperId, name, message }]); + const hanldeKeyDownEnter = (e: React.KeyboardEvent) => { + if (isComposing) return; + if (e.key === 'Enter') { + handleSendChat(); + } }; const handleClickEmoticon = () => { alert('구현 예정'); }; + // 채팅방 입장 + useEffect(() => { + if (!isConnected || !socket || !roomId || isJoinedRoomRef.current) return; + + const setUpRoom = async () => { + if (isProducer) { + socket?.emit('createRoom', { roomId }); + } else { + // 채팅방 입장 + socket?.emit('joinRoom', { roomId }, () => {}); + // 채팅방 종료 이벤트 + } + isJoinedRoomRef.current = true; + }; + setUpRoom(); + }, [isConnected, socket, roomId, isProducer]); + // 채팅 이벤트 등록/해제 useEffect(() => { - if (!isConnected || !socket || !roomId || isJoinedRoom) return; - setUpRoom(isProducer); + if (!socket || !isConnected) return () => {}; + + const handleReceiveChat = (response: Chat) => { + const { camperId, name, message } = response; + setChattings(prev => [...prev, { chatId: `${Date.now()}-${camperId}`, camperId, name, message }]); + }; + + const handleChatClosed = () => { + setShowModal(true); + }; socket?.on('chat', handleReceiveChat); + socket?.on('chatClosed', handleChatClosed); return () => { socket?.off('chat', handleReceiveChat); + socket?.off('chatClosed'); }; - }, [isConnected, roomId, socket]); + }, [socket, isConnected]); // 자동 스크롤 useEffect(() => { @@ -106,8 +114,8 @@ const ChatContainer = ({ roomId, isProducer }: { roomId: string; isProducer: boo <>
- {chattings.map((chat, index) => ( -
+ {chattings.map((chat: Chat) => ( +
{chat.camperId} {chat.message}
@@ -128,6 +136,7 @@ const ChatContainer = ({ roomId, isProducer }: { roomId: string; isProducer: boo disabled={!isLoggedIn} />
); } - -export default ChatEndModal; diff --git a/apps/client/src/features/chatting/ui/index.ts b/apps/client/src/features/chatting/ui/index.ts new file mode 100644 index 00000000..16361bf5 --- /dev/null +++ b/apps/client/src/features/chatting/ui/index.ts @@ -0,0 +1 @@ +export { ChatContainer } from './ChatContainer'; diff --git a/apps/client/src/features/chatting/ui/types.ts b/apps/client/src/features/chatting/ui/types.ts new file mode 100644 index 00000000..8e4f1587 --- /dev/null +++ b/apps/client/src/features/chatting/ui/types.ts @@ -0,0 +1,6 @@ +export type Chat = { + chatId?: string; + camperId: string; + name: string; + message: string; +}; diff --git a/apps/client/src/features/editProfile/index.ts b/apps/client/src/features/editProfile/index.ts new file mode 100644 index 00000000..c04a16a5 --- /dev/null +++ b/apps/client/src/features/editProfile/index.ts @@ -0,0 +1 @@ +export { EditUserInfo } from './ui'; diff --git a/apps/client/src/features/editProfile/ui/EditUserInfo.tsx b/apps/client/src/features/editProfile/ui/EditUserInfo.tsx new file mode 100644 index 00000000..6cffb6ca --- /dev/null +++ b/apps/client/src/features/editProfile/ui/EditUserInfo.tsx @@ -0,0 +1,195 @@ +import { useForm } from 'react-hook-form'; +import { useState } from 'react'; +import { Avatar, AvatarFallback, AvatarImage } from '@/shared/ui/shadcn/avatar'; +import { UserData } from '@/pages/Profile'; +import { Field } from '@/shared/types/sharedTypes'; +import { Button } from '@/shared/ui/shadcn/button'; +import { axiosInstance } from '@/shared/api'; +import { useToast } from '@/shared/lib'; + +type EditUserInfoProps = Readonly<{ + userData: UserData | undefined; + toggleEditing: () => void; +}>; + +export type FormInput = { + camperId: string | undefined; + name: string | undefined; + field: Field | undefined; + email: string | undefined; + github: string | undefined; + blog: string | undefined; + linkedIn: string | undefined; +}; + +export function EditUserInfo({ userData, toggleEditing }: EditUserInfoProps) { + const [selectedField, setSelectedField] = useState(userData?.field); + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + defaultValues: { + camperId: userData?.camperId, + name: userData?.name, + field: userData?.field, + email: userData?.contacts.email, + github: userData?.contacts.github, + blog: userData?.contacts.blog, + linkedIn: userData?.contacts.linkedIn, + }, + }); + const { toast } = useToast(); + + const handleSelectField = (field: Field) => { + setSelectedField(selectedField === field ? '' : field); + }; + + const handlePatchUserInfo = (data: FormInput) => { + const formData = { + name: data.name, + camperId: data.camperId, + field: selectedField, + contacts: { + email: data.email ? data.email : '', + github: data.github ? data.github : '', + blog: data.blog ? data.blog : '', + linkedin: data.linkedIn ? data.linkedIn : '', + }, + }; + + if (!formData.field) return; + + axiosInstance.patch('/v1/members/info', formData).then(response => { + if (response.data.success) { + toggleEditing(); + } else { + toast({ variant: 'destructive', title: '유저 정보 수정 실패' }); + } + }); + }; + + return ( +
+ + + MY + +
+ {/* ID */} +
+ +

+ {errors.camperId?.message} +

+
+ {/* 이름 */} +
+ +

{errors.name?.message}

+
+ {/* email */} +
+ +
+ {/* github */} +
+ +
+ {/* blog */} +
+ +
+ {/* linkedIn */} +
+ +
+ {/* 분야 */} +
+ + 분야 + +
+ Select your field + + + +

+ {selectedField === '' && '분야를 입력해주세요'} +

+
+
+
+ +
+
+
+ ); +} diff --git a/apps/client/src/features/editProfile/ui/index.ts b/apps/client/src/features/editProfile/ui/index.ts new file mode 100644 index 00000000..0422c101 --- /dev/null +++ b/apps/client/src/features/editProfile/ui/index.ts @@ -0,0 +1 @@ +export { EditUserInfo } from './EditUserInfo'; diff --git a/apps/client/src/features/watching/index.ts b/apps/client/src/features/watching/index.ts new file mode 100644 index 00000000..3ba42577 --- /dev/null +++ b/apps/client/src/features/watching/index.ts @@ -0,0 +1,2 @@ +export { LiveCamperInfo, LivePlayer } from './ui'; +export { useConsume } from './model'; diff --git a/apps/client/src/features/watching/model/consumeHelpers.ts b/apps/client/src/features/watching/model/consumeHelpers.ts new file mode 100644 index 00000000..60758b71 --- /dev/null +++ b/apps/client/src/features/watching/model/consumeHelpers.ts @@ -0,0 +1,51 @@ +import { MediaKind } from 'mediasoup-client/lib/RtpParameters'; +import { Socket } from 'socket.io-client'; +import { Transport } from 'mediasoup-client/lib/types'; +import { TransportInfo } from '@/shared/types/mediasoupTypes'; + +type CreateConsumer = { + consumerId: string; + producerId: string; + kind: MediaKind; + rtpParameters: any; +}; + +type CreateConsumerResponse = { + consumers: CreateConsumer[]; +}; + +export const createConsumer = async ( + socket: Socket, + roomId: string, + transport: Transport, + transportInfo: TransportInfo, +) => { + const mediaStream = new MediaStream(); + socket.emit( + 'createConsumer', + { + roomId, + transportId: transportInfo.transportId, + }, + async ({ consumers }: CreateConsumerResponse) => { + await Promise.all( + consumers.map(async consumerData => { + const consumer = await transport.consume({ + id: consumerData.consumerId, + producerId: consumerData.producerId, + rtpParameters: consumerData.rtpParameters, + kind: consumerData.kind, + }); + + if (consumer.track.kind === 'video') { + consumer.track.enabled = true; + } + mediaStream.addTrack(consumer.track); + consumer.resume(); + }), + ); + }, + ); + + return mediaStream; +}; diff --git a/apps/client/src/features/watching/model/index.ts b/apps/client/src/features/watching/model/index.ts new file mode 100644 index 00000000..e5bfd955 --- /dev/null +++ b/apps/client/src/features/watching/model/index.ts @@ -0,0 +1 @@ +export { useConsume } from './useConsume'; diff --git a/apps/client/src/features/watching/model/useConsume.ts b/apps/client/src/features/watching/model/useConsume.ts new file mode 100644 index 00000000..0a0607db --- /dev/null +++ b/apps/client/src/features/watching/model/useConsume.ts @@ -0,0 +1,66 @@ +import { Transport } from 'mediasoup-client/lib/types'; +import { useEffect, useRef, useState } from 'react'; +import { Socket } from 'socket.io-client'; +import { connectTransport, createDevice, getRtpCapabilities } from '@/shared/lib'; +import { createConsumer } from './consumeHelpers'; + +type UseConsumerProps = { + socket: Socket | null; + roomId: string | undefined; +}; + +export const useConsume = ({ socket, roomId }: UseConsumerProps) => { + const transportRef = useRef(null); + const transportIdRef = useRef(undefined); + const mediastreamRef = useRef(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!socket || !roomId) return undefined; + + const initializeConsumer = async () => { + try { + const rtpCapabilities = await getRtpCapabilities(socket, roomId); + if (!rtpCapabilities) { + setError(new Error('rtpCapabilities가 없습니다.')); + return; + } + + const device = await createDevice(rtpCapabilities); + if (!device) { + setError(new Error('device가 없습니다.')); + return; + } + + const { transport: newTransport, transportInfo } = await connectTransport(socket, device, roomId, false); + if (!newTransport || !transportInfo) { + setError(new Error('transport 연결에 문제가 발생했습니다.')); + return; + } + transportRef.current = newTransport; + transportIdRef.current = transportInfo.transportId; + + const newMediastream = await createConsumer(socket, roomId, newTransport, transportInfo); + mediastreamRef.current = newMediastream; + } finally { + setIsLoading(false); + } + }; + + initializeConsumer(); + + return () => { + transportRef.current?.close(); + mediastreamRef.current?.getTracks().map(track => track.stop()); + }; + }, [socket, roomId]); + + return { + transport: transportRef.current, + transportId: transportIdRef.current, + mediastream: mediastreamRef.current, + error, + isLoading, + }; +}; diff --git a/apps/client/src/features/watching/ui/LiveCamperInfo/LiveCamperInfo.tsx b/apps/client/src/features/watching/ui/LiveCamperInfo/LiveCamperInfo.tsx new file mode 100644 index 00000000..8242e001 --- /dev/null +++ b/apps/client/src/features/watching/ui/LiveCamperInfo/LiveCamperInfo.tsx @@ -0,0 +1,88 @@ +import { Avatar, AvatarFallback, AvatarImage } from '@/shared/ui/shadcn/avatar'; +import { Badge } from '@/shared/ui/shadcn/badge'; +import { useAPI } from '@/shared/api'; +import { + LoadingCharacter, + ErrorCharacter, + IconButton, + MailIcon, + GithubIcon, + BlogIcon, + LinkedInIcon, +} from '@/shared/ui'; +import { LiveInfo } from './types'; + +export function LiveCamperInfo({ liveId }: Readonly<{ liveId: string }>) { + const { data, isLoading, error } = useAPI(`v1/broadcasts/${liveId}/info`); + + if (error || !data) { + return ( +
+ +
+ ); + } + + if (isLoading) { + return ; + } + + return ( +
+
+ {/* 제목 */} +

{data.title}

+ +
+ {/* 프로필 사진 */} + + + {data.camperId} + + +
+
+ {data.camperId} + + {data.field ? data.field : '???'} + +
+ {data.viewers}명 시청 중 +
+
+
+ + {/* 우측 상단 아이콘들 - 2x2 그리드 */} +
+ window.open(`mailto:${data.contacts.email}`)} + > + {' '} + + + window.open(data.contacts.blog, '_blank')} + > + + + + window.open(data.contacts.github, '_blank')}> + + + + window.open(data.contacts.linkedin, '_blank')}> + + +
+
+ ); +} diff --git a/apps/client/src/features/watching/ui/LiveCamperInfo/index.ts b/apps/client/src/features/watching/ui/LiveCamperInfo/index.ts new file mode 100644 index 00000000..20af8fdb --- /dev/null +++ b/apps/client/src/features/watching/ui/LiveCamperInfo/index.ts @@ -0,0 +1 @@ +export { LiveCamperInfo } from './LiveCamperInfo'; diff --git a/apps/client/src/types/liveTypes.ts b/apps/client/src/features/watching/ui/LiveCamperInfo/types.ts similarity index 63% rename from apps/client/src/types/liveTypes.ts rename to apps/client/src/features/watching/ui/LiveCamperInfo/types.ts index 4ae44e92..60991915 100644 --- a/apps/client/src/types/liveTypes.ts +++ b/apps/client/src/features/watching/ui/LiveCamperInfo/types.ts @@ -1,17 +1,17 @@ -export interface ContactInfo { +import { Field } from '@/shared/types/sharedTypes'; + +export type ContactInfo = { github: string; linkedin: string; email: string; blog: string; -} +}; -export interface LiveInfo { +export type LiveInfo = { title: string; camperId: string; viewers: number; field: Field; profileImage: string; contacts: ContactInfo; -} - -export type Field = 'WEB' | 'AND' | 'IOS' | ''; +}; diff --git a/apps/client/src/features/watching/ui/LivePlayer/LivePlayer.tsx b/apps/client/src/features/watching/ui/LivePlayer/LivePlayer.tsx new file mode 100644 index 00000000..362e884a --- /dev/null +++ b/apps/client/src/features/watching/ui/LivePlayer/LivePlayer.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef, useState } from 'react'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/shadcn/select'; +import { PlayIcon, PauseIcon, VolumeOffIcon, VolumeOnIcon, ExpandIcon } from '@/shared/ui/Icons'; +import { ErrorCharacter } from '@/shared/ui'; +import { LivePlayerProps, VideoQuality } from './types'; + +export function LivePlayer({ mediaStream, socket, transportId, errors }: LivePlayerProps) { + const [isVideoEnabled, setIsVideoEnabled] = useState(true); + const [isAudioEnabled, setIsAudioEnabled] = useState(false); + const [videoQuality, setVideoQuality] = useState('720p'); + const videoRef = useRef(null); + + const { socketError, consumerError } = errors; + + useEffect(() => { + const videoElement = videoRef.current; + if (videoElement && mediaStream) { + videoElement.srcObject = mediaStream; + } + + return () => { + if (videoElement?.srcObject) { + videoElement.srcObject = null; + } + }; + }, [mediaStream]); + + const handlePlayPause = async () => { + if (mediaStream && videoRef.current) { + if (isVideoEnabled) { + videoRef.current.pause(); + setIsVideoEnabled(false); + } else { + videoRef.current.play(); + setIsVideoEnabled(true); + } + } + }; + + const handleMute = () => { + if (mediaStream) { + setIsAudioEnabled(prev => !prev); + } + }; + + const handleVideoQuality = (selectedVideoQuality: VideoQuality) => { + if (!socket) return; + + socket.emit('setVideoQuality', { transportId, quality: selectedVideoQuality }); + setVideoQuality(selectedVideoQuality); + }; + + const handleExpand = async () => { + await videoRef.current?.requestFullscreen?.(); + }; + + if (socketError || consumerError) { + return ( +
+ +
+ ); + } + + return ( +
+
+ ); +} diff --git a/apps/client/src/features/watching/ui/LivePlayer/index.ts b/apps/client/src/features/watching/ui/LivePlayer/index.ts new file mode 100644 index 00000000..be87dbad --- /dev/null +++ b/apps/client/src/features/watching/ui/LivePlayer/index.ts @@ -0,0 +1 @@ +export { LivePlayer } from './LivePlayer'; diff --git a/apps/client/src/features/watching/ui/LivePlayer/types.ts b/apps/client/src/features/watching/ui/LivePlayer/types.ts new file mode 100644 index 00000000..2dadb764 --- /dev/null +++ b/apps/client/src/features/watching/ui/LivePlayer/types.ts @@ -0,0 +1,15 @@ +import { Socket } from 'socket.io-client'; + +export type Errors = { + socketError: Error | null; + consumerError: Error | null; +}; + +export type LivePlayerProps = Readonly<{ + mediaStream: MediaStream | null; + socket: Socket | null; + transportId: string | undefined; + errors: Errors; +}>; + +export type VideoQuality = 'auto' | '480p' | '720p' | '1080p'; diff --git a/apps/client/src/features/watching/ui/index.ts b/apps/client/src/features/watching/ui/index.ts new file mode 100644 index 00000000..199d1712 --- /dev/null +++ b/apps/client/src/features/watching/ui/index.ts @@ -0,0 +1,2 @@ +export { LiveCamperInfo } from './LiveCamperInfo'; +export { LivePlayer } from './LivePlayer'; diff --git a/apps/client/src/hooks/useAPI.ts b/apps/client/src/hooks/useAPI.ts deleted file mode 100644 index 65e9f182..00000000 --- a/apps/client/src/hooks/useAPI.ts +++ /dev/null @@ -1,32 +0,0 @@ -import axiosInstance from '@services/axios'; -import { AxiosRequestConfig } from 'axios'; -import { useEffect, useState } from 'react'; - -interface APIQueryState { - data: T | null; - isLoading: boolean; - error: Error | null; -} - -export const useAPI = (apiInfo: AxiosRequestConfig): APIQueryState => { - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [data, setData] = useState(null); - - useEffect(() => { - const fetchData = async () => { - try { - const result = await axiosInstance.request(apiInfo); - setData(result.data.data); - } catch (err) { - setError(err instanceof Error ? err : new Error()); - } finally { - setIsLoading(false); - } - }; - - fetchData(); - }, []); - - return { data, isLoading, error }; -}; diff --git a/apps/client/src/hooks/useConsumer.ts b/apps/client/src/hooks/useConsumer.ts deleted file mode 100644 index 744530da..00000000 --- a/apps/client/src/hooks/useConsumer.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { Transport, Device, MediaKind } from 'mediasoup-client/lib/types'; -import { ConnectTransportResponse, TransportInfo } from '@/types/mediasoupTypes'; -import { Socket } from 'socket.io-client'; -import { checkDependencies } from '@utils/utils'; - -interface UseConsumerProps { - socket: Socket | null; - device: Device | null; - roomId: string | undefined; - transportInfo: TransportInfo | null; - isConnected: boolean; -} - -interface ConnectTransportParams { - socket: Socket; - transportInfo: TransportInfo; - device: Device; - roomId: string | undefined; -} - -interface CreateConsumerParams { - socket: Socket; - roomId: string; - transportInfo: TransportInfo; - transport: Transport | null; -} - -export interface CreateConsumer { - consumerId: string; - producerId: string; - kind: MediaKind; - rtpParameters: any; -} - -export interface CreateConsumerResponse { - consumers: CreateConsumer[]; -} - -export const useConsumer = ({ socket, device, roomId, transportInfo, isConnected }: UseConsumerProps) => { - const transport = useRef(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [mediastream, setMediastream] = useState(null); - - const connectTransport = async ({ socket, transportInfo, device, roomId }: ConnectTransportParams) => { - if (!socket || !transportInfo || !device || !roomId) { - const dependencyError = checkDependencies('connectTransport', { socket, transportInfo, device, roomId }); - setError(dependencyError); - return; - } - - setError(null); - - const newTransport = device.createRecvTransport({ - id: transportInfo.transportId, - iceParameters: transportInfo.iceParameters, - iceCandidates: transportInfo.iceCandidates, - dtlsParameters: transportInfo.dtlsParameters, - }); - - transport.current = newTransport; - - transport.current.on('connect', async ({ dtlsParameters }, callback) => { - const response = await new Promise((resolve, reject) => { - socket.emit( - 'connectTransport', - { - roomId, - dtlsParameters, - transportId: transportInfo.transportId, - }, - (response: ConnectTransportResponse) => { - if (response.connected) { - resolve(response); - } else { - reject(new Error('Transport connection failed')); - } - }, - ); - }); - callback(); - return response; - }); - }; - - const createConsumer = async ({ socket, roomId, transportInfo, transport }: CreateConsumerParams) => { - if (!transport || !socket) { - const dependencyError = checkDependencies('createConsumer', { socket, transport }); - setError(dependencyError); - return; - } - - setError(null); - - socket.emit( - 'createConsumer', - { - roomId, - transportId: transportInfo.transportId, - }, - async ({ consumers }: CreateConsumerResponse) => { - const newMediastream = new MediaStream(); - for (const consumerData of consumers) { - const consumer = await transport.consume({ - id: consumerData.consumerId, - producerId: consumerData.producerId, - rtpParameters: consumerData.rtpParameters, - kind: consumerData.kind, - }); - - if (consumer.track.kind === 'video') { - consumer.track.enabled = true; - } - newMediastream.addTrack(consumer.track); - consumer.resume(); - } - - setMediastream(newMediastream); - }, - ); - }; - - useEffect(() => { - if (!socket || !isConnected || !roomId || !transportInfo || !device) { - return; - } - - connectTransport({ - socket, - transportInfo, - device, - roomId, - }) - .then(() => - createConsumer({ - socket, - roomId, - transportInfo, - transport: transport.current, - }), - ) - .then(() => setIsLoading(false)) - .catch(err => setError(err instanceof Error ? err : new Error('Consumer initialization failed'))); - - return () => { - if (transport.current) { - transport.current.close(); - transport.current = null; - } - setMediastream(null); - }; - }, [socket, isConnected, transportInfo, device, roomId]); - - return { - transport: transport.current, - mediastream, - error, - isLoading, - }; -}; diff --git a/apps/client/src/hooks/useProducer.ts b/apps/client/src/hooks/useProducer.ts deleted file mode 100644 index 9c72d3bc..00000000 --- a/apps/client/src/hooks/useProducer.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { Transport, Device, Producer } from 'mediasoup-client/lib/types'; -import { ConnectTransportResponse, TransportInfo } from '@/types/mediasoupTypes'; -import { Socket } from 'socket.io-client'; -import { checkDependencies } from '@/utils/utils'; -import { ENCODING_OPTIONS } from '@/constants/videoOptions'; - -interface UseProducerProps { - socket: Socket | null; - // tracks: Tracks; - // isStreamReady: boolean; - mediaStream: MediaStream | null; - isMediaStreamReady: boolean; - roomId: string; - device: Device | null; - transportInfo: TransportInfo | null; -} - -interface UseProducerReturn { - transport: Transport | null; - error: Error | null; - producerId: string; - producers: Map; -} - -export const useProducer = ({ - socket, - mediaStream, - isMediaStreamReady, - roomId, - device, - transportInfo, -}: UseProducerProps): UseProducerReturn => { - const transport = useRef(null); - const [error, setError] = useState(null); - const [producerId, setProducerId] = useState(''); - const [producers, setProducers] = useState>(new Map()); - - const createTransport = async (socket: Socket, device: Device, roomId: string, transportInfo: TransportInfo) => { - if (!socket || !device || !roomId || !transportInfo) { - const dependencyError = checkDependencies('createTransport', { socket, device, roomId, transportInfo }); - setError(dependencyError); - return; - } - - setError(null); - - const newTransport = device.createSendTransport({ - id: transportInfo.transportId, - iceParameters: transportInfo.iceParameters, - iceCandidates: transportInfo.iceCandidates, - dtlsParameters: transportInfo.dtlsParameters, - }); - - transport.current = newTransport; - - transport.current.on('connect', async (parameters, callback) => { - socket.emit( - 'connectTransport', - { - roomId, - dtlsParameters: parameters.dtlsParameters, - transportId: transportInfo.transportId, - }, - (response: ConnectTransportResponse) => { - if (response.connected) { - callback(); - } - }, - ); - }); - }; - - const createProducer = async (socket: Socket, transportInfo: TransportInfo) => { - if (!transport.current || !socket || !mediaStream) { - const dependencyError = checkDependencies('createProducer', { - socket, - mediaStream, - transport: transport.current, - }); - setError(dependencyError); - return; - } - - setError(null); - - transport.current!.on('produce', (parameters, callback) => { - socket.emit( - 'createProducer', - { - roomId, - transportId: transportInfo.transportId, - kind: parameters.kind, - rtpParameters: parameters.rtpParameters, - }, - (response: { producerId: string }) => { - callback({ id: response.producerId }); - setProducerId(response.producerId); - }, - ); - }); - - mediaStream.getTracks().forEach(track => { - const producerConfig: Record = { - track: track, - stopTracks: false, - }; - - if (track.kind === 'video') { - producerConfig['encodings'] = ENCODING_OPTIONS; - producerConfig['codecOptions'] = { - videoGoogleStartBitrate: 1000, - }; - } - - transport.current!.produce(producerConfig).then(producer => { - setProducers(prev => new Map(prev).set(track.kind, producer)); - }); - }); - }; - - useEffect(() => { - if (!socket || !device || !roomId || !mediaStream || !isMediaStreamReady || !transportInfo) { - return; - } - - createTransport(socket, device, roomId, transportInfo) - .then(() => createProducer(socket, transportInfo)) - .catch(err => setError(err instanceof Error ? err : new Error('Producer initialization failed'))); - - return () => { - if (transport.current) { - transport.current.close(); - transport.current = null; - } - }; - }, [socket, device, roomId, transportInfo, isMediaStreamReady]); - - return { - transport: transport.current, - error, - producerId, - producers, - }; -}; diff --git a/apps/client/src/hooks/useRoom.ts b/apps/client/src/hooks/useRoom.ts deleted file mode 100644 index 431c386e..00000000 --- a/apps/client/src/hooks/useRoom.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useEffect, useState } from 'react'; -import { Socket } from 'socket.io-client'; - -export const useRoom = (socket: Socket | null, isConnected: boolean, isMediaStreamReady: boolean) => { - const [roomId, setRoomId] = useState(''); - const [roomError, setRoomError] = useState(null); - - const getRooomId = async () => { - if (!socket) { - setRoomError(new Error('getRoomId Error: socket이 존재하지 않습니다.')); - return; - } - - setRoomError(null); - socket.emit('createRoom', (response: { roomId: string }) => { - setRoomId(response.roomId); - }); - }; - - useEffect(() => { - if (!isMediaStreamReady) return; - getRooomId(); - }, [isConnected, isMediaStreamReady]); - - return { - roomId, - roomError, - }; -}; diff --git a/apps/client/src/hooks/useTransport.ts b/apps/client/src/hooks/useTransport.ts deleted file mode 100644 index 46954c9c..00000000 --- a/apps/client/src/hooks/useTransport.ts +++ /dev/null @@ -1,93 +0,0 @@ -import * as mediasoupClient from 'mediasoup-client'; -import { useEffect, useState } from 'react'; -import { RtpCapabilities } from 'mediasoup-client/lib/RtpParameters'; -import { Device } from 'mediasoup-client/lib/types'; -import { checkDependencies } from '@/utils/utils'; -import { Socket } from 'socket.io-client'; -import { TransportInfo } from '@/types/mediasoupTypes'; - -interface UseTransportProps { - socket: Socket | null; - roomId: string | undefined; - isProducer: boolean; -} - -export const useTransport = ({ socket, roomId, isProducer = false }: UseTransportProps) => { - const [transportInfo, setTransportInfo] = useState(null); - const [transportError, setTransportError] = useState(null); - const [device, setDevice] = useState(null); - - const getRtpCapabilities = async (roomId: string) => { - if (!socket || !roomId) { - const dependencyError = checkDependencies('getRtpCapabilities', { socket, roomId }); - setTransportError(dependencyError); - return; - } - - const rtpCapabilities: RtpCapabilities = await new Promise((resolve, reject) => { - socket.emit('getRtpCapabilities', { roomId }, (response: { rtpCapabilities: RtpCapabilities }) => { - if (response.rtpCapabilities) { - resolve(response.rtpCapabilities); - } else { - reject(new Error('getRtpCapabilities Error: RTP Capabilities를 받아오지 못했습니다.')); - } - }); - }); - return rtpCapabilities; - }; - - const createDevice = async (rtpCapabilities: RtpCapabilities) => { - if (!rtpCapabilities) { - setTransportError(new Error('createDevice Error: RTP Capabilities가 없습니다.')); - return; - } - - const newDevice = new mediasoupClient.Device(); - await newDevice.load({ - routerRtpCapabilities: rtpCapabilities, - }); - return newDevice; - }; - - const createTransport = async (device: Device, roomId: string) => { - if (!socket || !device || !roomId) { - const dependencyError = checkDependencies('createTransport', { socket, device, roomId }); - setTransportError(dependencyError); - return; - } - - socket.emit('createTransport', { roomId: roomId, isProducer }, (response: TransportInfo) => { - setTransportInfo(response); - }); - }; - - const initializeTransport = async (roomId: string) => { - if (!socket || !roomId) return; - - const rtpCapabilities = await getRtpCapabilities(roomId); - if (!rtpCapabilities) return; - - const newDevice = await createDevice(rtpCapabilities); - if (!newDevice) return; - setDevice(newDevice); - - await createTransport(newDevice, roomId); - if (!transportInfo) return; - }; - - useEffect(() => { - if (!socket || !roomId) { - return; - } - - initializeTransport(roomId); - - return () => {}; - }, [socket, roomId]); - - return { - transportInfo, - device, - transportError, - }; -}; diff --git a/apps/client/src/index.css b/apps/client/src/index.css index edd9eefb..0626b23c 100644 --- a/apps/client/src/index.css +++ b/apps/client/src/index.css @@ -4,7 +4,7 @@ @font-face { font-family: 'Pretendard'; - src: url('./assets/fonts/PretendardVariable.woff2') format('woff2'); + src: url('/fonts/PretendardVariable.woff2') format('woff2'); font-weight: 100 900; font-style: normal; } diff --git a/apps/client/src/main.tsx b/apps/client/src/main.tsx index 21390861..07116fa8 100644 --- a/apps/client/src/main.tsx +++ b/apps/client/src/main.tsx @@ -1,6 +1,6 @@ import { createRoot } from 'react-dom/client'; import './index.css'; import { RouterProvider } from 'react-router-dom'; -import router from './Router'; +import { router } from '@/app/routes'; createRoot(document.getElementById('root')!).render(); diff --git a/apps/client/src/pages/Auth/index.tsx b/apps/client/src/pages/Auth/AuthPage.tsx similarity index 56% rename from apps/client/src/pages/Auth/index.tsx rename to apps/client/src/pages/Auth/AuthPage.tsx index 59786df4..0f34193f 100644 --- a/apps/client/src/pages/Auth/index.tsx +++ b/apps/client/src/pages/Auth/AuthPage.tsx @@ -1,32 +1,28 @@ -import ErrorCharacter from '@components/ErrorCharacter'; -import { useAuth } from '@hooks/useAuth'; import { useEffect, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; +import { ErrorCharacter } from '@/shared/ui'; +import { useAuth } from '@/features/auth'; -function Auth() { +export function AuthPage() { const [searchParams] = useSearchParams(); const { setLogIn } = useAuth(); const navigate = useNavigate(); const [error, setError] = useState(null); useEffect(() => { - try { - const accessToken = searchParams.get('accessToken'); - const isNecessaryInfo = searchParams.get('isNecessaryInfo'); - if (!accessToken) { - throw new Error('액세스 토큰을 받지 못했습니다.'); - } - - setLogIn(accessToken); - if (isNecessaryInfo === 'true') navigate('/', { replace: true }); - else navigate('/profile', { replace: true }); - } catch (err) { - setError(err instanceof Error ? err : new Error('로그인 처리 중 오류')); + const accessToken = searchParams.get('accessToken'); + const isNecessaryInfo = searchParams.get('isNecessaryInfo'); + if (!accessToken) { + setError(new Error('액세스 토큰을 받지 못했습니다.')); setTimeout(() => { navigate('/', { replace: true }); }, 3000); + return; } - }, []); + + setLogIn(accessToken); + navigate(isNecessaryInfo === 'true' ? '/' : '/profile', { replace: true }); + }, [navigate, searchParams, setLogIn]); return (
@@ -41,5 +37,3 @@ function Auth() {
); } - -export default Auth; diff --git a/apps/client/src/pages/Auth/index.ts b/apps/client/src/pages/Auth/index.ts new file mode 100644 index 00000000..8a909a2b --- /dev/null +++ b/apps/client/src/pages/Auth/index.ts @@ -0,0 +1 @@ +export { AuthPage } from './AuthPage'; diff --git a/apps/client/src/pages/Broadcast/BroadcastPage.tsx b/apps/client/src/pages/Broadcast/BroadcastPage.tsx new file mode 100644 index 00000000..78eae4e0 --- /dev/null +++ b/apps/client/src/pages/Broadcast/BroadcastPage.tsx @@ -0,0 +1,208 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { + ErrorCharacter, + MicrophoneOffIcon, + MicrophoneOnIcon, + VideoOffIcon, + VideoOnIcon, + ScreenShareIcon, + ScreenShareOffIcon, +} from '@/shared/ui'; +import { + useMedia, + useScreenShare, + Tracks, + BroadcastPlayer, + BroadcastTitle, + RecordButton, +} from '@/features/broadcasting'; +import { ChatContainer } from '@/features/chatting'; +import { useSocket, useTheme } from '@/shared/lib'; +import { Button } from '@/shared/ui/shadcn/button'; +import { axiosInstance } from '@/shared/api'; +import { useProduce } from '@/features/broadcasting/model'; + +const mediaServerUrl = import.meta.env.VITE_MEDIASERVER_URL; + +export function BroadcastPage() { + // 미디어 스트림(비디오, 오디오) + const { + mediaStream, + mediaStreamError, + isMediaStreamReady: _, + isAudioEnabled, + isVideoEnabled, + toggleAudio, + toggleVideo, + } = useMedia(); + + // 화면 공유 + const { screenStream, isScreenSharing, screenShareError, toggleScreenShare } = useScreenShare(); + // 송출 정보 + const tracksRef = useRef({ video: undefined, mediaAudio: undefined, screenAudio: undefined }); + const [isStreamReady, setIsStreamReady] = useState(false); + // 방송 송출 + const { socket, socketError } = useSocket(mediaServerUrl); + const { roomId, transport, producers, error: producerError } = useProduce({ socket, mediaStream }); + // 방송 정보 + const [title, setTitle] = useState(''); + // 테마 + const { theme } = useTheme(); + + useLayoutEffect(() => { + if (theme === 'light') { + document.querySelector('html')?.setAttribute('data-theme', 'light'); + } else { + document.querySelector('html')?.removeAttribute('data-theme'); + } + }, [theme]); + + const stopBroadcast = useCallback( + (e?: BeforeUnloadEvent) => { + if (e) { + e.preventDefault(); + e.returnValue = ''; + } + if (socket) { + socket.emit('stopBroadcast', { roomId }); + socket.disconnect(); + mediaStream?.getTracks().forEach(track => { + track.stop(); + }); + } + transport?.close(); + }, + [socket, mediaStream, roomId, transport], + ); + + const handleCheckout = () => { + stopBroadcast(); + window.close(); + }; + + const handleBroadcastTitle = (newTitle: string) => { + setTitle(newTitle); + }; + + const playPauseAudio = () => { + if (isAudioEnabled) { + producers.get('mediaAudio')?.pause(); + if (tracksRef.current.mediaAudio) tracksRef.current.mediaAudio.enabled = false; + } else { + producers.get('mediaAudio')?.resume(); + if (tracksRef.current.mediaAudio) tracksRef.current.mediaAudio.enabled = true; + } + }; + + useEffect(() => { + tracksRef.current.mediaAudio = mediaStream?.getAudioTracks()[0]; + + axiosInstance.get('/v1/members/info').then(response => { + if (response.data.success) { + setTitle(`${response.data.data.camperId}님의 방송`); + } + }); + + window.addEventListener('beforeunload', stopBroadcast); + return () => { + window.removeEventListener('beforeunload', stopBroadcast); + }; + }, [mediaStream, stopBroadcast]); + + useEffect(() => { + const changeTrack = async () => { + const currentProducer = producers.get('video'); + if (!currentProducer) return; + + currentProducer.pause(); + + let newTrack = null; + + if (isVideoEnabled && isScreenSharing) { + newTrack = tracksRef.current.video || null; + } else if (isVideoEnabled && !isScreenSharing) { + newTrack = mediaStream?.getVideoTracks()[0] || null; + } else if (!isVideoEnabled && isScreenSharing) { + newTrack = screenStream?.getVideoTracks()[0] || null; + } + + if (isVideoEnabled && mediaStream) mediaStream.getVideoTracks()[0].enabled = true; + if (isScreenSharing && screenStream) screenStream.getVideoTracks()[0].enabled = true; + + await currentProducer.replaceTrack({ track: newTrack }); + + if (newTrack) { + currentProducer.resume(); + } + }; + + changeTrack(); + }, [isVideoEnabled, isScreenSharing, mediaStream, screenStream, producers]); + + if (socketError || screenShareError) { + mediaStream?.getTracks().forEach((track: MediaStreamTrack) => track.stop()); + return ( +
+ +
+ ); + } + + return ( +
+ {mediaStreamError || producerError ? ( + <> +

Error

+ {mediaStreamError &&
{mediaStreamError.message}
} + {producerError &&
{producerError.message}
} + + ) : ( + <> + +
+ +
+
+ + +
+ +
+ + + +
+
+
+ + + )} +
+ ); +} diff --git a/apps/client/src/pages/Broadcast/BroadcastTitle.tsx b/apps/client/src/pages/Broadcast/BroadcastTitle.tsx deleted file mode 100644 index 8aba321f..00000000 --- a/apps/client/src/pages/Broadcast/BroadcastTitle.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { useState } from 'react'; -import { useForm, SubmitHandler } from 'react-hook-form'; -import { Button } from '@components/ui/button'; -import axiosInstance from '@services/axios'; - -interface Inputs { - title: string; -} - -interface BroadcastTitleProps { - currentTitle: string; - onTitleChange: (newTitle: string) => void; -} - -function BroadcastTitle({ currentTitle, onTitleChange }: BroadcastTitleProps) { - const { - register, - handleSubmit, - formState: { errors }, - } = useForm(); - - const [isEditing, setIsEditing] = useState(false); - - const handleEditTitle = () => { - setIsEditing(true); - }; - - const onSubmit: SubmitHandler = data => { - axiosInstance.patch('/v1/broadcasts/title', { title: data.title }).then(response => { - if (!response.data.success) { - alert('제목 변경에 실패했습니다!'); - } else { - onTitleChange(data.title); - } - }); - setIsEditing(false); - }; - - return ( - <> - {isEditing ? ( -
-
-
- - {(errors.title?.type === 'required' || errors.title?.type === 'maxLength') && ( -

- {errors.title?.type === 'required' - ? '방송 제목을 입력해주세요' - : '방송 제목은 50자 이하로 입력해주세요'} -

- )} -
- -
-
- ) : ( -
-
{currentTitle}
- -
- )} - - ); -} - -export default BroadcastTitle; diff --git a/apps/client/src/pages/Broadcast/index.tsx b/apps/client/src/pages/Broadcast/index.tsx index ce8703cf..66d7e53b 100644 --- a/apps/client/src/pages/Broadcast/index.tsx +++ b/apps/client/src/pages/Broadcast/index.tsx @@ -1,214 +1 @@ -import BroadcastTitle from './BroadcastTitle'; -import ChatContainer from '@components/ChatContainer'; -import ErrorCharacter from '@components/ErrorCharacter'; -import { useProducer } from '@hooks/useProducer'; -import { useRoom } from '@hooks/useRoom'; -import { useSocket } from '@hooks/useSocket'; -import { useTransport } from '@hooks/useTransport'; -import { - MicrophoneOffIcon, - MicrophoneOnIcon, - VideoOffIcon, - VideoOnIcon, - ScreenShareIcon, - ScreenShareIconOff, -} from '@/components/Icons'; -import { Button } from '@components/ui/button'; -import { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import useScreenShare from '@/hooks/useScreenShare'; -import BroadcastPlayer from './BroadcastPlayer'; -import { Tracks } from '@/types/mediasoupTypes'; -import RecordButton from './RecordButton'; -import axiosInstance from '@/services/axios'; -import { useMedia } from '@/hooks/useMedia'; -import { useTheme } from '@/hooks/useTheme'; - -const mediaServerUrl = import.meta.env.VITE_MEDIASERVER_URL; - -function Broadcast() { - // 미디어 스트림(비디오, 오디오) - const { - mediaStream, - mediaStreamError, - isMediaStreamReady, - isAudioEnabled, - isVideoEnabled, - toggleAudio, - toggleVideo, - } = useMedia(); - - // 화면 공유 - const { screenStream, isScreenSharing, screenShareError, toggleScreenShare } = useScreenShare(); - // 송출 정보 - const tracksRef = useRef({ video: undefined, mediaAudio: undefined, screenAudio: undefined }); - const [isStreamReady, setIsStreamReady] = useState(false); - // 방송 송출 - const { socket, isConnected, socketError } = useSocket(mediaServerUrl); - const { roomId, roomError } = useRoom(socket, isConnected, isMediaStreamReady); - const { transportInfo, device, transportError } = useTransport({ socket, roomId, isProducer: true }); - const { - transport, - error: mediasoupError, - producers, - } = useProducer({ - socket, - mediaStream, - isMediaStreamReady, - transportInfo, - device, - roomId, - }); - // 방송 정보 - const [title, setTitle] = useState(''); - // 테마 - const { theme } = useTheme(); - - useLayoutEffect(() => { - if (theme === 'light') { - document.querySelector('html')?.setAttribute('data-theme', 'light'); - } else { - document.querySelector('html')?.removeAttribute('data-theme'); - } - }, []); - - useEffect(() => { - tracksRef.current['mediaAudio'] = mediaStream?.getAudioTracks()[0]; - - axiosInstance.get('/v1/members/info').then(response => { - if (response.data.success) { - setTitle(`${response.data.data.camperId}님의 방송`); - } - }); - - window.addEventListener('beforeunload', stopBroadcast); - return () => { - window.removeEventListener('beforeunload', stopBroadcast); - }; - }, []); - - useEffect(() => { - changeTrack(); - }, [isVideoEnabled, isScreenSharing]); - - const changeTrack = async () => { - const currentProducer = producers.get('video'); - if (!currentProducer) return; - - currentProducer.pause(); - - let newTrack = null; - - if (isVideoEnabled && isScreenSharing) { - newTrack = tracksRef.current.video || null; - } else if (isVideoEnabled && !isScreenSharing) { - newTrack = mediaStream?.getVideoTracks()[0] || null; - } else if (!isVideoEnabled && isScreenSharing) { - newTrack = screenStream?.getVideoTracks()[0] || null; - } - - if (isVideoEnabled && mediaStream) mediaStream.getVideoTracks()[0].enabled = true; - if (isScreenSharing && screenStream) screenStream.getVideoTracks()[0].enabled = true; - - await currentProducer.replaceTrack({ track: newTrack }); - - if (newTrack) { - currentProducer.resume(); - } - }; - - const stopBroadcast = (e?: BeforeUnloadEvent) => { - if (e) { - e.preventDefault(); - e.returnValue = ''; - } - if (socket) { - socket.emit('stopBroadcast', { roomId }); - socket.disconnect(); - mediaStream?.getTracks().forEach(track => { - track.stop(); - }); - } - transport?.close(); - }; - - const handleCheckout = () => { - stopBroadcast(); - window.close(); - }; - - const handleBroadcastTitle = (newTitle: string) => { - setTitle(newTitle); - }; - - const playPauseAudio = () => { - if (isAudioEnabled) { - producers.get('mediaAudio')?.pause(); - if (tracksRef.current.mediaAudio) tracksRef.current.mediaAudio.enabled = false; - } else { - producers.get('mediaAudio')?.resume(); - if (tracksRef.current.mediaAudio) tracksRef.current.mediaAudio.enabled = true; - } - }; - - if (socketError || roomError || transportError || screenShareError) { - mediaStream?.getTracks().forEach((track: MediaStreamTrack) => track.stop()); - return ( -
- -
- ); - } - - return ( -
- {mediaStreamError || mediasoupError ? ( - <> -

Error

- {mediaStreamError &&
{mediaStreamError.message}
} - {mediasoupError &&
{mediasoupError.message}
} - - ) : ( - <> - -
- -
-
- - -
- -
- - - -
-
-
- - - )} -
- ); -} - -export default Broadcast; +export { BroadcastPage as default } from './BroadcastPage'; diff --git a/apps/client/src/pages/Home/index.tsx b/apps/client/src/pages/Home/HomePage.tsx similarity index 57% rename from apps/client/src/pages/Home/index.tsx rename to apps/client/src/pages/Home/HomePage.tsx index 3418f376..abc49be7 100644 --- a/apps/client/src/pages/Home/index.tsx +++ b/apps/client/src/pages/Home/HomePage.tsx @@ -1,7 +1,6 @@ -import LiveList from '@pages/Home/LiveList'; -import Banner from './Banner'; +import { Banner, LiveList } from '@/widgets'; -export default function Home() { +export function HomePage() { return (
diff --git a/apps/client/src/pages/Home/index.ts b/apps/client/src/pages/Home/index.ts new file mode 100644 index 00000000..0799f479 --- /dev/null +++ b/apps/client/src/pages/Home/index.ts @@ -0,0 +1 @@ +export { HomePage } from './HomePage'; diff --git a/apps/client/src/types/homeTypes.ts b/apps/client/src/pages/Home/model/homeTypes.ts similarity index 60% rename from apps/client/src/types/homeTypes.ts rename to apps/client/src/pages/Home/model/homeTypes.ts index d8f0a309..20b50474 100644 --- a/apps/client/src/types/homeTypes.ts +++ b/apps/client/src/pages/Home/model/homeTypes.ts @@ -1,15 +1,15 @@ -import { Field } from './liveTypes'; +import { Field } from '@/shared/types/sharedTypes'; -export interface LivePreviewInfo { +export type LivePreviewInfo = { broadcastId: string; broadcastTitle: string; camperId: string; profileImage: string; thumbnail: string; field: Field; -} +}; -export interface LivePreviewListInfo { +export type LivePreviewListInfo = { broadcasts: LivePreviewInfo[]; nextCursor: string | null; -} +}; diff --git a/apps/client/src/pages/Home/model/index.ts b/apps/client/src/pages/Home/model/index.ts new file mode 100644 index 00000000..e62cd6ef --- /dev/null +++ b/apps/client/src/pages/Home/model/index.ts @@ -0,0 +1,2 @@ +export { useIntersect } from './useIntersect'; +export type { LivePreviewInfo, LivePreviewListInfo } from './homeTypes'; diff --git a/apps/client/src/hooks/useIntersect.ts b/apps/client/src/pages/Home/model/useIntersect.ts similarity index 92% rename from apps/client/src/hooks/useIntersect.ts rename to apps/client/src/pages/Home/model/useIntersect.ts index d713ce0f..361be516 100644 --- a/apps/client/src/hooks/useIntersect.ts +++ b/apps/client/src/pages/Home/model/useIntersect.ts @@ -2,10 +2,10 @@ import { useCallback, useEffect, useRef } from 'react'; type IntersectHandler = (entry: IntersectionObserverEntry, observer: IntersectionObserver) => void; -interface UseIntersectProps { +type UseIntersectProps = { onIntersect: IntersectHandler; options?: IntersectionObserverInit; -} +}; export const useIntersect = ({ onIntersect, options }: UseIntersectProps) => { const ref = useRef(null); @@ -19,7 +19,7 @@ export const useIntersect = ({ onIntersect, options }: UseIntersectProps) => { ); useEffect(() => { - if (!ref.current) return; + if (!ref.current) return undefined; const observer = new IntersectionObserver(callback, options); observer.observe(ref.current); return () => { diff --git a/apps/client/src/pages/Live/LiveCamperInfo.tsx b/apps/client/src/pages/Live/LiveCamperInfo.tsx deleted file mode 100644 index 4c4714dc..00000000 --- a/apps/client/src/pages/Live/LiveCamperInfo.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { Avatar, AvatarFallback, AvatarImage } from '@components/ui/avatar'; -import { Badge } from '@components/ui/badge'; -import IconButton from '@components/IconButton'; -import { MailIcon, GithubIcon, BlogIcon, LinkedInIcon } from '@/components/Icons'; -import { useAPI } from '@hooks/useAPI'; -import { LiveInfo } from '@/types/liveTypes'; -import LoadingCharacter from '@components/LoadingCharacter'; -import ErrorCharacter from '@components/ErrorCharacter'; - -function LiveCamperInfo({ liveId }: { liveId: string }) { - const { data, isLoading, error } = useAPI({ url: `v1/broadcasts/${liveId}/info` }); - - return ( - <> - {error || !data ? ( -
- -
- ) : isLoading ? ( - - ) : ( -
-
- {/* 제목 */} -

{data.title}

- -
- {/* 프로필 사진 */} - - - {data.camperId} - - -
-
- {data.camperId} - - {data.field ? data.field : '???'} - -
- {data.viewers}명 시청 중 -
-
-
- - {/* 우측 상단 아이콘들 - 2x2 그리드 */} -
- window.open(`mailto:${data.contacts.email}`)} - > - {' '} - - - window.open(data.contacts.blog, '_blank')} - > - - - - window.open(data.contacts.github, '_blank')}> - - - - window.open(data.contacts.linkedin, '_blank')} - > - - -
-
- )} - - ); -} - -export default LiveCamperInfo; diff --git a/apps/client/src/pages/Live/index.tsx b/apps/client/src/pages/Live/LivePage.tsx similarity index 51% rename from apps/client/src/pages/Live/index.tsx rename to apps/client/src/pages/Live/LivePage.tsx index 004e3915..14cbcd68 100644 --- a/apps/client/src/pages/Live/index.tsx +++ b/apps/client/src/pages/Live/LivePage.tsx @@ -1,58 +1,50 @@ -import ChatContainer from '@components/ChatContainer'; -import ErrorCharacter from '@components/ErrorCharacter'; -import LiveCamperInfo from './LiveCamperInfo'; -import { useConsumer } from '@hooks/useConsumer'; -import { useSocket } from '@hooks/useSocket'; -import { useTransport } from '@hooks/useTransport'; -import LivePlayer from './LivePlayer'; import { useEffect } from 'react'; import { useParams } from 'react-router-dom'; +import { ErrorCharacter } from '@/shared/ui'; +import { ChatContainer } from '@/features/chatting'; +import { useConsume, LivePlayer, LiveCamperInfo } from '@/features/watching'; +import { useSocket } from '@/shared/lib'; const socketUrl = import.meta.env.VITE_MEDIASERVER_URL; -export default function Live() { +export function LivePage() { const { liveId } = useParams<{ liveId: string }>(); - const { socket, isConnected, socketError } = useSocket(socketUrl); - const { transportInfo, device, transportError } = useTransport({ - socket, - roomId: liveId, - isProducer: false, - }); + const { socket, socketError } = useSocket(socketUrl); const { transport, + transportId, mediastream: mediaStream, error: consumerError, - } = useConsumer({ + } = useConsume({ socket, - device, roomId: liveId, - transportInfo, - isConnected, }); - const handleLeaveLive = () => { - if (socket && liveId && transportInfo) { - socket.emit('leaveBroadcast', { transportId: transportInfo.transportId, roomId: liveId }); - } + useEffect(() => { + if (!socket || !liveId || !transport) return undefined; - socket?.disconnect(); - transport?.close(); - }; + const handleLeaveLive = () => { + if (socket && liveId && transportId) { + socket.emit('leaveBroadcast', { transportId, roomId: liveId }); + } - const preventClose = (e: BeforeUnloadEvent) => { - e.preventDefault(); - handleLeaveLive(); - e.returnValue = ''; - }; + socket?.disconnect(); + transport?.close(); + }; + + const preventClose = (e: BeforeUnloadEvent) => { + e.preventDefault(); + handleLeaveLive(); + e.returnValue = ''; + }; - useEffect(() => { window.addEventListener('beforeunload', preventClose); return () => { handleLeaveLive(); window.removeEventListener('beforeunload', preventClose); }; - }, []); + }, [socket, liveId, transportId, transport]); return (
@@ -63,9 +55,9 @@ export default function Live() {
diff --git a/apps/client/src/pages/Live/LivePlayer.tsx b/apps/client/src/pages/Live/LivePlayer.tsx deleted file mode 100644 index 02173905..00000000 --- a/apps/client/src/pages/Live/LivePlayer.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@components/ui/select'; -import { PlayIcon, PauseIcon, VolumeOffIcon, VolumeOnIcon, ExpandIcon } from '@/components/Icons'; -import { Socket } from 'socket.io-client'; -import ErrorCharacter from '@/components/ErrorCharacter'; - -interface Errors { - socketError: Error | null; - transportError: Error | null; - consumerError: Error | null; -} - -interface LivePlayerProps { - mediaStream: MediaStream | null; - socket: Socket | null; - transportId: string | undefined; - errors: Errors; -} - -type VideoQuality = 'auto' | '480p' | '720p' | '1080p'; - -function LivePlayer({ mediaStream, socket, transportId, errors }: LivePlayerProps) { - const [isVideoEnabled, setIsVideoEnabled] = useState(true); - const [isAudioEnabled, setIsAudioEnabled] = useState(false); - const [videoQuality, setVideoQuality] = useState('720p'); - const videoRef = useRef(null); - - const { socketError, transportError, consumerError } = errors; - - useEffect(() => { - const videoElement = videoRef.current; - if (videoElement && mediaStream) { - videoElement.srcObject = mediaStream; - } - - return () => { - if (videoElement && videoElement.srcObject) { - videoElement.srcObject = null; - } - }; - }, [mediaStream]); - - const handlePlayPause = async () => { - if (mediaStream && videoRef.current) { - if (isVideoEnabled) { - videoRef.current.pause(); - setIsVideoEnabled(false); - } else { - videoRef.current.play(); - setIsVideoEnabled(true); - } - } - }; - - const handleMute = () => { - if (mediaStream) { - setIsAudioEnabled(prev => !prev); - } - }; - - const handleVideoQuality = (videoQuality: VideoQuality) => { - if (!socket) return; - - socket.emit('setVideoQuality', { transportId, quality: videoQuality }); - setVideoQuality(videoQuality); - }; - - const handleExpand = async () => { - await videoRef.current?.requestFullscreen?.(); - }; - - return ( - <> - {socketError || transportError || consumerError ? ( -
- -
- ) : ( -
-
- )} - - ); -} - -export default LivePlayer; diff --git a/apps/client/src/pages/Live/index.ts b/apps/client/src/pages/Live/index.ts new file mode 100644 index 00000000..cb88e418 --- /dev/null +++ b/apps/client/src/pages/Live/index.ts @@ -0,0 +1 @@ +export { LivePage as default } from './LivePage'; diff --git a/apps/client/src/pages/Profile/Attendance.tsx b/apps/client/src/pages/Profile/Attendance.tsx deleted file mode 100644 index fcb6c17a..00000000 --- a/apps/client/src/pages/Profile/Attendance.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import ErrorCharacter from '@/components/ErrorCharacter'; -import { PlayIcon } from '@/components/Icons'; -import LoadingCharacter from '@/components/LoadingCharacter'; -import axiosInstance from '@/services/axios'; -import { useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; - -interface AttendanceData { - attendanceId: number; - date: string; - startTime: string; - endTime: string; - isAttendance: boolean; -} - -function Attendance() { - const [attendanceList, setAttendanceList] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [showLoading, setShowLoading] = useState(false); - const [error, setError] = useState(null); - - const navigate = useNavigate(); - - useEffect(() => { - axiosInstance - .get('/v1/members/attendance') - .then(response => { - if (response.data.success) { - setAttendanceList(response.data.data.attendances); - } else { - setError(new Error(response.data.message)); - } - }) - .catch(error => setError(error instanceof Error ? error : new Error(error))) - .finally(() => { - setIsLoading(false); - }); - }, [setAttendanceList, setError]); - - useEffect(() => { - const timer = setTimeout(() => { - setShowLoading(true); - }, 250); - - return () => clearTimeout(timer); - }); - - const handlePlayRecord = (attendanceId: number) => { - navigate(`/record/${attendanceId}`); - }; - - return ( -
-
-
- {['학습일', '시작 시간', '종료 시간', '출석 여부'].map((data: string, idx) => ( -
- {data} -
- ))} -
- {showLoading && isLoading ? ( -
- -
- ) : error ? ( -
- -
- ) : ( -
- {attendanceList?.map(data => ( -
-
{data.date}
-
{data.startTime}
-
{data.endTime}
-
-
- - {data.isAttendance ? '출석' : '결석'} - - -
-
-
- ))} -
- )} -
-
- ); -} - -export default Attendance; diff --git a/apps/client/src/pages/Profile/EditUserInfo.tsx b/apps/client/src/pages/Profile/EditUserInfo.tsx deleted file mode 100644 index 25158705..00000000 --- a/apps/client/src/pages/Profile/EditUserInfo.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { UserData } from '.'; -import { useForm } from 'react-hook-form'; -import { Field } from '@/types/liveTypes'; -import { Button } from '@/components/ui/button'; -import { useState } from 'react'; -import axiosInstance from '@/services/axios'; -import { useToast } from '@/hooks/useToast'; - -interface EditUserInfoProps { - userData: UserData | undefined; - toggleEditing: () => void; -} - -interface FormInput { - camperId: string | undefined; - name: string | undefined; - field: Field | undefined; - email: string | undefined; - github: string | undefined; - blog: string | undefined; - linkedIn: string | undefined; -} - -function EditUserInfo({ userData, toggleEditing }: EditUserInfoProps) { - const [selectedField, setSelectedField] = useState(userData?.field); - const { - register, - handleSubmit, - formState: { errors }, - } = useForm({ - defaultValues: { - camperId: userData?.camperId, - name: userData?.name, - field: userData?.field, - email: userData?.contacts.email, - github: userData?.contacts.github, - blog: userData?.contacts.blog, - linkedIn: userData?.contacts.linkedIn, - }, - }); - const { toast } = useToast(); - - const handleSelectField = (field: Field) => { - setSelectedField(selectedField === field ? '' : field); - }; - - const handlePatchUserInfo = (data: FormInput) => { - const formData = { - name: data.name, - camperId: data.camperId, - field: selectedField, - contacts: { - email: data.email ? data.email : '', - github: data.github ? data.github : '', - blog: data.blog ? data.blog : '', - linkedin: data.linkedIn ? data.linkedIn : '', - }, - }; - - if (!formData.field) return; - - axiosInstance.patch('/v1/members/info', formData).then(response => { - if (response.data.success) { - toggleEditing(); - } else { - toast({ variant: 'destructive', title: '유저 정보 수정 실패' }); - } - }); - }; - - return ( -
- - - MY - -
- {(errors.camperId || errors.name || !selectedField) && ( -

- {errors.camperId ? errors.camperId.message : errors.name ? errors.name.message : '분야를 선택해주세요'} -

- )} - {/* ID */} -
- - -
- {/* 이름 */} -
- - -
- {/* TODO: 입력 검증 */} - {/* email */} -
- - -
- {/* github */} -
- - -
- {/* blog */} -
- - -
- {/* linkedIn */} -
- - -
- {/* 분야 */} -
- -
- - - -
-
-
- -
-
-
- ); -} - -export default EditUserInfo; diff --git a/apps/client/src/pages/Profile/ProfilePage.tsx b/apps/client/src/pages/Profile/ProfilePage.tsx new file mode 100644 index 00000000..f48d75f2 --- /dev/null +++ b/apps/client/src/pages/Profile/ProfilePage.tsx @@ -0,0 +1,74 @@ +import { useEffect, useState } from 'react'; +import { Attendance, UserInfo } from './ui'; +import { UserData } from './model'; +import { EditUserInfo } from '@/features/editProfile'; +import { axiosInstance } from '@/shared/api'; +import { ErrorCharacter, LoadingCharacter } from '@/shared/ui'; + +export function ProfilePage() { + const [userData, setUserData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [showLoading, setShowLoading] = useState(true); + + useEffect(() => { + axiosInstance + .get('/v1/members/info') + .then(response => { + if (response.data.success) { + setUserData(response.data.data); + } else { + setError(new Error(response.data.message)); + } + }) + .catch(err => setError(err instanceof Error ? err : new Error(err))) + .finally(() => setIsLoading(false)); + }, [isEditing]); + + useEffect(() => { + if (!userData) return; + if (!userData.camperId || !userData.name || !userData.field) { + if (!isEditing) setIsEditing(true); + } + }, [userData, isEditing]); + + useEffect(() => { + const timeoutId = setTimeout(() => { + setShowLoading(false); + }, 250); + + return () => clearTimeout(timeoutId); + }, []); + + const toggleEditing = () => { + setIsEditing(prev => !prev); + }; + + if (showLoading && isLoading) { + return ( +
+ +
+ ); + } + + if (error || !userData) { + return ( +
+ +
+ ); + } + + if (isEditing) { + return ; + } + + return ( + <> + + + + ); +} diff --git a/apps/client/src/pages/Profile/UserInfo.tsx b/apps/client/src/pages/Profile/UserInfo.tsx deleted file mode 100644 index c1409401..00000000 --- a/apps/client/src/pages/Profile/UserInfo.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import ErrorCharacter from '@/components/ErrorCharacter'; -import { BlogIcon, EditIcon, GithubIcon, LinkedInIcon, MailIcon } from '@/components/Icons'; -import LoadingCharacter from '@/components/LoadingCharacter'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { useEffect, useState } from 'react'; -import { UserData } from '.'; - -interface UserInfoProps { - userData: UserData | undefined; - isLoading: boolean; - error: Error | null; - toggleEditing: () => void; -} - -function UserInfo({ userData, isLoading, error, toggleEditing }: UserInfoProps) { - const [showLoading, setShowLoading] = useState(false); - - useEffect(() => { - const timer = setTimeout(() => { - setShowLoading(true); - }, 250); - - return () => clearTimeout(timer); - }); - - return ( -
- {showLoading && isLoading ? ( -
- -
- ) : error ? ( -
- -
- ) : ( - <> -
- - - MY - -
{userData?.name}
-
-
-
-
- - {userData?.camperId ? userData.camperId : '???'} - -
- {userData?.field ? userData.field : '???'} -
- -
-
- - email - {userData?.contacts.email} -
-
- - Github - {userData?.contacts.github} -
-
- - Blog - {userData?.contacts.blog} -
-
- - LinkedIn - {userData?.contacts.linkedIn} -
-
-
- - )} -
- ); -} - -export default UserInfo; diff --git a/apps/client/src/pages/Profile/index.ts b/apps/client/src/pages/Profile/index.ts new file mode 100644 index 00000000..ce680163 --- /dev/null +++ b/apps/client/src/pages/Profile/index.ts @@ -0,0 +1,2 @@ +export { ProfilePage as default } from './ProfilePage'; +export type { UserData } from './model'; diff --git a/apps/client/src/pages/Profile/index.tsx b/apps/client/src/pages/Profile/index.tsx deleted file mode 100644 index 40655a46..00000000 --- a/apps/client/src/pages/Profile/index.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { useEffect, useState } from 'react'; -import Attendance from './Attendance'; -import UserInfo from './UserInfo'; -import EditUserInfo from './EditUserInfo'; -import axiosInstance from '@/services/axios'; -import { Field } from '@/types/liveTypes'; -import ErrorCharacter from '@/components/ErrorCharacter'; -import LoadingCharacter from '@/components/LoadingCharacter'; - -export interface UserData { - id: number; - camperId: string; - name: string; - field: Field; - contacts: Contacts; - profileImage: string; -} - -export interface Contacts { - email: string; - github: string; - blog: string; - linkedIn: string; -} - -export default function Profile() { - const [userData, setUserData] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [isEditing, setIsEditing] = useState(false); - const [showLoading, setShowLoading] = useState(true); - - useEffect(() => { - axiosInstance - .get('/v1/members/info') - .then(response => { - if (response.data.success) { - setUserData(response.data.data); - } else { - setError(new Error(response.data.message)); - } - }) - .catch(error => setError(error instanceof Error ? error : new Error(error))) - .finally(() => setIsLoading(false)); - }, [isEditing]); - - useEffect(() => { - if (!userData) return; - if (!userData.camperId || !userData.name || !userData.field) { - if (!isEditing) setIsEditing(true); - } - }, [userData]); - - useEffect(() => { - const timeoutId = setTimeout(() => { - setShowLoading(false); - }, 250); - - return () => clearTimeout(timeoutId); - }, []); - - const toggleEditing = () => { - setIsEditing(prev => !prev); - }; - - return ( -
- {showLoading && isLoading ? ( -
- -
- ) : error || !userData ? ( -
- -
- ) : isEditing ? ( - - ) : ( - <> - - - - )} -
- ); -} diff --git a/apps/client/src/pages/Profile/model/index.ts b/apps/client/src/pages/Profile/model/index.ts new file mode 100644 index 00000000..66de0f4e --- /dev/null +++ b/apps/client/src/pages/Profile/model/index.ts @@ -0,0 +1 @@ +export type { UserData } from './types'; diff --git a/apps/client/src/pages/Profile/model/types.ts b/apps/client/src/pages/Profile/model/types.ts new file mode 100644 index 00000000..5b54fd70 --- /dev/null +++ b/apps/client/src/pages/Profile/model/types.ts @@ -0,0 +1,17 @@ +import { Field } from '@/shared/types/sharedTypes'; + +type Contacts = { + email: string; + github: string; + blog: string; + linkedIn: string; +}; + +export type UserData = { + id: number; + camperId: string; + name: string; + field: Field; + contacts: Contacts; + profileImage: string; +}; diff --git a/apps/client/src/pages/Profile/ui/Attendance.tsx b/apps/client/src/pages/Profile/ui/Attendance.tsx new file mode 100644 index 00000000..7ebc9b49 --- /dev/null +++ b/apps/client/src/pages/Profile/ui/Attendance.tsx @@ -0,0 +1,100 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ErrorCharacter, LoadingCharacter } from '@/shared/ui'; +import { PlayIcon } from '@/shared/ui/Icons'; +import { axiosInstance } from '@/shared/api'; + +type AttendanceData = { + attendanceId: number; + date: string; + startTime: string; + endTime: string; + isAttendance: boolean; +}; + +export function Attendance() { + const [attendanceList, setAttendanceList] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [showLoading, setShowLoading] = useState(false); + const [error, setError] = useState(null); + + const navigate = useNavigate(); + + useEffect(() => { + axiosInstance + .get('/v1/members/attendance') + .then(response => { + if (response.data.success) { + setAttendanceList(response.data.data.attendances); + } else { + setError(new Error(response.data.message)); + } + }) + .catch(err => setError(err instanceof Error ? err : new Error(err))) + .finally(() => { + setIsLoading(false); + }); + }, [setAttendanceList, setError]); + + useEffect(() => { + const timer = setTimeout(() => { + setShowLoading(true); + }, 250); + + return () => clearTimeout(timer); + }); + + const handlePlayRecord = (attendanceId: number) => { + navigate(`/record/${attendanceId}`); + }; + + if (showLoading && isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+ {['학습일', '시작 시간', '종료 시간', '출석 여부'].map((data: string) => ( +
+ {data} +
+ ))} +
+ +
+ {attendanceList?.map(data => ( +
+
{data.date}
+
{data.startTime}
+
{data.endTime}
+
+
+ + {data.isAttendance ? '출석' : '결석'} + + +
+
+
+ ))} +
+
+
+ ); +} diff --git a/apps/client/src/pages/Profile/ui/UserInfo.tsx b/apps/client/src/pages/Profile/ui/UserInfo.tsx new file mode 100644 index 00000000..437d9802 --- /dev/null +++ b/apps/client/src/pages/Profile/ui/UserInfo.tsx @@ -0,0 +1,86 @@ +import { useEffect, useState } from 'react'; +import { ErrorCharacter, LoadingCharacter, BlogIcon, EditIcon, GithubIcon, LinkedInIcon, MailIcon } from '@/shared/ui'; +import { Avatar, AvatarFallback, AvatarImage } from '@/shared/ui/shadcn/avatar'; +import { UserData } from '../model'; + +type UserInfoProps = Readonly<{ + userData: UserData | undefined; + isLoading: boolean; + error: Error | null; + toggleEditing: () => void; +}>; + +export function UserInfo({ userData, isLoading, error, toggleEditing }: UserInfoProps) { + const [showLoading, setShowLoading] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => { + setShowLoading(true); + }, 250); + + return () => clearTimeout(timer); + }); + + if (showLoading && isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ +
+ ); + } + + return ( +
+
+ + + MY + +
{userData?.name}
+
+
+
+
+ + {userData?.camperId ? userData.camperId : '???'} + +
+ {userData?.field ? userData.field : '???'} +
+ +
+
+ + email + {userData?.contacts.email} +
+
+ + Github + {userData?.contacts.github} +
+
+ + Blog + {userData?.contacts.blog} +
+
+ + LinkedIn + {userData?.contacts.linkedIn} +
+
+
+
+ ); +} diff --git a/apps/client/src/pages/Profile/ui/index.ts b/apps/client/src/pages/Profile/ui/index.ts new file mode 100644 index 00000000..906171a0 --- /dev/null +++ b/apps/client/src/pages/Profile/ui/index.ts @@ -0,0 +1,2 @@ +export { Attendance } from './Attendance'; +export { UserInfo } from './UserInfo'; diff --git a/apps/client/src/pages/Record/RecordInfo.tsx b/apps/client/src/pages/Record/RecordInfo.tsx deleted file mode 100644 index 54840272..00000000 --- a/apps/client/src/pages/Record/RecordInfo.tsx +++ /dev/null @@ -1,13 +0,0 @@ -interface RecordInfoProps { - title: string; -} - -function RecordInfo(props: RecordInfoProps) { - return ( -
-

{props.title}

-
- ); -} - -export default RecordInfo; diff --git a/apps/client/src/pages/Record/RecordPage.tsx b/apps/client/src/pages/Record/RecordPage.tsx new file mode 100644 index 00000000..add84e24 --- /dev/null +++ b/apps/client/src/pages/Record/RecordPage.tsx @@ -0,0 +1,25 @@ +import { useState } from 'react'; +import { RecordInfo, RecordList, RecordPlayer } from './ui'; + +export type RecordData = { + recordId: number; + title: string; + video: string; + date: string; +}; + +export function RecordPage() { + const [nowPlaying, setNowPlaying] = useState({ recordId: 0, title: '', video: '', date: '' }); + + return ( +
+
+ + +
+
+ +
+
+ ); +} diff --git a/apps/client/src/pages/Record/index.ts b/apps/client/src/pages/Record/index.ts new file mode 100644 index 00000000..b0981d1b --- /dev/null +++ b/apps/client/src/pages/Record/index.ts @@ -0,0 +1 @@ +export { RecordPage as default } from './RecordPage'; diff --git a/apps/client/src/pages/Record/index.tsx b/apps/client/src/pages/Record/index.tsx deleted file mode 100644 index 473fa581..00000000 --- a/apps/client/src/pages/Record/index.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { useState } from 'react'; -import RecordInfo from './RecordInfo'; -import RecordList from './RecordList'; -import RecordPlayer from './RecordPlayer'; - -export interface RecordData { - recordId: number; - title: string; - video: string; - date: string; -} - -function Record() { - const [nowPlaying, setIsNowPlaying] = useState({ recordId: 0, title: '', video: '', date: '' }); - - return ( -
- <> -
- - -
-
- -
- -
- ); -} - -export default Record; diff --git a/apps/client/src/pages/Record/ui/RecordInfo.tsx b/apps/client/src/pages/Record/ui/RecordInfo.tsx new file mode 100644 index 00000000..e8bca509 --- /dev/null +++ b/apps/client/src/pages/Record/ui/RecordInfo.tsx @@ -0,0 +1,7 @@ +export function RecordInfo({ title }: Readonly<{ title: string }>) { + return ( +
+

{title}

+
+ ); +} diff --git a/apps/client/src/pages/Record/RecordList.tsx b/apps/client/src/pages/Record/ui/RecordList.tsx similarity index 76% rename from apps/client/src/pages/Record/RecordList.tsx rename to apps/client/src/pages/Record/ui/RecordList.tsx index 54302c2c..9590d40d 100644 --- a/apps/client/src/pages/Record/RecordList.tsx +++ b/apps/client/src/pages/Record/ui/RecordList.tsx @@ -1,15 +1,14 @@ -import { PlayIcon } from '@/components/Icons'; -import { RecordData } from '.'; import { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; -import axiosInstance from '@/services/axios'; -import ErrorCharacter from '@/components/ErrorCharacter'; +import { PlayIcon, ErrorCharacter } from '@/shared/ui'; +import { RecordData } from '../RecordPage'; +import { axiosInstance } from '@/shared/api'; -interface RecordListProps { +type RecordListProps = Readonly<{ onClickList: (data: RecordData) => void; -} +}>; -function RecordList(props: RecordListProps) { +export function RecordList({ onClickList }: RecordListProps) { const [recordList, setRecordList] = useState([]); const { attendanceId } = useParams<{ attendanceId: string }>(); const [error, setError] = useState(''); @@ -19,7 +18,7 @@ function RecordList(props: RecordListProps) { if (response.data.success) setRecordList(response.data.data.records); else setError(response.data.message); }); - }, []); + }, [attendanceId]); return (
@@ -30,12 +29,13 @@ function RecordList(props: RecordListProps) { ) : (
- {recordList.map((record: RecordData, idx: number) => ( -
( +
+ ))}
@@ -53,5 +53,3 @@ function RecordList(props: RecordListProps) {
); } - -export default RecordList; diff --git a/apps/client/src/pages/Record/RecordPlayer.tsx b/apps/client/src/pages/Record/ui/RecordPlayer.tsx similarity index 75% rename from apps/client/src/pages/Record/RecordPlayer.tsx rename to apps/client/src/pages/Record/ui/RecordPlayer.tsx index f5f066a5..6779a8fd 100644 --- a/apps/client/src/pages/Record/RecordPlayer.tsx +++ b/apps/client/src/pages/Record/ui/RecordPlayer.tsx @@ -1,26 +1,23 @@ -import LoadingCharacter from '@/components/LoadingCharacter'; import { useEffect, useState } from 'react'; import ReactPlayer from 'react-player'; +import { LoadingCharacter } from '@/shared/ui'; -interface RecordPlayerProps { - video: string; -} - -function RecordPlayer(props: RecordPlayerProps) { +export function RecordPlayer({ video }: Readonly<{ video: string }>) { const [isSelectedVideo, setIsSelectedVideo] = useState(false); useEffect(() => { - if (props.video) { + if (video) { setIsSelectedVideo(true); } - }, [props.video]); + }, [video]); return (
{isSelectedVideo ? (
); } - -export default RecordPlayer; diff --git a/apps/client/src/pages/Record/ui/index.ts b/apps/client/src/pages/Record/ui/index.ts new file mode 100644 index 00000000..b7b6255a --- /dev/null +++ b/apps/client/src/pages/Record/ui/index.ts @@ -0,0 +1,3 @@ +export { RecordInfo } from './RecordInfo'; +export { RecordList } from './RecordList'; +export { RecordPlayer } from './RecordPlayer'; diff --git a/apps/client/src/services/axios.ts b/apps/client/src/shared/api/axios.ts similarity index 78% rename from apps/client/src/services/axios.ts rename to apps/client/src/shared/api/axios.ts index ea1e8792..ed2abf6d 100644 --- a/apps/client/src/services/axios.ts +++ b/apps/client/src/shared/api/axios.ts @@ -2,7 +2,7 @@ import axios from 'axios'; const baseUrl = import.meta.env.VITE_API_SERVER_URL; -const axiosInstance = axios.create({ +export const axiosInstance = axios.create({ baseURL: baseUrl, headers: { 'Content-Type': 'application/json' }, withCredentials: true, @@ -17,9 +17,5 @@ axiosInstance.interceptors.request.use( } return config; }, - error => { - return Promise.reject(error); - }, + error => Promise.reject(error instanceof Error ? error : new Error(error)), ); - -export default axiosInstance; diff --git a/apps/client/src/shared/api/index.ts b/apps/client/src/shared/api/index.ts new file mode 100644 index 00000000..a2123fd9 --- /dev/null +++ b/apps/client/src/shared/api/index.ts @@ -0,0 +1,2 @@ +export { axiosInstance } from './axios'; +export { useAPI } from './useAPI'; diff --git a/apps/client/src/shared/api/useAPI.ts b/apps/client/src/shared/api/useAPI.ts new file mode 100644 index 00000000..32fe424d --- /dev/null +++ b/apps/client/src/shared/api/useAPI.ts @@ -0,0 +1,45 @@ +import { useCallback, useEffect, useState } from 'react'; +import { axiosInstance } from '@/shared/api'; + +type APIOptions = { + method?: 'GET' | 'POST' | 'DELETE' | 'PUT' | 'PATCH'; + params?: Record; + data?: unknown; +}; + +type APIQueryState = { + data: T | null; + fetchData: () => Promise; + isLoading: boolean; + error: Error | null; +}; + +export const useAPI = (endpoint: string, options: APIOptions = {}): APIQueryState => { + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + + const fetchData = useCallback(async () => { + setIsLoading(true); + try { + const result = await axiosInstance.request({ + url: endpoint, + method: options.method ?? 'GET', + params: options.params, + data: options.data, + }); + setData(result.data.data); + setError(null); + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to fetch data.')); + } finally { + setIsLoading(false); + } + }, [endpoint, options.method, options.params, options.data]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + return { data, fetchData, isLoading, error }; +}; diff --git a/apps/client/src/shared/contexts/ThemeContext.tsx b/apps/client/src/shared/contexts/ThemeContext.tsx new file mode 100644 index 00000000..6cc4f639 --- /dev/null +++ b/apps/client/src/shared/contexts/ThemeContext.tsx @@ -0,0 +1,15 @@ +import { createContext } from 'react'; + +const currentTheme = localStorage.getItem('theme') ?? null; + +type Theme = 'light' | 'dark' | null; + +type ThemeContextValue = { + theme: Theme; + setTheme: React.Dispatch>; +}; + +export const ThemeContext = createContext({ + theme: currentTheme as Theme, + setTheme: () => null, +}); diff --git a/apps/client/src/shared/contexts/index.ts b/apps/client/src/shared/contexts/index.ts new file mode 100644 index 00000000..0cf5b4fe --- /dev/null +++ b/apps/client/src/shared/contexts/index.ts @@ -0,0 +1,2 @@ +export { AuthContext } from '../../features/auth/model/AuthContext'; +export { ThemeContext } from './ThemeContext'; diff --git a/apps/client/src/shared/lib/index.ts b/apps/client/src/shared/lib/index.ts new file mode 100644 index 00000000..1f4c1538 --- /dev/null +++ b/apps/client/src/shared/lib/index.ts @@ -0,0 +1,5 @@ +export { useSocket } from './useSocket'; +export { useTheme } from './useTheme'; +export { useToast } from './useToast'; +export { getRtpCapabilities, createDevice, connectTransport } from './mediasoupHelpers'; +export { cn, checkDependencies } from './utils'; diff --git a/apps/client/src/shared/lib/mediasoupHelpers.ts b/apps/client/src/shared/lib/mediasoupHelpers.ts new file mode 100644 index 00000000..12850d32 --- /dev/null +++ b/apps/client/src/shared/lib/mediasoupHelpers.ts @@ -0,0 +1,73 @@ +import * as mediasoupClient from 'mediasoup-client'; +import { RtpCapabilities } from 'mediasoup-client/lib/RtpParameters'; +import { Socket } from 'socket.io-client'; +import { TransportInfo } from '../types/mediasoupTypes'; + +type ConnectTransportResponse = { + connected: boolean; + isProducer: boolean; +}; + +export const getRtpCapabilities = async (socket: Socket, roomId: string): Promise => + new Promise((resolve, reject) => { + socket.emit('getRtpCapabilities', { roomId }, (response: { rtpCapabilities: RtpCapabilities }) => { + if (response.rtpCapabilities) { + resolve(response.rtpCapabilities); + } else { + reject(new Error('getRtpCapabilities Error: RTP Capabilities를 받아오지 못했습니다.')); + } + }); + }); + +export const createDevice = async (rtpCapabilities: RtpCapabilities) => { + const newDevice = new mediasoupClient.Device(); + await newDevice.load({ + routerRtpCapabilities: rtpCapabilities, + }); + return newDevice; +}; + +export const connectTransport = async ( + socket: Socket, + device: mediasoupClient.Device, + roomId: string, + isProducer: boolean, +) => { + const transportInfo: TransportInfo = await new Promise(resolve => { + socket.emit('createTransport', { roomId, isProducer }, (response: TransportInfo) => { + resolve(response); + }); + }); + + const transport = isProducer + ? device.createSendTransport({ + id: transportInfo.transportId, + iceParameters: transportInfo.iceParameters, + iceCandidates: transportInfo.iceCandidates, + dtlsParameters: transportInfo.dtlsParameters, + }) + : device.createRecvTransport({ + id: transportInfo.transportId, + iceParameters: transportInfo.iceParameters, + iceCandidates: transportInfo.iceCandidates, + dtlsParameters: transportInfo.dtlsParameters, + }); + + transport.on('connect', async ({ dtlsParameters }, callback) => { + socket.emit( + 'connectTransport', + { + roomId, + dtlsParameters, + transportId: transportInfo.transportId, + }, + (response: ConnectTransportResponse) => { + if (response.connected) { + callback(); + } + }, + ); + }); + + return { transport, transportInfo }; +}; diff --git a/apps/client/src/hooks/useSocket.ts b/apps/client/src/shared/lib/useSocket.ts similarity index 90% rename from apps/client/src/hooks/useSocket.ts rename to apps/client/src/shared/lib/useSocket.ts index 85745d98..c44cec53 100644 --- a/apps/client/src/hooks/useSocket.ts +++ b/apps/client/src/shared/lib/useSocket.ts @@ -1,15 +1,15 @@ import { useEffect, useRef, useState } from 'react'; import { io, Socket } from 'socket.io-client'; -interface ExceptionData { +type ExceptionData = { status: number; message: string; -} +}; -interface ExceptionResponse { +type ExceptionResponse = { event: string; data: ExceptionData; -} +}; export const useSocket = (url: string) => { const socketRef = useRef(null); @@ -18,7 +18,7 @@ export const useSocket = (url: string) => { useEffect(() => { const accessToken = localStorage.getItem('accessToken'); - if (socketRef.current?.connected) return; + if (socketRef.current?.connected) return undefined; const socket = io(url, { withCredentials: true, transports: ['websocket', 'polling'], @@ -49,7 +49,6 @@ export const useSocket = (url: string) => { }); socket.on('exception', (error: ExceptionResponse) => { - console.error(`socket exception Error: ${error.data.status}`); setSocketError(new Error(error.data.message)); }); @@ -58,7 +57,7 @@ export const useSocket = (url: string) => { socket.disconnect(); } }; - }, []); + }, [url]); return { socket: socketRef.current, diff --git a/apps/client/src/hooks/useTheme.ts b/apps/client/src/shared/lib/useTheme.ts similarity index 91% rename from apps/client/src/hooks/useTheme.ts rename to apps/client/src/shared/lib/useTheme.ts index 697b2ffb..9a321e19 100644 --- a/apps/client/src/hooks/useTheme.ts +++ b/apps/client/src/shared/lib/useTheme.ts @@ -1,5 +1,5 @@ -import { ThemeContext } from '@/contexts/ThemeContext'; import { useContext, useLayoutEffect } from 'react'; +import { ThemeContext } from '@/shared/contexts/ThemeContext'; export const useTheme = () => { const { theme, setTheme } = useContext(ThemeContext); diff --git a/apps/client/src/hooks/useToast.ts b/apps/client/src/shared/lib/useToast.ts similarity index 96% rename from apps/client/src/hooks/useToast.ts rename to apps/client/src/shared/lib/useToast.ts index 4d8b1e64..4b55fe82 100644 --- a/apps/client/src/hooks/useToast.ts +++ b/apps/client/src/shared/lib/useToast.ts @@ -1,9 +1,10 @@ +/* eslint-disable */ 'use client'; // Inspired by react-hot-toast library import * as React from 'react'; -import type { ToastActionElement, ToastProps } from '@/components/ui/toast'; +import type { ToastActionElement, ToastProps } from '@/shared/ui/shadcn/toast'; const TOAST_LIMIT = 1; const TOAST_REMOVE_DELAY = 1000000; @@ -64,7 +65,7 @@ const addToRemoveQueue = (toastId: string) => { toastTimeouts.delete(toastId); dispatch({ type: 'REMOVE_TOAST', - toastId: toastId, + toastId, }); }, TOAST_REMOVE_DELAY); @@ -160,7 +161,7 @@ function toast({ ...props }: Toast) { }); return { - id: id, + id, dismiss, update, }; diff --git a/apps/client/src/shared/lib/utils.ts b/apps/client/src/shared/lib/utils.ts new file mode 100644 index 00000000..ae429998 --- /dev/null +++ b/apps/client/src/shared/lib/utils.ts @@ -0,0 +1,13 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +export const checkDependencies = (functionName: string, dependencies: { [key: string]: any }) => { + const missing = dependencies.keys(dependencies).filter((key: string) => !dependencies[key]); + + if (missing.length === 0) return null; + return new Error(`${functionName} Error: ${missing.join(',')}이(가) 없습니다.`); +}; diff --git a/apps/client/src/shared/types/index.ts b/apps/client/src/shared/types/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/apps/client/src/types/mediasoupTypes.ts b/apps/client/src/shared/types/mediasoupTypes.ts similarity index 54% rename from apps/client/src/types/mediasoupTypes.ts rename to apps/client/src/shared/types/mediasoupTypes.ts index 6c6cc9a3..a3c63c0c 100644 --- a/apps/client/src/types/mediasoupTypes.ts +++ b/apps/client/src/shared/types/mediasoupTypes.ts @@ -1,20 +1,14 @@ import { DtlsParameters, IceCandidate, IceParameters } from 'mediasoup-client/lib/types'; -export interface TransportInfo { +export type TransportInfo = { transportId: string; isProducer: boolean; iceParameters: IceParameters; iceCandidates: IceCandidate[]; dtlsParameters: DtlsParameters; -} +}; -export interface ConnectTransportResponse { +export type ConnectTransportResponse = { connected: boolean; isProducer: boolean; -} - -export interface Tracks { - video: MediaStreamTrack | undefined; - mediaAudio: MediaStreamTrack | undefined; - screenAudio: MediaStreamTrack | undefined; -} +}; diff --git a/apps/client/src/shared/types/sharedTypes.ts b/apps/client/src/shared/types/sharedTypes.ts new file mode 100644 index 00000000..bc748519 --- /dev/null +++ b/apps/client/src/shared/types/sharedTypes.ts @@ -0,0 +1 @@ +export type Field = 'WEB' | 'AND' | 'IOS' | ''; diff --git a/apps/client/src/components/FloatingButton/index.tsx b/apps/client/src/shared/ui/FloatingButton.tsx similarity index 61% rename from apps/client/src/components/FloatingButton/index.tsx rename to apps/client/src/shared/ui/FloatingButton.tsx index c111154c..c60b7217 100644 --- a/apps/client/src/components/FloatingButton/index.tsx +++ b/apps/client/src/shared/ui/FloatingButton.tsx @@ -1,8 +1,8 @@ -import { Button } from '@components/ui/button'; -import { ThemeIcon } from '@components/Icons'; -import { useTheme } from '@/hooks/useTheme'; +import { Button } from '@/shared/ui/shadcn/button'; +import { ThemeIcon } from '@/shared/ui/Icons'; +import { useTheme } from '@/shared/lib/useTheme'; -function FloatingButton() { +export function FloatingButton() { const { convertTheme } = useTheme(); return ( @@ -16,5 +16,3 @@ function FloatingButton() {
); } - -export default FloatingButton; diff --git a/apps/client/src/components/IconButton/index.tsx b/apps/client/src/shared/ui/IconButton.tsx similarity index 76% rename from apps/client/src/components/IconButton/index.tsx rename to apps/client/src/shared/ui/IconButton.tsx index de50e95b..62fe9d42 100644 --- a/apps/client/src/components/IconButton/index.tsx +++ b/apps/client/src/shared/ui/IconButton.tsx @@ -1,15 +1,16 @@ -interface IconButtonProps { +type IconButtonProps = Readonly<{ children: React.ReactNode; title?: string; ariaLabel?: string; onClick?: () => void; disabled?: boolean; className?: string; -} +}>; -function IconButton({ children, title, ariaLabel, onClick, disabled, className }: IconButtonProps) { +export function IconButton({ children, title, ariaLabel, onClick, disabled, className }: IconButtonProps) { return (
); } - -export default Modal; diff --git a/apps/client/src/components/Icons/Character.tsx b/apps/client/src/shared/ui/character/DefaultCharacter.tsx similarity index 94% rename from apps/client/src/components/Icons/Character.tsx rename to apps/client/src/shared/ui/character/DefaultCharacter.tsx index 6c41c56b..d9ae1214 100644 --- a/apps/client/src/components/Icons/Character.tsx +++ b/apps/client/src/shared/ui/character/DefaultCharacter.tsx @@ -1,9 +1,9 @@ -interface IconProps { +type IconProps = Readonly<{ size?: number; className?: string; -} +}>; -function Character({ size = 24, className = '' }: IconProps) { +export function DefaultCharacter({ size = 24, className = '' }: IconProps) { return ( @@ -30,5 +30,3 @@ function Character({ size = 24, className = '' }: IconProps) { ); } - -export default Character; diff --git a/apps/client/src/components/ErrorCharacter/index.tsx b/apps/client/src/shared/ui/character/ErrorCharacter.tsx similarity index 95% rename from apps/client/src/components/ErrorCharacter/index.tsx rename to apps/client/src/shared/ui/character/ErrorCharacter.tsx index b22591bb..2a9cdc0f 100644 --- a/apps/client/src/components/ErrorCharacter/index.tsx +++ b/apps/client/src/shared/ui/character/ErrorCharacter.tsx @@ -1,9 +1,9 @@ -interface ErrorCharacterProps { +type ErrorCharacterProps = Readonly<{ size?: number; message?: string; -} +}>; -const ErrorCharacter = ({ size = 300, message = 'Error' }: ErrorCharacterProps): JSX.Element => { +export function ErrorCharacter({ size = 300, message = 'Error' }: ErrorCharacterProps) { return (
@@ -82,6 +82,4 @@ const ErrorCharacter = ({ size = 300, message = 'Error' }: ErrorCharacterProps):

{message}

); -}; - -export default ErrorCharacter; +} diff --git a/apps/client/src/components/LoadingCharacter/index.tsx b/apps/client/src/shared/ui/character/LoadingCharacter.tsx similarity index 84% rename from apps/client/src/components/LoadingCharacter/index.tsx rename to apps/client/src/shared/ui/character/LoadingCharacter.tsx index d7cb3771..9d25c2fd 100644 --- a/apps/client/src/components/LoadingCharacter/index.tsx +++ b/apps/client/src/shared/ui/character/LoadingCharacter.tsx @@ -1,54 +1,54 @@ -interface LoadingCharacterProps { +type LoadingCharacterProps = Readonly<{ size?: number; -} +}>; -const LoadingCharacter = ({ size = 300 }: LoadingCharacterProps): JSX.Element => { +export function LoadingCharacter({ size = 300 }: LoadingCharacterProps) { return (
- {/*-- Shadow */} + {/* -- Shadow */} - {/*-- Body */} + {/* -- Body */} - {/*-- Left Ear/Antenna */} + {/* -- Left Ear/Antenna */} - {/*-- Right Ear/Antenna */} + {/* -- Right Ear/Antenna */} - {/*-- Camera Lens/Eye */} + {/* -- Camera Lens/Eye */} - {/*-- Smile (static) */} + {/* -- Smile (static) */} - {/*-- Blush */} + {/* -- Blush */} - {/*-- Legs */} + {/* -- Legs */} - {/*-- Arms holding display */} + {/* -- Arms holding display */} - {/*-- Display */} + {/* -- Display */} - {/*-- Loading Spinner */} + {/* -- Loading Spinner */} @@ -63,7 +63,7 @@ const LoadingCharacter = ({ size = 300 }: LoadingCharacterProps): JSX.Element => - {/*-- Loading Text */} + {/* -- Loading Text */}
); -}; - -export default LoadingCharacter; +} diff --git a/apps/client/src/components/Icons/MoveCharacter.tsx b/apps/client/src/shared/ui/character/MoveCharacter.tsx similarity index 62% rename from apps/client/src/components/Icons/MoveCharacter.tsx rename to apps/client/src/shared/ui/character/MoveCharacter.tsx index 1479135d..bf8dd8fd 100644 --- a/apps/client/src/components/Icons/MoveCharacter.tsx +++ b/apps/client/src/shared/ui/character/MoveCharacter.tsx @@ -1,7 +1,8 @@ -function MoveCharacter() { +export function MoveCharacter() { return ( - + {/* Initial transform to set starting position */} + - + {/* Shadow with initial path */} + + {/* Body */} - + - + {/* Arms with initial positions */} + - + + {/* Antennae group */} + {/* Left antenna */} - - - + + + + {/* Right antenna */} - - - + + + + {/* Face elements */} - - + {/* Legs with initial positions */} + + - + ); } - -export default MoveCharacter; diff --git a/apps/client/src/shared/ui/character/index.ts b/apps/client/src/shared/ui/character/index.ts new file mode 100644 index 00000000..036e7c11 --- /dev/null +++ b/apps/client/src/shared/ui/character/index.ts @@ -0,0 +1,4 @@ +export { DefaultCharacter } from './DefaultCharacter'; +export { ErrorCharacter } from './ErrorCharacter'; +export { LoadingCharacter } from './LoadingCharacter'; +export { MoveCharacter } from './MoveCharacter'; diff --git a/apps/client/src/shared/ui/index.ts b/apps/client/src/shared/ui/index.ts new file mode 100644 index 00000000..91ebf9ae --- /dev/null +++ b/apps/client/src/shared/ui/index.ts @@ -0,0 +1,28 @@ +export { DefaultCharacter, ErrorCharacter, LoadingCharacter, MoveCharacter } from './character'; +export { FloatingButton } from './FloatingButton'; +export { IconButton } from './IconButton'; +export { Modal } from './Modal'; +export { + BlogIcon, + CloseIcon, + EditIcon, + ExpandIcon, + GithubIcon, + GoogleIcon, + LinkedInIcon, + Logo, + MailIcon, + MicrophoneOffIcon, + MicrophoneOnIcon, + PauseIcon, + PlayIcon, + ScreenShareIcon, + ScreenShareOffIcon, + SearchIcon, + SmileIcon, + ThemeIcon, + VideoOffIcon, + VideoOnIcon, + VolumeOffIcon, + VolumeOnIcon, +} from './Icons'; diff --git a/apps/client/src/components/ui/avatar.tsx b/apps/client/src/shared/ui/shadcn/avatar.tsx similarity index 97% rename from apps/client/src/components/ui/avatar.tsx rename to apps/client/src/shared/ui/shadcn/avatar.tsx index 6807a16f..318d12a1 100644 --- a/apps/client/src/components/ui/avatar.tsx +++ b/apps/client/src/shared/ui/shadcn/avatar.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as AvatarPrimitive from '@radix-ui/react-avatar'; -import { cn } from '@/utils/utils'; +import { cn } from '@/shared/lib/utils'; const Avatar = React.forwardRef< React.ElementRef, diff --git a/apps/client/src/components/ui/badge.tsx b/apps/client/src/shared/ui/shadcn/badge.tsx similarity index 96% rename from apps/client/src/components/ui/badge.tsx rename to apps/client/src/shared/ui/shadcn/badge.tsx index 74d3a0ff..c77b7185 100644 --- a/apps/client/src/components/ui/badge.tsx +++ b/apps/client/src/shared/ui/shadcn/badge.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { cva, type VariantProps } from 'class-variance-authority'; -import { cn } from '@/utils/utils'; +import { cn } from '@/shared/lib/utils'; const badgeVariants = cva( 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', diff --git a/apps/client/src/components/ui/button.tsx b/apps/client/src/shared/ui/shadcn/button.tsx similarity index 97% rename from apps/client/src/components/ui/button.tsx rename to apps/client/src/shared/ui/shadcn/button.tsx index 466585c7..c1624fcb 100644 --- a/apps/client/src/components/ui/button.tsx +++ b/apps/client/src/shared/ui/shadcn/button.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { Slot } from '@radix-ui/react-slot'; import { cva, type VariantProps } from 'class-variance-authority'; -import { cn } from '@/utils/utils'; +import { cn } from '@/shared/lib/utils'; const buttonVariants = cva( 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', diff --git a/apps/client/src/components/ui/card.tsx b/apps/client/src/shared/ui/shadcn/card.tsx similarity index 97% rename from apps/client/src/components/ui/card.tsx rename to apps/client/src/shared/ui/shadcn/card.tsx index d140013f..31d875bd 100644 --- a/apps/client/src/components/ui/card.tsx +++ b/apps/client/src/shared/ui/shadcn/card.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { cn } from '@/utils/utils'; +import { cn } from '@/shared/lib/utils'; const Card = React.forwardRef>(({ className, ...props }, ref) => (
diff --git a/apps/client/src/shared/ui/shadcn/index.ts b/apps/client/src/shared/ui/shadcn/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/apps/client/src/shared/ui/shadcn/input.tsx b/apps/client/src/shared/ui/shadcn/input.tsx new file mode 100644 index 00000000..90750234 --- /dev/null +++ b/apps/client/src/shared/ui/shadcn/input.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; + +import { cn } from '@/shared/lib/utils'; + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => ( + + ), +); +Input.displayName = 'Input'; + +export { Input }; diff --git a/apps/client/src/components/ui/scroll-area.tsx b/apps/client/src/shared/ui/shadcn/scroll-area.tsx similarity index 97% rename from apps/client/src/components/ui/scroll-area.tsx rename to apps/client/src/shared/ui/shadcn/scroll-area.tsx index abf0f1d2..777213cf 100644 --- a/apps/client/src/components/ui/scroll-area.tsx +++ b/apps/client/src/shared/ui/shadcn/scroll-area.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; -import { cn } from '@/utils/utils'; +import { cn } from '@/shared/lib/utils'; const ScrollArea = React.forwardRef< React.ElementRef, diff --git a/apps/client/src/components/ui/select.tsx b/apps/client/src/shared/ui/shadcn/select.tsx similarity index 99% rename from apps/client/src/components/ui/select.tsx rename to apps/client/src/shared/ui/shadcn/select.tsx index dd1cf276..1797f50a 100644 --- a/apps/client/src/components/ui/select.tsx +++ b/apps/client/src/shared/ui/shadcn/select.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as SelectPrimitive from '@radix-ui/react-select'; import { Check, ChevronDown, ChevronUp } from 'lucide-react'; -import { cn } from '@/utils/utils'; +import { cn } from '@/shared/lib/utils'; const Select = SelectPrimitive.Root; diff --git a/apps/client/src/components/ui/toast.tsx b/apps/client/src/shared/ui/shadcn/toast.tsx similarity index 95% rename from apps/client/src/components/ui/toast.tsx rename to apps/client/src/shared/ui/shadcn/toast.tsx index da67acd7..2304403e 100644 --- a/apps/client/src/components/ui/toast.tsx +++ b/apps/client/src/shared/ui/shadcn/toast.tsx @@ -3,7 +3,7 @@ import * as ToastPrimitives from '@radix-ui/react-toast'; import { cva, type VariantProps } from 'class-variance-authority'; import { X } from 'lucide-react'; -import { cn } from '@utils/utils'; +import { cn } from '@/shared/lib/utils'; const ToastProvider = ToastPrimitives.Provider; @@ -40,9 +40,9 @@ const toastVariants = cva( const Toast = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & VariantProps ->(({ className, variant, ...props }, ref) => { - return ; -}); +>(({ className, variant, ...props }, ref) => ( + +)); Toast.displayName = ToastPrimitives.Root.displayName; const ToastAction = React.forwardRef< diff --git a/apps/client/src/shared/ui/shadcn/toaster.tsx b/apps/client/src/shared/ui/shadcn/toaster.tsx new file mode 100644 index 00000000..20552819 --- /dev/null +++ b/apps/client/src/shared/ui/shadcn/toaster.tsx @@ -0,0 +1,29 @@ +import { useToast } from '@/shared/lib/useToast'; +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from '@/shared/ui/shadcn/toast'; + +export function Toaster() { + const { toasts } = useToast(); + + return ( + + {toasts.map(({ id, title, description, action, ...props }) => ( + +
+ {title && {title}} + {description && {description}} +
+ {action} + +
+ ))} + +
+ ); +} diff --git a/apps/client/src/utils/utils.ts b/apps/client/src/utils/utils.ts deleted file mode 100644 index 9d3dc04c..00000000 --- a/apps/client/src/utils/utils.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { clsx, type ClassValue } from 'clsx'; -import { twMerge } from 'tailwind-merge'; - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} - -export const checkDependencies = (functionName: string, dependencies: { [key: string]: any }) => { - const missing = dependencies.keys(dependencies).filter((key: string) => !dependencies[key]); - - if (missing.length === 0) return null; - return new Error(`${functionName} Error: ${missing.join(',')}이(가) 없습니다.`); -}; - -export const getPayloadFromJWT = () => { - const token = localStorage.getItem('accessToken'); - if (!token) return undefined; - const base64Payload = token.split('.')[1]; - const base64 = base64Payload.replace(/-/g, '+').replace(/_/g, '/'); - - const decodedJWT = JSON.parse( - decodeURIComponent( - window - .atob(base64) - .split('') - .map(c => { - return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); - }) - .join(''), - ), - ); - return decodedJWT; -}; diff --git a/apps/client/src/pages/Home/Banner.tsx b/apps/client/src/widgets/Banner/Banner.tsx similarity index 82% rename from apps/client/src/pages/Home/Banner.tsx rename to apps/client/src/widgets/Banner/Banner.tsx index f6ffae8f..34b53304 100644 --- a/apps/client/src/pages/Home/Banner.tsx +++ b/apps/client/src/widgets/Banner/Banner.tsx @@ -1,7 +1,7 @@ -import MoveCharacter from '@/components/Icons/MoveCharacter'; -import Bookmark from './Bookmark'; +import { MoveCharacter } from '@/shared/ui'; +import { Bookmark } from './Bookmark'; -function Banner() { +export function Banner() { return (
@@ -19,5 +19,3 @@ function Banner() {
); } - -export default Banner; diff --git a/apps/client/src/pages/Home/Bookmark.tsx b/apps/client/src/widgets/Banner/Bookmark.tsx similarity index 62% rename from apps/client/src/pages/Home/Bookmark.tsx rename to apps/client/src/widgets/Banner/Bookmark.tsx index a077d5a6..f9a80846 100644 --- a/apps/client/src/pages/Home/Bookmark.tsx +++ b/apps/client/src/widgets/Banner/Bookmark.tsx @@ -1,20 +1,14 @@ -import Modal from '@components/Modal'; -import { Button } from '@components/ui/button'; import { createPortal } from 'react-dom'; import { useForm } from 'react-hook-form'; -import { useToast } from '@hooks/useToast'; -import { AuthContext } from '@contexts/AuthContext'; -import axiosInstance from '@services/axios'; import { useContext, useEffect, useState } from 'react'; -import { CloseIcon } from '@components/Icons'; +import { Modal, CloseIcon } from '@/shared/ui'; +import { Button } from '@/shared/ui/shadcn/button'; +import { useToast } from '@/shared/lib'; +import { AuthContext } from '@/shared/contexts'; +import { axiosInstance } from '@/shared/api'; +import { BookmarkData } from './types'; -interface BookmarkData { - bookmarkId: number; - name: string; - url: string; -} - -function Bookmark() { +export function Bookmark() { const { isLoggedIn } = useContext(AuthContext); const [bookmarkList, setBookmarkList] = useState([]); const [showModal, setShowModal] = useState(false); @@ -71,28 +65,33 @@ function Bookmark() { toast({ variant: 'destructive', title: '북마크 조회 실패' }); } }); - }, []); + }, [toast]); return ( <> {isLoggedIn && (
- {bookmarkList && - bookmarkList.map(data => ( - - ))} + + +
+ ))} {bookmarkList.length < 5 && ( -
@@ -70,5 +71,3 @@ function LogInButton() { ); } - -export default LogInButton; diff --git a/apps/client/src/widgets/Header/LogoButton.tsx b/apps/client/src/widgets/Header/LogoButton.tsx new file mode 100644 index 00000000..bc833fb4 --- /dev/null +++ b/apps/client/src/widgets/Header/LogoButton.tsx @@ -0,0 +1,21 @@ +import { useNavigate } from 'react-router-dom'; +import { Logo } from '@/shared/ui/Icons'; +import { DefaultCharacter } from '@/shared/ui'; + +export function LogoButton() { + const navigate = useNavigate(); + const handleLogoClick = () => { + if (window.location.pathname === '/') { + window.location.reload(); + } else { + navigate('/'); + } + }; + + return ( + + ); +} diff --git a/apps/client/src/components/WelcomeCharacter/index.tsx b/apps/client/src/widgets/Header/WelcomeCharacter.tsx similarity index 95% rename from apps/client/src/components/WelcomeCharacter/index.tsx rename to apps/client/src/widgets/Header/WelcomeCharacter.tsx index 2d743d8c..e8c83e6d 100644 --- a/apps/client/src/components/WelcomeCharacter/index.tsx +++ b/apps/client/src/widgets/Header/WelcomeCharacter.tsx @@ -1,9 +1,9 @@ -interface Props { +type Props = Readonly<{ size?: number; className?: string; -} +}>; -function WelcomeCharacter({ size, className }: Props) { +export function WelcomeCharacter({ size, className }: Props) { return ( {/* Shadow */} @@ -32,7 +32,7 @@ function WelcomeCharacter({ size, className }: Props) { - {/*} Left Ear/Antenna */} + {/* } Left Ear/Antenna */} @@ -60,7 +60,7 @@ function WelcomeCharacter({ size, className }: Props) { - {/*} Happy Smile */} + {/* } Happy Smile */} ); } -export default WelcomeCharacter; diff --git a/apps/client/src/widgets/Header/index.tsx b/apps/client/src/widgets/Header/index.tsx new file mode 100644 index 00000000..29429dc9 --- /dev/null +++ b/apps/client/src/widgets/Header/index.tsx @@ -0,0 +1 @@ +export { Header } from './Header'; diff --git a/apps/client/src/pages/Home/FieldFilter.tsx b/apps/client/src/widgets/LiveList/FieldFilter.tsx similarity index 79% rename from apps/client/src/pages/Home/FieldFilter.tsx rename to apps/client/src/widgets/LiveList/FieldFilter.tsx index 0320cd19..c33ea3da 100644 --- a/apps/client/src/pages/Home/FieldFilter.tsx +++ b/apps/client/src/widgets/LiveList/FieldFilter.tsx @@ -1,14 +1,14 @@ -import { Button } from '@/components/ui/button'; -import { Field } from '@/types/liveTypes'; import { useState } from 'react'; +import { Button } from '@/shared/ui/shadcn/button'; +import { Field } from '@/shared/types/sharedTypes'; const fields: Field[] = ['WEB', 'AND', 'IOS']; -interface FieldFilterProps { +type FieldFilterProps = Readonly<{ onClickFilterButton: (field: Field) => void; -} +}>; -function FieldFilter({ onClickFilterButton }: FieldFilterProps) { +export function FieldFilter({ onClickFilterButton }: FieldFilterProps) { const [selected, setSelected] = useState(''); const handleClick = (field: Field) => { @@ -35,5 +35,3 @@ function FieldFilter({ onClickFilterButton }: FieldFilterProps) {
); } - -export default FieldFilter; diff --git a/apps/client/src/pages/Home/LiveCard.tsx b/apps/client/src/widgets/LiveList/LiveCard.tsx similarity index 61% rename from apps/client/src/pages/Home/LiveCard.tsx rename to apps/client/src/widgets/LiveList/LiveCard.tsx index 4c2ec430..33544701 100644 --- a/apps/client/src/pages/Home/LiveCard.tsx +++ b/apps/client/src/widgets/LiveList/LiveCard.tsx @@ -1,14 +1,14 @@ import { useNavigate } from 'react-router-dom'; -interface LiveCardProps { +type LiveCardProps = Readonly<{ liveId: string; title: string; userId: string; profileUrl?: string; thumbnailUrl: string; -} +}>; -const LiveCard = ({ liveId, title, userId, profileUrl, thumbnailUrl }: LiveCardProps) => { +export function LiveCard({ liveId, title, userId, profileUrl, thumbnailUrl }: LiveCardProps) { const navigate = useNavigate(); const handleClick = () => { @@ -16,9 +16,10 @@ const LiveCard = ({ liveId, title, userId, profileUrl, thumbnailUrl }: LiveCardP }; return ( -
{/* 썸네일 */}
@@ -32,18 +33,16 @@ const LiveCard = ({ liveId, title, userId, profileUrl, thumbnailUrl }: LiveCardP
{/* 방송 정보 */} -
+
{profileUrl && {userId}}
-

{title}

-

{userId}

+

{title}

+

{userId}

-
+ ); -}; - -export default LiveCard; +} diff --git a/apps/client/src/pages/Home/LiveList.tsx b/apps/client/src/widgets/LiveList/LiveList.tsx similarity index 71% rename from apps/client/src/pages/Home/LiveList.tsx rename to apps/client/src/widgets/LiveList/LiveList.tsx index eeb5425c..e3cd1894 100644 --- a/apps/client/src/pages/Home/LiveList.tsx +++ b/apps/client/src/widgets/LiveList/LiveList.tsx @@ -1,28 +1,21 @@ -import FieldFilter from './FieldFilter'; -import LiveCard from './LiveCard'; -import { LivePreviewInfo } from '@/types/homeTypes'; -import { useEffect, useState } from 'react'; -import axiosInstance from '@services/axios'; -import Search from './Search'; -import { Field } from '@/types/liveTypes'; -import { useIntersect } from '@/hooks/useIntersect'; +import { useCallback, useEffect, useState } from 'react'; +import { axiosInstance } from '@/shared/api'; +import { FieldFilter } from './FieldFilter'; +import { LiveCard } from './LiveCard'; +import { LivePreviewInfo } from '@/pages/Home/model/homeTypes'; +import { Search } from './Search'; +import { Field } from '@/shared/types/sharedTypes'; +import { useIntersect } from '@/pages/Home/model'; const LIMIT = 12; -function LiveList() { +export function LiveList() { const [liveList, setLiveList] = useState([]); const [hasNext, setHasNext] = useState(true); const [cursor, setCursor] = useState(null); const [field, setField] = useState(''); - const ref = useIntersect({ - onIntersect: (entry, observer) => { - observer.unobserve(entry.target); - if (hasNext && cursor) getLiveList(); - }, - options: { threshold: 0.3 }, - }); - const getLiveList = () => { + const getLiveList = useCallback(() => { axiosInstance.get('/v1/broadcasts', { params: { field, cursor, limit: LIMIT } }).then(response => { if (response.data.success) { const { broadcasts, nextCursor } = response.data.data; @@ -31,17 +24,27 @@ function LiveList() { if (!nextCursor) setHasNext(false); } }); - }; + }, [field, cursor]); - const hanldeFilterField = (field: Field) => { - axiosInstance.get('/v1/broadcasts', { params: { field, cursor: null, limit: LIMIT } }).then(response => { - if (response.data.success) { - const { broadcasts, nextCursor } = response.data.data; - setLiveList(broadcasts); - setCursor(nextCursor); - setHasNext(nextCursor ? true : false); - } - }); + const ref = useIntersect({ + onIntersect: (entry, observer) => { + observer.unobserve(entry.target); + if (hasNext && cursor) getLiveList(); + }, + options: { threshold: 0.3 }, + }); + + const hanldeFilterField = (selectedField: Field) => { + axiosInstance + .get('/v1/broadcasts', { params: { field: selectedField, cursor: null, limit: LIMIT } }) + .then(response => { + if (response.data.success) { + const { broadcasts, nextCursor } = response.data.data; + setLiveList(broadcasts); + setCursor(nextCursor); + setHasNext(!!nextCursor); + } + }); }; const handleSearch = (keyword: string) => { @@ -56,7 +59,7 @@ function LiveList() { useEffect(() => { getLiveList(); - }, []); + }, [getLiveList]); return (
@@ -85,10 +88,8 @@ function LiveList() {
방송 정보가 없습니다.
)}
-
+
); } - -export default LiveList; diff --git a/apps/client/src/pages/Home/Search.tsx b/apps/client/src/widgets/LiveList/Search.tsx similarity index 78% rename from apps/client/src/pages/Home/Search.tsx rename to apps/client/src/widgets/LiveList/Search.tsx index 50495377..d3fa7552 100644 --- a/apps/client/src/pages/Home/Search.tsx +++ b/apps/client/src/widgets/LiveList/Search.tsx @@ -1,16 +1,15 @@ -import IconButton from '@/components/IconButton'; -import { SearchIcon } from '@/components/Icons'; import { useForm } from 'react-hook-form'; +import { IconButton, SearchIcon } from '@/shared/ui'; -interface SearchProps { +type SearchProps = Readonly<{ onSearch: (keyword: string) => void; -} +}>; -interface FormInput { +type FormInput = { keyword: string; -} +}; -function Search({ onSearch }: SearchProps) { +export function Search({ onSearch }: SearchProps) { const { register, handleSubmit } = useForm(); const hanldeSearchSubmit = ({ keyword }: FormInput) => { @@ -35,5 +34,3 @@ function Search({ onSearch }: SearchProps) {
); } - -export default Search; diff --git a/apps/client/src/widgets/LiveList/index.ts b/apps/client/src/widgets/LiveList/index.ts new file mode 100644 index 00000000..e761f109 --- /dev/null +++ b/apps/client/src/widgets/LiveList/index.ts @@ -0,0 +1 @@ +export { LiveList } from './LiveList'; diff --git a/apps/client/src/widgets/index.ts b/apps/client/src/widgets/index.ts new file mode 100644 index 00000000..635bafaa --- /dev/null +++ b/apps/client/src/widgets/index.ts @@ -0,0 +1,3 @@ +export { Banner } from './Banner'; +export { LiveList } from './LiveList'; +export { Header } from './Header'; diff --git a/apps/client/tailwind.config.js b/apps/client/tailwind.config.js index 505c72f4..b32e7b12 100644 --- a/apps/client/tailwind.config.js +++ b/apps/client/tailwind.config.js @@ -1,4 +1,5 @@ /** @type {import('tailwindcss').Config} */ +import * as tailwindAnimate from 'tailwindcss-animate'; export default { darkMode: ['class'], @@ -100,5 +101,5 @@ export default { }, }, }, - plugins: [require('tailwindcss-animate')], + plugins: [tailwindAnimate], }; diff --git a/apps/client/tsconfig.json b/apps/client/tsconfig.json index cc54801b..a78c09ee 100644 --- a/apps/client/tsconfig.json +++ b/apps/client/tsconfig.json @@ -18,14 +18,14 @@ "baseUrl": ".", "paths": { "@/*": ["./src/*"], - "@assets/*": ["./src/assets/*"], - "@components/*": ["./src/components/*"], - "@contexts/*": ["./src/contexts/*"], - "@hooks/*": ["./src/hooks/*"], - "@services/*": ["./src/services/*"], - "@pages/*": ["./src/pages/*"], - "@utils/*": ["./src/utils/*"], - "@constants/*": ["./src/constants/*"] + "@assets/*": ["src/assets/*"], + "@components/*": ["src/components/*"], + "@contexts/*": ["src/app/providers/theme/*"], + "@hooks/*": ["src/shared/hooks/*"], + "@services/*": ["src/shared/api/*"], + "@pages/*": ["src/pages/*"], + "@utils/*": ["src/shared/lib/utils/*"], + "@constants/*": ["src/shared/constants/*"] }, "outDir": "./dist" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98038132..3ad0663d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -348,6 +348,12 @@ importers: '@types/react-dom': specifier: ^18.3.1 version: 18.3.1 + '@typescript-eslint/eslint-plugin': + specifier: ^7.18.0 + version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1)(typescript@5.6.3) + '@typescript-eslint/parser': + specifier: ^7.18.0 + version: 7.18.0(eslint@8.57.1)(typescript@5.6.3) '@vitejs/plugin-react-swc': specifier: ^3.5.0 version: 3.7.1(vite@5.4.10(@types/node@20.17.6)(terser@5.36.0)) @@ -357,9 +363,21 @@ importers: eslint: specifier: '*' version: 8.57.1 + eslint-config-airbnb: + specifier: ^19.0.4 + version: 19.0.4(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint-plugin-jsx-a11y@6.10.2(eslint@8.57.1))(eslint-plugin-react-hooks@5.0.0(eslint@8.57.1))(eslint-plugin-react@7.37.2(eslint@8.57.1))(eslint@8.57.1) + eslint-config-airbnb-typescript: + specifier: ^18.0.0 + version: 18.0.0(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1)(typescript@5.6.3))(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.3))(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint@8.57.1) eslint-config-prettier: specifier: '*' version: 8.10.0(eslint@8.57.1) + eslint-plugin-import: + specifier: ^2.31.0 + version: 2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1) + eslint-plugin-jsx-a11y: + specifier: ^6.10.2 + version: 6.10.2(eslint@8.57.1) eslint-plugin-prettier: specifier: '*' version: 4.2.1(eslint-config-prettier@8.10.0(eslint@8.57.1))(eslint@8.57.1)(prettier@2.8.8) @@ -378,9 +396,15 @@ importers: prettier: specifier: '*' version: 2.8.8 + sonarqube-scanner: + specifier: ^4.2.6 + version: 4.2.6 tailwindcss: specifier: ^3.4.14 version: 3.4.14(ts-node@10.9.2(@swc/core@1.8.0)(@types/node@20.17.6)(typescript@5.6.3)) + typescript: + specifier: '*' + version: 5.6.3 vite: specifier: ^5.4.10 version: 5.4.10(@types/node@20.17.6)(terser@5.36.0) @@ -2361,6 +2385,9 @@ packages: cpu: [x64] os: [win32] + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@scarf/scarf@1.4.0': resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} @@ -2754,6 +2781,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/jsonwebtoken@9.0.5': resolution: {integrity: sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==} @@ -2857,6 +2887,17 @@ packages: typescript: optional: true + '@typescript-eslint/eslint-plugin@7.18.0': + resolution: {integrity: sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + '@typescript-eslint/parser': ^7.0.0 + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + '@typescript-eslint/parser@6.21.0': resolution: {integrity: sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==} engines: {node: ^16.0.0 || >=18.0.0} @@ -2867,10 +2908,24 @@ packages: typescript: optional: true + '@typescript-eslint/parser@7.18.0': + resolution: {integrity: sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + '@typescript-eslint/scope-manager@6.21.0': resolution: {integrity: sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==} engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/scope-manager@7.18.0': + resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==} + engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/type-utils@6.21.0': resolution: {integrity: sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==} engines: {node: ^16.0.0 || >=18.0.0} @@ -2881,10 +2936,24 @@ packages: typescript: optional: true + '@typescript-eslint/type-utils@7.18.0': + resolution: {integrity: sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + '@typescript-eslint/types@6.21.0': resolution: {integrity: sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==} engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/types@7.18.0': + resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==} + engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/typescript-estree@6.21.0': resolution: {integrity: sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==} engines: {node: ^16.0.0 || >=18.0.0} @@ -2894,16 +2963,35 @@ packages: typescript: optional: true + '@typescript-eslint/typescript-estree@7.18.0': + resolution: {integrity: sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + '@typescript-eslint/utils@6.21.0': resolution: {integrity: sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 + '@typescript-eslint/utils@7.18.0': + resolution: {integrity: sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + '@typescript-eslint/visitor-keys@6.21.0': resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==} engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/visitor-keys@7.18.0': + resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==} + engines: {node: ^18.18.0 || >=20.0.0} + '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} @@ -2985,6 +3073,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adm-zip@0.5.12: + resolution: {integrity: sha512-6TVU49mK6KZb4qG6xWaaM4C7sA/sgUMLy/JYMOzkcp3BvVLpW0fXDFQiIzAuxFCt/2+xD7fNIiPFAoLZPhVNLQ==} + engines: {node: '>=6.0'} + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -3085,6 +3177,10 @@ packages: resolution: {integrity: sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==} engines: {node: '>=10'} + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + array-buffer-byte-length@1.0.1: resolution: {integrity: sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==} engines: {node: '>= 0.4'} @@ -3107,6 +3203,10 @@ packages: resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} engines: {node: '>= 0.4'} + array.prototype.findlastindex@1.2.5: + resolution: {integrity: sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==} + engines: {node: '>= 0.4'} + array.prototype.flat@1.3.2: resolution: {integrity: sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==} engines: {node: '>= 0.4'} @@ -3126,6 +3226,9 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + ast-types-flow@0.0.8: + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + async@0.2.10: resolution: {integrity: sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==} @@ -3158,9 +3261,20 @@ packages: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} + axe-core@4.10.2: + resolution: {integrity: sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w==} + engines: {node: '>=4'} + axios@1.7.7: resolution: {integrity: sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==} + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + b4a@1.6.7: + resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==} + babel-jest@29.7.0: resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3204,6 +3318,9 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + bare-events@2.5.4: + resolution: {integrity: sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -3460,6 +3577,10 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@12.0.0: + resolution: {integrity: sha512-MwVNWlYjDTtOjX5PiD7o5pK0UrFU/OYgcJfjjK4RaHZETNtjJqrZa9Y9ds88+A+f+d5lv+561eZ+yCKoS3gbAA==} + engines: {node: '>=18'} + commander@12.1.0: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} @@ -3490,6 +3611,9 @@ packages: resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} engines: {'0': node >= 0.8} + confusing-browser-globals@1.0.11: + resolution: {integrity: sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==} + consola@2.15.3: resolution: {integrity: sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==} @@ -3590,6 +3714,9 @@ packages: resolution: {integrity: sha512-Wa3cgG4+IC9zqezwozLbVBIiJ7Cjg1J2hPxvXcp1F3SFjzQzbdiGhOtI1thVfRBnBCX3kvCW63iaP9v+4yYa8w==} hasBin: true + damerau-levenshtein@1.0.8: + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} @@ -3617,6 +3744,14 @@ packages: supports-color: optional: true + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.3.7: resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} engines: {node: '>=6.0'} @@ -3851,12 +3986,76 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + eslint-config-airbnb-base@15.0.0: + resolution: {integrity: sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==} + engines: {node: ^10.12.0 || >=12.0.0} + peerDependencies: + eslint: ^7.32.0 || ^8.2.0 + eslint-plugin-import: ^2.25.2 + + eslint-config-airbnb-typescript@18.0.0: + resolution: {integrity: sha512-oc+Lxzgzsu8FQyFVa4QFaVKiitTYiiW3frB9KYW5OWdPrqFc7FzxgB20hP4cHMlr+MBzGcLl3jnCOVOydL9mIg==} + peerDependencies: + '@typescript-eslint/eslint-plugin': ^7.0.0 + '@typescript-eslint/parser': ^7.0.0 + eslint: ^8.56.0 + + eslint-config-airbnb@19.0.4: + resolution: {integrity: sha512-T75QYQVQX57jiNgpF9r1KegMICE94VYwoFQyMGhrvc+lB8YF2E/M/PYDaQe1AJcWaEgqLE+ErXV1Og/+6Vyzew==} + engines: {node: ^10.12.0 || ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^7.32.0 || ^8.2.0 + eslint-plugin-import: ^2.25.3 + eslint-plugin-jsx-a11y: ^6.5.1 + eslint-plugin-react: ^7.28.0 + eslint-plugin-react-hooks: ^4.3.0 + eslint-config-prettier@8.10.0: resolution: {integrity: sha512-SM8AMJdeQqRYT9O9zguiruQZaN7+z+E4eAP9oiLNGKMtomwaB1E9dcgUD6ZAn/eQAb52USbvezbiljfZUhbJcg==} hasBin: true peerDependencies: eslint: '>=7.0.0' + eslint-import-resolver-node@0.3.9: + resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + + eslint-module-utils@2.12.0: + resolution: {integrity: sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-import@2.31.0: + resolution: {integrity: sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-jsx-a11y@6.10.2: + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} + engines: {node: '>=4.0'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 + eslint-plugin-prettier@4.2.1: resolution: {integrity: sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ==} engines: {node: '>=12.0.0'} @@ -3988,6 +4187,9 @@ packages: fast-diff@1.3.0: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.2: resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} engines: {node: '>=8.6.0'} @@ -4132,6 +4334,10 @@ packages: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} + fs-extra@11.2.0: + resolution: {integrity: sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==} + engines: {node: '>=14.14'} + fs-extra@9.1.0: resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} engines: {node: '>=10'} @@ -4306,6 +4512,10 @@ packages: resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} engines: {node: '>=0.10.0'} + hpagent@1.2.0: + resolution: {integrity: sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==} + engines: {node: '>=14'} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -4718,6 +4928,10 @@ packages: resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-sonar-reporter@2.0.0: + resolution: {integrity: sha512-ZervDCgEX5gdUbdtWsjdipLN3bKJwpxbvhkYNXTAYvAckCihobSLr9OT/IuyNIRT1EZMDDwR6DroWtrq+IL64w==} + engines: {node: '>=8.0.0'} + jest-util@29.7.0: resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -4783,6 +4997,10 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -4821,6 +5039,13 @@ packages: kuler@2.0.0: resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + language-subtag-registry@0.3.23: + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} + + language-tags@1.0.9: + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} + engines: {node: '>=0.10'} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -4944,6 +5169,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + lru-cache@7.18.3: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} @@ -5166,6 +5395,10 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-forge@1.3.1: + resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==} + engines: {node: '>= 6.13.0'} + node-gyp-build@4.8.3: resolution: {integrity: sha512-EMS95CMJzdoSKoIiXo8pxKoL8DYxwIZXYlLmgPb8KUv794abpnLK6ynsCAWNliOjREKruYKdzbh76HHYUHX7nw==} hasBin: true @@ -5228,6 +5461,10 @@ packages: resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} engines: {node: '>= 0.4'} + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + object.values@1.2.0: resolution: {integrity: sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==} engines: {node: '>= 0.4'} @@ -5489,6 +5726,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + properties-file@3.5.4: + resolution: {integrity: sha512-OGQPWZ4j9ENDKBl+wUHqNtzayGF5sLlVcmjcqEMUUHeCbUSggDndii+kjcBDPj3GQvqYB9sUEc4siX36wx4glw==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -5513,6 +5753,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + queue-tick@1.0.1: + resolution: {integrity: sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==} + randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} @@ -5792,6 +6035,11 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.6.0: + resolution: {integrity: sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==} + engines: {node: '>=10'} + hasBin: true + semver@7.6.3: resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} engines: {node: '>=10'} @@ -5867,6 +6115,10 @@ packages: resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==} engines: {node: '>=18'} + slugify@1.6.6: + resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==} + engines: {node: '>=8.0.0'} + socket.io-adapter@2.5.5: resolution: {integrity: sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==} @@ -5886,6 +6138,11 @@ packages: resolution: {integrity: sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==} engines: {node: '>=10.2.0'} + sonarqube-scanner@4.2.6: + resolution: {integrity: sha512-UK6mCGr290bKo6yML9fYOyLrvPkU7vmnYPLvTWVUIQpxiTbkPm4bmPvhcIcSBBH0dN+cKObcrne1E8zuEYl95g==} + engines: {node: '>= 18'} + hasBin: true + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -5929,6 +6186,9 @@ packages: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} + streamx@2.21.1: + resolution: {integrity: sha512-PhP9wUnFLa+91CPy3N6tiQsK+gnYyUNuk15S3YG/zjYE7RuPeCjJngqnzpC31ow0lzBHQ+QGO4cNJnd0djYUsw==} + string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -5953,6 +6213,10 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + string.prototype.includes@2.0.1: + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} + engines: {node: '>= 0.4'} + string.prototype.matchall@4.0.11: resolution: {integrity: sha512-NUdh0aDavY2og7IbBPenWqR9exH+E26Sv8e0/eTe1tltDGZL+GtBkDAnnyBtmekfK6/Dq3MkcGtzXFEd1LQrtg==} engines: {node: '>= 0.4'} @@ -6073,6 +6337,9 @@ packages: resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} engines: {node: '>=6'} + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + tar@7.4.3: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} @@ -6106,6 +6373,9 @@ packages: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} + text-decoder@1.2.3: + resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} + text-hex@1.0.0: resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} @@ -6210,6 +6480,9 @@ packages: resolution: {integrity: sha512-xWFISjviPydmtmgeUAuXp4N1fky+VCtfhOkDUFIv5ea7p4wuTomI4QTrXvFBX2S4jZsmyTSrStQl+E+4w+RzxA==} engines: {node: '>=10.13.0'} + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + tsconfig-paths@4.2.0: resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} engines: {node: '>=6'} @@ -6591,6 +6864,9 @@ packages: utf-8-validate: optional: true + xml@1.0.1: + resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} + xmlhttprequest-ssl@2.1.2: resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} engines: {node: '>=0.4.0'} @@ -9043,6 +9319,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.24.4': optional: true + '@rtsao/scc@1.1.0': {} + '@scarf/scarf@1.4.0': {} '@sinclair/typebox@0.27.8': {} @@ -9562,6 +9840,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/json5@0.0.29': {} + '@types/jsonwebtoken@9.0.5': dependencies: '@types/node': 20.17.6 @@ -9700,6 +9980,24 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1)(typescript@5.6.3)': + dependencies: + '@eslint-community/regexpp': 4.12.1 + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.6.3) + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/type-utils': 7.18.0(eslint@8.57.1)(typescript@5.6.3) + '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.6.3) + '@typescript-eslint/visitor-keys': 7.18.0 + eslint: 8.57.1 + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + ts-api-utils: 1.4.0(typescript@5.6.3) + optionalDependencies: + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3)': dependencies: '@typescript-eslint/scope-manager': 6.21.0 @@ -9713,11 +10011,29 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.3)': + dependencies: + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.6.3) + '@typescript-eslint/visitor-keys': 7.18.0 + debug: 4.3.7(supports-color@5.5.0) + eslint: 8.57.1 + optionalDependencies: + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@6.21.0': dependencies: '@typescript-eslint/types': 6.21.0 '@typescript-eslint/visitor-keys': 6.21.0 + '@typescript-eslint/scope-manager@7.18.0': + dependencies: + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 + '@typescript-eslint/type-utils@6.21.0(eslint@8.57.1)(typescript@5.3.3)': dependencies: '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) @@ -9730,8 +10046,22 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/type-utils@7.18.0(eslint@8.57.1)(typescript@5.6.3)': + dependencies: + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.6.3) + '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.6.3) + debug: 4.3.7(supports-color@5.5.0) + eslint: 8.57.1 + ts-api-utils: 1.4.0(typescript@5.6.3) + optionalDependencies: + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/types@6.21.0': {} + '@typescript-eslint/types@7.18.0': {} + '@typescript-eslint/typescript-estree@6.21.0(typescript@5.3.3)': dependencies: '@typescript-eslint/types': 6.21.0 @@ -9747,6 +10077,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@7.18.0(typescript@5.6.3)': + dependencies: + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 + debug: 4.3.7(supports-color@5.5.0) + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.6.3 + ts-api-utils: 1.4.0(typescript@5.6.3) + optionalDependencies: + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@6.21.0(eslint@8.57.1)(typescript@5.3.3)': dependencies: '@eslint-community/eslint-utils': 4.4.1(eslint@8.57.1) @@ -9761,11 +10106,27 @@ snapshots: - supports-color - typescript + '@typescript-eslint/utils@7.18.0(eslint@8.57.1)(typescript@5.6.3)': + dependencies: + '@eslint-community/eslint-utils': 4.4.1(eslint@8.57.1) + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.6.3) + eslint: 8.57.1 + transitivePeerDependencies: + - supports-color + - typescript + '@typescript-eslint/visitor-keys@6.21.0': dependencies: '@typescript-eslint/types': 6.21.0 eslint-visitor-keys: 3.4.3 + '@typescript-eslint/visitor-keys@7.18.0': + dependencies: + '@typescript-eslint/types': 7.18.0 + eslint-visitor-keys: 3.4.3 + '@ungap/structured-clone@1.2.0': {} '@vitejs/plugin-react-swc@3.7.1(vite@5.4.10(@types/node@20.17.6)(terser@5.36.0))': @@ -9874,6 +10235,8 @@ snapshots: acorn@8.14.0: {} + adm-zip@0.5.12: {} + ajv-formats@2.1.1(ajv@8.12.0): optionalDependencies: ajv: 8.12.0 @@ -9958,6 +10321,8 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.3.2: {} + array-buffer-byte-length@1.0.1: dependencies: call-bind: 1.0.7 @@ -9987,6 +10352,15 @@ snapshots: es-object-atoms: 1.0.0 es-shim-unscopables: 1.0.2 + array.prototype.findlastindex@1.2.5: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + es-shim-unscopables: 1.0.2 + array.prototype.flat@1.3.2: dependencies: call-bind: 1.0.7 @@ -10022,6 +10396,8 @@ snapshots: asap@2.0.6: {} + ast-types-flow@0.0.8: {} + async@0.2.10: {} async@3.2.6: {} @@ -10052,6 +10428,8 @@ snapshots: aws-ssl-profiles@1.1.2: {} + axe-core@4.10.2: {} + axios@1.7.7: dependencies: follow-redirects: 1.15.9 @@ -10060,6 +10438,10 @@ snapshots: transitivePeerDependencies: - debug + axobject-query@4.1.0: {} + + b4a@1.6.7: {} + babel-jest@29.7.0(@babel/core@7.26.0): dependencies: '@babel/core': 7.26.0 @@ -10141,6 +10523,9 @@ snapshots: balanced-match@1.0.2: {} + bare-events@2.5.4: + optional: true + base64-js@1.5.1: {} base64id@2.0.0: {} @@ -10411,6 +10796,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@12.0.0: {} + commander@12.1.0: {} commander@2.20.3: {} @@ -10456,6 +10843,8 @@ snapshots: readable-stream: 2.3.8 typedarray: 0.0.6 + confusing-browser-globals@1.0.11: {} + consola@2.15.3: {} content-disposition@0.5.4: @@ -10583,6 +10972,8 @@ snapshots: temp: 0.9.4 word-wrap: 1.2.5 + damerau-levenshtein@1.0.8: {} + data-uri-to-buffer@4.0.1: {} data-view-buffer@1.0.1: @@ -10609,6 +11000,10 @@ snapshots: dependencies: ms: 2.0.0 + debug@3.2.7: + dependencies: + ms: 2.1.3 + debug@4.3.7(supports-color@5.5.0): dependencies: ms: 2.1.3 @@ -10896,10 +11291,105 @@ snapshots: escape-string-regexp@4.0.0: {} + eslint-config-airbnb-base@15.0.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint@8.57.1): + dependencies: + confusing-browser-globals: 1.0.11 + eslint: 8.57.1 + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1) + object.assign: 4.1.5 + object.entries: 1.1.8 + semver: 6.3.1 + + eslint-config-airbnb-typescript@18.0.0(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1)(typescript@5.6.3))(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.3))(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint@8.57.1): + dependencies: + '@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1)(typescript@5.6.3) + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.6.3) + eslint: 8.57.1 + eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint@8.57.1) + transitivePeerDependencies: + - eslint-plugin-import + + eslint-config-airbnb@19.0.4(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint-plugin-jsx-a11y@6.10.2(eslint@8.57.1))(eslint-plugin-react-hooks@5.0.0(eslint@8.57.1))(eslint-plugin-react@7.37.2(eslint@8.57.1))(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1) + eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) + eslint-plugin-react: 7.37.2(eslint@8.57.1) + eslint-plugin-react-hooks: 5.0.0(eslint@8.57.1) + object.assign: 4.1.5 + object.entries: 1.1.8 + eslint-config-prettier@8.10.0(eslint@8.57.1): dependencies: eslint: 8.57.1 + eslint-import-resolver-node@0.3.9: + dependencies: + debug: 3.2.7 + is-core-module: 2.15.1 + resolve: 1.22.8 + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint@8.57.1): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.6.3) + eslint: 8.57.1 + eslint-import-resolver-node: 0.3.9 + transitivePeerDependencies: + - supports-color + + eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.8 + array.prototype.findlastindex: 1.2.5 + array.prototype.flat: 1.3.2 + array.prototype.flatmap: 1.3.2 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 8.57.1 + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint@8.57.1) + hasown: 2.0.2 + is-core-module: 2.15.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.0 + semver: 6.3.1 + string.prototype.trimend: 1.0.8 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.6.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-jsx-a11y@6.10.2(eslint@8.57.1): + dependencies: + aria-query: 5.3.2 + array-includes: 3.1.8 + array.prototype.flatmap: 1.3.2 + ast-types-flow: 0.0.8 + axe-core: 4.10.2 + axobject-query: 4.1.0 + damerau-levenshtein: 1.0.8 + emoji-regex: 9.2.2 + eslint: 8.57.1 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + language-tags: 1.0.9 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + safe-regex-test: 1.0.3 + string.prototype.includes: 2.0.1 + eslint-plugin-prettier@4.2.1(eslint-config-prettier@8.10.0(eslint@8.57.1))(eslint@8.57.1)(prettier@2.8.8): dependencies: eslint: 8.57.1 @@ -11114,6 +11604,8 @@ snapshots: fast-diff@1.3.0: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.2: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -11284,6 +11776,12 @@ snapshots: jsonfile: 6.1.0 universalify: 2.0.1 + fs-extra@11.2.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + fs-extra@9.1.0: dependencies: at-least-node: 1.0.0 @@ -11457,6 +11955,8 @@ snapshots: dependencies: parse-passwd: 1.0.0 + hpagent@1.2.0: {} + html-escaper@2.0.2: {} http-errors@2.0.0: @@ -12119,6 +12619,10 @@ snapshots: transitivePeerDependencies: - supports-color + jest-sonar-reporter@2.0.0: + dependencies: + xml: 1.0.1 + jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 @@ -12210,6 +12714,10 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json5@1.0.2: + dependencies: + minimist: 1.2.8 + json5@2.2.3: {} jsonc-parser@3.2.1: {} @@ -12261,6 +12769,12 @@ snapshots: kuler@2.0.0: {} + language-subtag-registry@0.3.23: {} + + language-tags@1.0.9: + dependencies: + language-subtag-registry: 0.3.23 + leven@3.1.0: {} levn@0.4.1: @@ -12382,6 +12896,10 @@ snapshots: dependencies: yallist: 3.1.1 + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + lru-cache@7.18.3: {} lru.min@1.1.1: {} @@ -12581,6 +13099,8 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + node-forge@1.3.1: {} + node-gyp-build@4.8.3: optional: true @@ -12643,6 +13163,12 @@ snapshots: es-abstract: 1.23.3 es-object-atoms: 1.0.0 + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + object.values@1.2.0: dependencies: call-bind: 1.0.7 @@ -12884,6 +13410,8 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + properties-file@3.5.4: {} + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -12903,6 +13431,8 @@ snapshots: queue-microtask@1.2.3: {} + queue-tick@1.0.1: {} + randombytes@2.1.0: dependencies: safe-buffer: 5.2.1 @@ -13217,6 +13747,10 @@ snapshots: semver@6.3.1: {} + semver@7.6.0: + dependencies: + lru-cache: 6.0.0 + semver@7.6.3: {} send@0.19.0: @@ -13314,6 +13848,8 @@ snapshots: ansi-styles: 6.2.1 is-fullwidth-code-point: 5.0.0 + slugify@1.6.6: {} + socket.io-adapter@2.5.5(bufferutil@4.0.8)(utf-8-validate@5.0.10): dependencies: debug: 4.3.7(supports-color@5.5.0) @@ -13369,6 +13905,23 @@ snapshots: - supports-color - utf-8-validate + sonarqube-scanner@4.2.6: + dependencies: + adm-zip: 0.5.12 + axios: 1.7.7 + commander: 12.0.0 + fs-extra: 11.2.0 + hpagent: 1.2.0 + jest-sonar-reporter: 2.0.0 + node-forge: 1.3.1 + properties-file: 3.5.4 + proxy-from-env: 1.1.0 + semver: 7.6.0 + slugify: 1.6.6 + tar-stream: 3.1.7 + transitivePeerDependencies: + - debug + source-map-js@1.2.1: {} source-map-support@0.5.13: @@ -13401,6 +13954,14 @@ snapshots: streamsearch@1.1.0: {} + streamx@2.21.1: + dependencies: + fast-fifo: 1.3.2 + queue-tick: 1.0.1 + text-decoder: 1.2.3 + optionalDependencies: + bare-events: 2.5.4 + string-argv@0.3.2: {} string-length@4.0.2: @@ -13431,6 +13992,12 @@ snapshots: get-east-asian-width: 1.3.0 strip-ansi: 7.1.0 + string.prototype.includes@2.0.1: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + string.prototype.matchall@4.0.11: dependencies: call-bind: 1.0.7 @@ -13594,6 +14161,12 @@ snapshots: tapable@2.2.1: {} + tar-stream@3.1.7: + dependencies: + b4a: 1.6.7 + fast-fifo: 1.3.2 + streamx: 2.21.1 + tar@7.4.3: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -13632,6 +14205,10 @@ snapshots: glob: 7.2.3 minimatch: 3.1.2 + text-decoder@1.2.3: + dependencies: + b4a: 1.6.7 + text-hex@1.0.0: {} text-table@0.2.0: {} @@ -13670,6 +14247,10 @@ snapshots: dependencies: typescript: 5.3.3 + ts-api-utils@1.4.0(typescript@5.6.3): + dependencies: + typescript: 5.6.3 + ts-interface-checker@0.1.13: {} ts-jest@29.2.5(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(jest@29.7.0(@types/node@20.17.6)(ts-node@10.9.2(@swc/core@1.8.0)(@types/node@20.17.6)(typescript@5.3.3)))(typescript@5.3.3): @@ -13776,6 +14357,13 @@ snapshots: enhanced-resolve: 5.17.1 tsconfig-paths: 4.2.0 + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + tsconfig-paths@4.2.0: dependencies: json5: 2.2.3 @@ -14162,6 +14750,8 @@ snapshots: bufferutil: 4.0.8 utf-8-validate: 5.0.10 + xml@1.0.1: {} + xmlhttprequest-ssl@2.1.2: {} xtend@4.0.2: {}