diff --git a/apps/client/.eslintrc b/apps/client/.eslintrc index d1d3543..684ff1e 100644 --- a/apps/client/.eslintrc +++ b/apps/client/.eslintrc @@ -54,6 +54,7 @@ "import/no-unresolved": "off", "import/extensions": ["off"], "import/prefer-default-export": "off", + "no-restricted-exports": "warn", // 접근성 관련 규칙 "jsx-a11y/media-has-caption": "off", diff --git a/apps/client/.gitignore b/apps/client/.gitignore index a547bf3..5bc5b2c 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 8dc66bf..7c4e3e1 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", @@ -49,6 +50,7 @@ "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/sonar-project.properties b/apps/client/sonar-project.properties new file mode 100644 index 0000000..34fbe22 --- /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/providers/AuthProvider.tsx b/apps/client/src/app/providers/AuthProvider.tsx index c1cdb67..2829e9f 100644 --- a/apps/client/src/app/providers/AuthProvider.tsx +++ b/apps/client/src/app/providers/AuthProvider.tsx @@ -1,7 +1,8 @@ import { useMemo, useState } from 'react'; import { AuthContext } from '@/shared/contexts'; +import { ProviderProps } from './types'; -export function AuthProvider({ children }: { children: React.ReactNode }) { +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 index 9edec63..09c2466 100644 --- a/apps/client/src/app/providers/Providers.tsx +++ b/apps/client/src/app/providers/Providers.tsx @@ -1,7 +1,8 @@ import { ThemeProvider } from '@/app/providers/ThemeProvider'; import { AuthProvider } from '@/app/providers/AuthProvider'; +import { ProviderProps } from './types'; -export function Providers({ children }: { children: React.ReactNode }) { +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 index 9ad225c..ad5f6be 100644 --- a/apps/client/src/app/providers/ThemeProvider.tsx +++ b/apps/client/src/app/providers/ThemeProvider.tsx @@ -1,9 +1,10 @@ import { useMemo, useState } from 'react'; import { ThemeContext } from '@/shared/contexts'; +import { ProviderProps } from './types'; type Theme = 'light' | 'dark' | null; -export function ThemeProvider({ children }: { children: React.ReactNode }) { +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/types.ts b/apps/client/src/app/providers/types.ts new file mode 100644 index 0000000..0820549 --- /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/app/routes/router.tsx b/apps/client/src/app/routes/router.tsx index 1da40b2..7049f81 100644 --- a/apps/client/src/app/routes/router.tsx +++ b/apps/client/src/app/routes/router.tsx @@ -1,13 +1,16 @@ import { createBrowserRouter } from 'react-router-dom'; -import { HomePage } from '@pages/Home'; -import { LivePage } from '@pages/Live'; -import { BroadcastPage } from '@pages/Broadcast'; -import { AuthPage } from '@pages/Auth'; -import { RecordPage } from '@pages/Record'; -import { ProfilePage } from '@/pages/Profile'; +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( [ @@ -21,7 +24,11 @@ export const router = createBrowserRouter( }, { path: 'live/:liveId', - element: , + element: ( + }> + + + ), }, { path: 'auth', @@ -33,12 +40,19 @@ export const router = createBrowserRouter( children: [ { path: 'profile', - element: , + element: ( + }> + + + ), }, - { path: 'record/:attendanceId', - element: , + element: ( + }> + + + ), }, ], }, @@ -50,7 +64,11 @@ export const router = createBrowserRouter( children: [ { path: '', - element: , + element: ( + }> + + + ), }, ], }, diff --git a/apps/client/src/features/broadcasting/index.ts b/apps/client/src/features/broadcasting/index.ts index 4c18f87..207ddd0 100644 --- a/apps/client/src/features/broadcasting/index.ts +++ b/apps/client/src/features/broadcasting/index.ts @@ -1,4 +1,4 @@ export { BroadcastPlayer, BroadcastTitle, RecordButton } from './ui'; -export { useRoom, useProducer, useMedia, useScreenShare } from './model'; +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 index 1c7e393..f1b0b89 100644 --- a/apps/client/src/features/broadcasting/model/index.ts +++ b/apps/client/src/features/broadcasting/model/index.ts @@ -1,4 +1,4 @@ export { useMedia } from './useMedia'; -export { useRoom, useProducer } from './mediasoup'; +export { useProduce } from './mediasoup'; export { useScreenShare } from './useScreenShare'; export type { Tracks } from './trackTypes'; diff --git a/apps/client/src/features/broadcasting/model/mediasoup/index.ts b/apps/client/src/features/broadcasting/model/mediasoup/index.ts index c74ebb6..ab31d45 100644 --- a/apps/client/src/features/broadcasting/model/mediasoup/index.ts +++ b/apps/client/src/features/broadcasting/model/mediasoup/index.ts @@ -1,2 +1 @@ -export { useRoom } from './useRoom'; -export { useProducer } from './useProducer'; +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 0000000..ed7d2c4 --- /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 0000000..b1ba626 --- /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/mediasoup/useProducer.ts b/apps/client/src/features/broadcasting/model/mediasoup/useProducer.ts deleted file mode 100644 index 37a4e45..0000000 --- a/apps/client/src/features/broadcasting/model/mediasoup/useProducer.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { Transport, Device, Producer } from 'mediasoup-client/lib/types'; -import { Socket } from 'socket.io-client'; -import { TransportInfo } from '@/shared/types/mediasoupTypes'; -import { checkDependencies } from '@/shared/lib'; -import { ENCODING_OPTIONS } from './encodingOptions'; - -type UseProducerProps = { - socket: Socket | null; - // tracks: Tracks; - // isStreamReady: boolean; - mediaStream: MediaStream | null; - isMediaStreamReady: boolean; - roomId: string; - device: Device | null; - transportInfo: TransportInfo | null; -}; - -type UseProducerReturn = { - transport: Transport | null; - error: Error | null; - producerId: string; - producers: Map; -}; - -type ConnectTransportResponse = { - connected: boolean; - isProducer: boolean; -}; - -export const useProducer = ({ - socket, - mediaStream, - isMediaStreamReady, - roomId, - device, - transportInfo, -}: UseProducerProps): UseProducerReturn => { - const transportRef = useRef(null); - const [error, setError] = useState(null); - const [producerId, setProducerId] = useState(''); - const [producers, setProducers] = useState>(new Map()); - - useEffect(() => { - if (!socket || !device || !roomId || !mediaStream || !isMediaStreamReady || !transportInfo) { - return undefined; - } - - const createTransport = async () => { - 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, - }); - - transportRef.current = newTransport; - - transportRef.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 () => { - if (!transportRef.current || !transportInfo || !socket || !mediaStream) { - const dependencyError = checkDependencies('createProducer', { - socket, - mediaStream, - transport: transportRef.current, - transportInfo, - }); - setError(dependencyError); - return; - } - - setError(null); - - transportRef.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, - stopTracks: false, - }; - - if (track.kind === 'video') { - producerConfig.encodings = ENCODING_OPTIONS; - producerConfig.codecOptions = { - videoGoogleStartBitrate: 1000, - }; - } - - transportRef.current!.produce(producerConfig).then(producer => { - setProducers(prev => new Map(prev).set(track.kind, producer)); - }); - }); - }; - - createTransport() - .then(() => createProducer()) - .catch(err => setError(err instanceof Error ? err : new Error('Producer initialization failed'))); - - return () => { - if (transportRef.current) { - transportRef.current.close(); - transportRef.current = null; - } - }; - }, [socket, device, roomId, transportInfo, isMediaStreamReady, mediaStream]); - - return { - transport: transportRef.current, - error, - producerId, - producers, - }; -}; diff --git a/apps/client/src/features/broadcasting/model/mediasoup/useRoom.ts b/apps/client/src/features/broadcasting/model/mediasoup/useRoom.ts deleted file mode 100644 index 524093a..0000000 --- a/apps/client/src/features/broadcasting/model/mediasoup/useRoom.ts +++ /dev/null @@ -1,30 +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); - - useEffect(() => { - if (!isMediaStreamReady) return; - - const getRooomId = async () => { - if (!socket) { - setRoomError(new Error('getRoomId Error: socket이 존재하지 않습니다.')); - return; - } - - setRoomError(null); - socket.emit('createRoom', (response: { roomId: string }) => { - setRoomId(response.roomId); - }); - }; - - getRooomId(); - }, [isConnected, isMediaStreamReady, socket]); - - return { - roomId, - roomError, - }; -}; diff --git a/apps/client/src/features/broadcasting/ui/BroadcastPlayer/BroadcastPlayer.tsx b/apps/client/src/features/broadcasting/ui/BroadcastPlayer/BroadcastPlayer.tsx index b18219c..6039076 100644 --- a/apps/client/src/features/broadcasting/ui/BroadcastPlayer/BroadcastPlayer.tsx +++ b/apps/client/src/features/broadcasting/ui/BroadcastPlayer/BroadcastPlayer.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react'; import { RESOLUTION_OPTIONS } from './resolutionOptions'; import { Tracks } from '../../../../features/broadcasting/model/trackTypes'; -type BroadcastPlayerProps = { +type BroadcastPlayerProps = Readonly<{ mediaStream: MediaStream | null; screenStream: MediaStream | null; isVideoEnabled: boolean; @@ -10,7 +10,7 @@ type BroadcastPlayerProps = { isStreamReady: boolean; setIsStreamReady: (ready: boolean) => void; tracksRef: React.MutableRefObject; -}; +}>; export function BroadcastPlayer({ mediaStream, diff --git a/apps/client/src/features/broadcasting/ui/BroadcastTitle.tsx b/apps/client/src/features/broadcasting/ui/BroadcastTitle.tsx index a6f2e66..581cecb 100644 --- a/apps/client/src/features/broadcasting/ui/BroadcastTitle.tsx +++ b/apps/client/src/features/broadcasting/ui/BroadcastTitle.tsx @@ -7,10 +7,10 @@ type Inputs = { title: string; }; -type BroadcastTitleProps = { +type BroadcastTitleProps = Readonly<{ currentTitle: string; onTitleChange: (newTitle: string) => void; -}; +}>; export function BroadcastTitle({ currentTitle, onTitleChange }: BroadcastTitleProps) { const { diff --git a/apps/client/src/features/broadcasting/ui/RecordButton.tsx b/apps/client/src/features/broadcasting/ui/RecordButton.tsx index 7cdf034..baf541a 100644 --- a/apps/client/src/features/broadcasting/ui/RecordButton.tsx +++ b/apps/client/src/features/broadcasting/ui/RecordButton.tsx @@ -9,10 +9,10 @@ type FormInput = { title: string; }; -type RecordButtonProps = { +type RecordButtonProps = Readonly<{ socket: Socket | null; roomId: string; -}; +}>; export function RecordButton({ socket, roomId }: RecordButtonProps) { const [isRecording, setIsRecording] = useState(false); diff --git a/apps/client/src/features/chatting/ui/ChatContainer.tsx b/apps/client/src/features/chatting/ui/ChatContainer.tsx index ee06282..1578c90 100644 --- a/apps/client/src/features/chatting/ui/ChatContainer.tsx +++ b/apps/client/src/features/chatting/ui/ChatContainer.tsx @@ -11,7 +11,12 @@ import { Chat } from './types'; const chatServerUrl = import.meta.env.VITE_CHAT_SERVER_URL; -export function 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 isJoinedRoomRef = useRef(false); diff --git a/apps/client/src/features/chatting/ui/ChatEndModal.tsx b/apps/client/src/features/chatting/ui/ChatEndModal.tsx index ec5f0cc..c17d802 100644 --- a/apps/client/src/features/chatting/ui/ChatEndModal.tsx +++ b/apps/client/src/features/chatting/ui/ChatEndModal.tsx @@ -1,9 +1,9 @@ import { useNavigate } from 'react-router-dom'; import { Modal } from '@/shared/ui'; -type ChatEndModalProps = { +type ChatEndModalProps = Readonly<{ setShowModal: (b: boolean) => void; -}; +}>; export function ChatEndModal({ setShowModal }: ChatEndModalProps) { const navigate = useNavigate(); diff --git a/apps/client/src/features/editProfile/ui/EditUserInfo.tsx b/apps/client/src/features/editProfile/ui/EditUserInfo.tsx index 5bc06a4..6cffb6c 100644 --- a/apps/client/src/features/editProfile/ui/EditUserInfo.tsx +++ b/apps/client/src/features/editProfile/ui/EditUserInfo.tsx @@ -7,10 +7,10 @@ import { Button } from '@/shared/ui/shadcn/button'; import { axiosInstance } from '@/shared/api'; import { useToast } from '@/shared/lib'; -type EditUserInfoProps = { +type EditUserInfoProps = Readonly<{ userData: UserData | undefined; toggleEditing: () => void; -}; +}>; export type FormInput = { camperId: string | undefined; @@ -89,7 +89,7 @@ export function EditUserInfo({ userData, toggleEditing }: EditUserInfoProps) { />

- {errors.camperId && errors.camperId.message} + {errors.camperId?.message}

{/* 이름 */} @@ -104,11 +104,8 @@ export function EditUserInfo({ userData, toggleEditing }: EditUserInfoProps) { className="flex-1 h-10 bg-transparent border border-default rounded-md focus:border-bold px-3 text-display-medium16 text-text-default" /> -

- {errors.name && errors.name.message} -

+

{errors.name?.message}

- {/* TODO: 입력 검증 */} {/* email */}
- -
- ))} + {data.name} + + + + ))} {bookmarkList.length < 5 && (