diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 180b3021..3f9aa749 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import * as Sentry from '@sentry/react'; import '@/App.css'; import { Layout } from '@/pages/Layout'; @@ -6,7 +6,7 @@ import { LoginPage } from '@/pages/LoginPage'; import { RegisterPage } from '@/pages/RegisterPage'; import { NotFoundPage } from '@/pages/NotFoundPage'; import { HomePage } from '@/pages/HomePage'; -import { Room } from '@/pages/RoomPage'; +import { RoomPage } from '@/pages/RoomPage'; import { FriendPage } from '@/pages/FriendPage'; import { SettingPage } from '@/pages/SettingPage'; import { MyRoomPage } from '@/pages/MyRoomPage'; @@ -14,6 +14,17 @@ import { PasswordResetPage } from '@/pages/PasswordResetPage'; import { SearchPage } from '@/pages/SearchPage'; import { QueryClientProvider } from '@tanstack/react-query'; import { queryClient } from '@/lib/react-query'; +import { useUserStore } from './stores/useUserStore'; + +const ProtectedRoute = ({ children }: { children: JSX.Element }) => { + const { user } = useUserStore(); + + if (!user) { + return ; + } + + return children; +}; function App() { const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes); @@ -27,11 +38,32 @@ function App() { } /> }> } /> - } /> - } /> - } /> + + + + } + /> + } /> + + + + } + /> } /> - } /> + + + + } + /> } /> diff --git a/src/frontend/src/api/endpoints/auth/auth.api.ts b/src/frontend/src/api/endpoints/auth/auth.api.ts index 5612b22e..21e642d4 100644 --- a/src/frontend/src/api/endpoints/auth/auth.api.ts +++ b/src/frontend/src/api/endpoints/auth/auth.api.ts @@ -34,8 +34,14 @@ export const authApi = { // 토큰 갱신 refreshToken: async () => { - const { data } = await instance.post('/auth/token/refresh'); - return data; + try { + const { data } = await instance.post('/auth/token/refresh'); + return data; + } catch (error) { + console.error('토큰 갱신 실패:', error); + useAuthStore.getState().clear(); + throw error; + } }, // 토큰 검증 diff --git a/src/frontend/src/api/endpoints/room/room.api.ts b/src/frontend/src/api/endpoints/room/room.api.ts index ff2d4d19..79b2d11c 100644 --- a/src/frontend/src/api/endpoints/room/room.api.ts +++ b/src/frontend/src/api/endpoints/room/room.api.ts @@ -1,5 +1,5 @@ import instance from '@/api/axios.instance'; -import { PlaylistDto, RoomRequestDto, MyRoomDto, RoomDto } from './room.interface'; +import { PlaylistDto, RoomRequestDto, MyRoomDto, RoomDto, CurrentRoomDto } from './room.interface'; export const roomApi = { // 방 생성 @@ -25,7 +25,7 @@ export const roomApi = { // 방 입장 joinRoom: async (roomCode: string) => { - const { data } = await instance.post(`/rooms/join`, { roomCode }); + const { data } = await instance.post(`/rooms/join`, { roomCode }); return data; }, diff --git a/src/frontend/src/api/endpoints/room/room.interface.ts b/src/frontend/src/api/endpoints/room/room.interface.ts index 4ae33fac..e18dd491 100644 --- a/src/frontend/src/api/endpoints/room/room.interface.ts +++ b/src/frontend/src/api/endpoints/room/room.interface.ts @@ -35,3 +35,34 @@ export interface RoomDto { userCount: number; playlistUrl?: string; } + +export interface CurrentRoomUserDto { + userId: number; + role: number; + nickname: string; + profileImageUrl: string; +} + +export interface CurrentRoomInfoDto { + roomId: number; + code: string; + title: string; + description: string; + userCount: number; + creator: string; + profileImageUrl: string; +} + +export interface CurrentRoomPlaylistDto { + url: string; + order: number; +} + +export interface CurrentRoomDto { + myRole: number; + roomDetails: { + userList: CurrentRoomUserDto[]; + roomInfo: CurrentRoomInfoDto[]; + playlist: CurrentRoomPlaylistDto[]; + }; +} diff --git a/src/frontend/src/components/RoomDetail/index.css.ts b/src/frontend/src/components/RoomDetail/index.css.ts index 795ac884..1ce0a08f 100644 --- a/src/frontend/src/components/RoomDetail/index.css.ts +++ b/src/frontend/src/components/RoomDetail/index.css.ts @@ -1,29 +1,10 @@ import styled from 'styled-components'; -export const Container = styled.div` - display: flex; - flex-direction: row; - justify-content: space-between; - gap: 20px; - padding-right: 16px; -`; - -export const Wrapper = styled.div` - width: 100%; - display: flex; - flex-direction: column; - gap: 16px; -`; - -export const TitleContainer = styled.header` +export const Container = styled.header` width: 100%; display: flex; - gap: 8px; justify-content: space-between; - > div { - display: flex; - gap: 8px; - } + gap: 8px; `; export const TitleContainer_Img = styled.img` @@ -32,14 +13,31 @@ export const TitleContainer_Img = styled.img` border-radius: 10px; `; +export const TextContainer = styled.div` + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 8px; +`; + export const Title = styled.div` font-size: 18px; font-weight: 700; + margin-top: 4px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + word-break: break-all; + line-height: 1.2em; + max-height: 2.4em; `; export const Username = styled.div` color: var(--palette-font-gray-strong); - margin-top: 8px; + font-size: 14px; `; export const DescriptionContainer = styled.div` @@ -48,28 +46,38 @@ export const DescriptionContainer = styled.div` export const Description = styled.div<{ $isExpanded: boolean }>` color: var(--palette-font-gray-strong); - margin-top: 16px; + font-size: 14px; + margin-top: 0.5rem; max-height: ${({ $isExpanded }) => ($isExpanded ? 'none' : '4.5em')}; overflow: hidden; line-height: 1.5em; + display: -webkit-box; + -webkit-line-clamp: ${({ $isExpanded }) => ($isExpanded ? 'none' : '3')}; + -webkit-box-orient: vertical; + text-overflow: ellipsis; + word-break: break-all; `; -export const TitleContainer_MemberNum = styled.div` - width: 6rem; +export const MemberCount = styled.div` + flex-shrink: 0; display: flex; - background-color: var(--palette-font-gray-strong); + align-items: center; + background: rgba(0, 0, 0, 0.5); color: white; - padding: 8px; + padding: 0.5rem 0.75rem; border-radius: 16px; height: 28px; - align-items: center; + font-size: 14px; + font-weight: 500; + white-space: nowrap; `; export const Circle = styled.div` width: 5px; height: 5px; border-radius: 100px; - background-color: red; + background-color: var(--palette-status-negative); + margin-right: 4px; `; export const MoreButton = styled.button` diff --git a/src/frontend/src/components/RoomDetail/index.tsx b/src/frontend/src/components/RoomDetail/index.tsx index ebf58fca..c1aca27f 100644 --- a/src/frontend/src/components/RoomDetail/index.tsx +++ b/src/frontend/src/components/RoomDetail/index.tsx @@ -1,52 +1,60 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { - TitleContainer, + Container, TitleContainer_Img, + TextContainer, Title, Username, DescriptionContainer, Description, - TitleContainer_MemberNum, + MemberCount, MoreButton, Circle, } from './index.css'; - -const roomInfoExample = { - imgUrl: - 'https://yt3.ggpht.com/YHA2HDj6dt07Qrtbfdyi94eKId71Bmoz6z9LfcLCbCv_RRnxUTYSn0p_IkW9k6Qkp_04Lq-56A=s88-c-k-c0x00ffffff-no-rj', - title: '너무 조용하지도, 너무 들뜨지도 않아 듣기 좋은 재즈', - creator: '유자네임', - description: - '설명이 들어갈 것이면 그렇다면 여기에 많은 내용이 담기겠죠\n심지어 단을 나눠서 설명이 필요할 수도 있겠죠\n#안정적 #음악 #코지\n#유료광고포함 #숨겨둠', - people: '500', -}; +import { useCurrentRoomStore } from '@/stores/useCurrentRoomStore'; +import DefaultProfile from '@/assets/img/DefaultProfile.svg'; export const RoomDetail = () => { + const { currentRoom } = useCurrentRoomStore(); + const roomInfo = currentRoom?.roomDetails.roomInfo[0]; + + const handleDescription = () => { + if (roomInfo?.description == null) return ''; + return roomInfo?.description; + }; + return ( - -
- -
- {roomInfoExample.title} - {roomInfoExample.creator} - -
-
- + + + + {roomInfo?.title} + {roomInfo?.creator} + + + - 201 / {roomInfoExample.people} - -
+ {roomInfo?.userCount}명 + + ); }; const DescriptionText = (props: { description: string }) => { const [isExpanded, setIsExpanded] = useState(false); + const [isOverflowing, setIsOverflowing] = useState(false); + const textRef = useRef(null); + + useEffect(() => { + const element = textRef.current; + if (element) { + setIsOverflowing(element.scrollHeight > element.clientHeight); + } + }, [props.description]); return ( - + {props.description.split('\n').map((line, index) => ( {line} @@ -54,7 +62,7 @@ const DescriptionText = (props: { description: string }) => { ))} - {!isExpanded && props.description.split('\n').length > 3 && ( + {!isExpanded && isOverflowing && ( setIsExpanded(true)}>더보기 )} {isExpanded && setIsExpanded(false)}>접기} diff --git a/src/frontend/src/components/TopNavBar/index.tsx b/src/frontend/src/components/TopNavBar/index.tsx index 534bd843..50ccb1ea 100644 --- a/src/frontend/src/components/TopNavBar/index.tsx +++ b/src/frontend/src/components/TopNavBar/index.tsx @@ -15,23 +15,34 @@ import DefaultProfile from '@/assets/img/DefaultProfile.svg'; import { RoomCreateModal } from '@/components/Modal/RoomCreateModal'; import { NotificationModal } from '@/components/Modal/NotificationModal'; import { SearchBar } from '@/components/Search/SearchBar'; +import { ProfileModal } from '@/components/Modal/ProfileModal'; import { useUserStore } from '@/stores/useUserStore'; import { useAuthStore } from '@/stores/useAuthStore'; -import { ProfileModal } from '@/components/Modal/ProfileModal'; +import { useMyRoomsStore } from '@/stores/useMyRoomsStore'; +import { useWebSocketStore } from '@/stores/useWebSocketStore'; export const TopNavBar = () => { - const { user, fetchMyProfile, clear } = useUserStore(); + const { user, fetchMyProfile, clearProfile } = useUserStore(); + const { fetchMyRooms } = useMyRoomsStore(); + const { connect } = useWebSocketStore(); const [isRoomCreateModalOpen, setIsRoomCreateModalOpen] = useState(false); const [isNotificationModalOpen, setIsNotificationModalOpen] = useState(false); const [isProfileModalOpen, setIsProfileModalOpen] = useState(false); const accessToken = useAuthStore(state => state.accessToken); useEffect(() => { - if (accessToken) { - fetchMyProfile(); - } else { - clear(); + connect(); + + if (!accessToken) { + clearProfile(); + return; } - }, [accessToken, fetchMyProfile, clear]); + + const initializeUser = async () => { + await fetchMyProfile(); + await fetchMyRooms(); + }; + initializeUser(); + }, [accessToken]); const clickNotification = (e: React.MouseEvent) => { e.stopPropagation(); diff --git a/src/frontend/src/hooks/queries/useCurrentRoom.ts b/src/frontend/src/hooks/queries/useCurrentRoom.ts new file mode 100644 index 00000000..20684b8a --- /dev/null +++ b/src/frontend/src/hooks/queries/useCurrentRoom.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query'; +import { roomApi } from '@/api/endpoints/room/room.api'; + +export const useCurrentRoom = (code: string | null) => { + return useQuery({ + queryKey: ['currentRoom', code], + queryFn: () => roomApi.joinRoom(code!), + enabled: !!code, // code가 있을 때만 쿼리 실행 + }); +}; diff --git a/src/frontend/src/hooks/queries/useMyRooms.ts b/src/frontend/src/hooks/queries/useMyRooms.ts index 427a43e4..c6c9d33b 100644 --- a/src/frontend/src/hooks/queries/useMyRooms.ts +++ b/src/frontend/src/hooks/queries/useMyRooms.ts @@ -1,11 +1,12 @@ import { useQuery } from '@tanstack/react-query'; -import { roomApi } from '@/api/endpoints/room/room.api'; import { useAuthStore } from '@/stores/useAuthStore'; +import { useMyRoomsStore } from '@/stores/useMyRoomsStore'; export const useMyRooms = () => { return useQuery({ queryKey: ['myRooms'], - queryFn: roomApi.getMyRooms, + queryFn: useMyRoomsStore.getState().fetchMyRooms, enabled: !!useAuthStore(state => state.accessToken), + staleTime: 0, }); }; diff --git a/src/frontend/src/main.tsx b/src/frontend/src/main.tsx index 2239905c..65e0106b 100644 --- a/src/frontend/src/main.tsx +++ b/src/frontend/src/main.tsx @@ -1,10 +1,10 @@ -import { StrictMode } from 'react'; +// import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import './index.css'; import App from './App.tsx'; createRoot(document.getElementById('root')!).render( - - - , + // + , + // , ); diff --git a/src/frontend/src/pages/MyRoomPage/MyRoomSkeleton/index.tsx b/src/frontend/src/pages/MyRoomPage/MyRoomSkeleton/index.tsx index df277e52..43b70a15 100644 --- a/src/frontend/src/pages/MyRoomPage/MyRoomSkeleton/index.tsx +++ b/src/frontend/src/pages/MyRoomPage/MyRoomSkeleton/index.tsx @@ -1,4 +1,4 @@ -import { Wrapper, Container, Title, SubTitle } from '../index.css'; +import { Wrapper, Container, Title, SubTitle } from '@/ui/Common.css'; import { SkeletonCard, SkeletonThumbnail, diff --git a/src/frontend/src/pages/MyRoomPage/index.css.ts b/src/frontend/src/pages/MyRoomPage/index.css.ts deleted file mode 100644 index d621ddd2..00000000 --- a/src/frontend/src/pages/MyRoomPage/index.css.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { styled } from 'styled-components'; - -export const Wrapper = styled.div` - display: flex; - justify-content: center; - width: 100%; - height: 100%; - padding: 1rem; -`; - -export const Container = styled.div` - width: 100%; - max-width: 720px; - display: flex; - flex-direction: column; - gap: 1rem; -`; - -export const Title = styled.h1` - font-size: 2rem; - font-weight: 600; - padding: 1.5rem 0; -`; - -export const SubTitle = styled.h1` - font-size: 1.5rem; - font-weight: 600; - padding: 2rem 0 1rem; -`; - -export const CommonParagraph = styled.p` - font-size: 1rem; -`; diff --git a/src/frontend/src/pages/MyRoomPage/index.tsx b/src/frontend/src/pages/MyRoomPage/index.tsx index fbc32da7..4b2e7227 100644 --- a/src/frontend/src/pages/MyRoomPage/index.tsx +++ b/src/frontend/src/pages/MyRoomPage/index.tsx @@ -1,32 +1,25 @@ -import { useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useEffect } from 'react'; import { MyRoomCard } from '@/components/MyRoomCard'; -import { Wrapper, Container, Title, SubTitle, CommonParagraph } from './index.css'; +import { Wrapper, Container, Title, SubTitle, CommonParagraph } from '@/ui/Common.css'; import { useUserStore } from '@/stores/useUserStore'; -import { MyRoomDto } from '@/api/endpoints/room/room.interface'; import { MyRoomSkeleton } from './MyRoomSkeleton'; import { useDelayedLoading } from '@/hooks/utils/useDelayedLoading'; import { useMyRooms } from '@/hooks/queries/useMyRooms'; +import { useMyRoomsStore } from '@/stores/useMyRoomsStore'; export const MyRoomPage = () => { - const navigate = useNavigate(); - const getMyRooms = useMyRooms(); + const { data } = useMyRooms(); const { user } = useUserStore(); - const [myRoomList, setMyRoomList] = useState([]); - const showSkeleton = useDelayedLoading(getMyRooms.data); + const { myRooms, setMyRooms, clearMyRooms } = useMyRoomsStore(); + const showSkeleton = useDelayedLoading(data); useEffect(() => { - if (!user) { - navigate('/login'); - return; + if (data) { + setMyRooms(data); + } else { + clearMyRooms(); } - - getMyRooms.refetch().then(({ data }) => { - if (data) { - setMyRoomList(data); - } - }); - }, [user, navigate, getMyRooms]); + }, [clearMyRooms, setMyRooms, data]); if (showSkeleton) { return ; @@ -38,8 +31,8 @@ export const MyRoomPage = () => { 내 방
내가 만든 방 - {myRoomList.filter(room => room.creator === user?.nickname).length > 0 ? ( - myRoomList + {myRooms.filter(room => room.creator === user?.nickname).length > 0 ? ( + myRooms .filter(room => room.creator === user?.nickname) .map(room => ) ) : ( @@ -48,8 +41,8 @@ export const MyRoomPage = () => {
참여 중인 방 - {myRoomList.filter(room => room.creator !== user?.nickname).length > 0 ? ( - myRoomList + {myRooms.filter(room => room.creator !== user?.nickname).length > 0 ? ( + myRooms .filter(room => room.creator !== user?.nickname) .map(room => ) ) : ( diff --git a/src/frontend/src/pages/NotFoundPage/index.css.ts b/src/frontend/src/pages/NotFoundPage/index.css.ts new file mode 100644 index 00000000..575fddb8 --- /dev/null +++ b/src/frontend/src/pages/NotFoundPage/index.css.ts @@ -0,0 +1,25 @@ +import { styled } from 'styled-components'; + +export const Container = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + text-align: center; +`; + +export const LogoImg = styled.img` + width: 150px; + margin-bottom: 2rem; +`; + +export const Title = styled.h1` + font-size: 1.5rem; + margin-bottom: 1.5rem; +`; + +export const Message = styled.p` + margin-bottom: 3rem; + color: var(--palette-font-gray); +`; diff --git a/src/frontend/src/pages/NotFoundPage/index.tsx b/src/frontend/src/pages/NotFoundPage/index.tsx index cf1795a9..f19b3712 100644 --- a/src/frontend/src/pages/NotFoundPage/index.tsx +++ b/src/frontend/src/pages/NotFoundPage/index.tsx @@ -1,3 +1,25 @@ +import { useNavigate } from 'react-router-dom'; +import Logo from '@/assets/img/Logo.svg'; +import { CommonButton } from '@/components/common/Button'; +import { ButtonColor } from '@/types/enums/ButtonColor'; +import { Container, LogoImg, Title, Message } from './index.css'; + export const NotFoundPage = () => { - return
404 Not Found입니다.
; + const navigate = useNavigate(); + + return ( + + + 원하시는 페이지를 찾을 수 없습니다. + 요청하신 페이지가 사라졌거나, 잘못된 경로를 이용하셨습니다. + navigate('/')} + width="160px" + height="40px" + color={ButtonColor.ORANGE} + > + 홈으로 가기 + + + ); }; diff --git a/src/frontend/src/pages/RoomPage/index.tsx b/src/frontend/src/pages/RoomPage/index.tsx index af4342bc..de893097 100644 --- a/src/frontend/src/pages/RoomPage/index.tsx +++ b/src/frontend/src/pages/RoomPage/index.tsx @@ -3,14 +3,34 @@ import { YouTubePlayer } from '@/components/YoutubePlayer'; import { RoomDetail } from '@/components/RoomDetail'; import { Container, Wrapper } from './index.css'; +import { useCurrentRoomStore } from '@/stores/useCurrentRoomStore'; +import { useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { useCurrentRoom } from '@/hooks/queries/useCurrentRoom'; + +export const RoomPage = () => { + const [searchParams] = useSearchParams(); + const roomCode = searchParams.get('code'); + const { data: room } = useCurrentRoom(roomCode); + console.log('RoomPage:', room); + + const { messages } = useCurrentRoomStore(); // NOTE: 채팅 메시지 테스트용 + + useEffect(() => { + if (room) { + useCurrentRoomStore.getState().setCurrentRoom(room); + useCurrentRoomStore.getState().subscribeChat(room.roomDetails.roomInfo[0]?.roomId); + } + }, [room]); -export const Room = () => { return ( <> + {/* NOTE: 채팅 메시지 테스트 용 */} +
    {messages && messages.map((message, i) =>
  • {message.message}
  • )}
diff --git a/src/frontend/src/pages/SettingPage/index.tsx b/src/frontend/src/pages/SettingPage/index.tsx index 84ad2538..2d2a9ae8 100644 --- a/src/frontend/src/pages/SettingPage/index.tsx +++ b/src/frontend/src/pages/SettingPage/index.tsx @@ -1,3 +1,24 @@ +import { Wrapper, Container, Title, CommonUl, CommonLi, Copyright } from '@/ui/Common.css'; +import { useAuth } from '@/hooks/queries/useAuth'; + export const SettingPage = () => { - return
설정
; + const { logout } = useAuth(); + + const handleLogout = () => { + logout.mutate(); + }; + + return ( + + + 설정 + + + 로그아웃 + + + © 2025. KICKZO. All rights reserved. + + + ); }; diff --git a/src/frontend/src/stores/useChatStore.ts b/src/frontend/src/stores/useChatStore.ts deleted file mode 100644 index 5b8da45c..00000000 --- a/src/frontend/src/stores/useChatStore.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { create } from 'zustand'; -import SockJS from 'sockjs-client'; -import Stomp, { Client, Message } from 'stompjs'; -import { UserRole } from '@/types/enums/UserRole'; - -const API_BASE_URL = 'http://localhost:8000'; - -const initialChatData = Array.from({ length: 100 }, (_, i) => ({ - role: i % 2 === 0 ? UserRole.MEMBER : UserRole.CREATOR, - nickname: `User${i}`, - time: `10:${(i % 60).toString().padStart(2, '0')}`, - text: `This is message number ${i}`, -})); - -interface ChatState { - chatData: { role: UserRole; nickname: string; time: string; text: string }[]; - stompClient: Client | null; - status: string; - userId: string; - roomId: string; - connect: () => void; - disconnect: () => void; - sendMessage: (message: string) => void; - addMessage: (message: string, sender?: string) => void; -} - -export const useChatStore = create((set, get) => ({ - chatData: initialChatData, - stompClient: null, - status: 'Disconnected', - userId: `user${Math.floor(Math.random() * 1000)}`, - roomId: '1', - - // WebSocket 연결 - connect: () => { - if (get().stompClient) { - console.warn('웹소켓이 이미 연결되어 있습니다.'); - return; - } - const socket = new SockJS(`${API_BASE_URL}/api/chat/ws`); - const client = Stomp.over(socket); - - client.connect({}, () => { - console.log('✅ WebSocket Connected'); - set({ status: 'Connected', stompClient: client }); - - const subscription = client.subscribe(`/topic/${get().roomId}`, (message: Message) => { - try { - const payload = JSON.parse(message.body); - get().addMessage(payload.content, payload.userId); - } catch (error) { - console.error('❌ 메시지 처리 오류:', error); - } - }); - - client.send( - '/app/joinRoom', - {}, - JSON.stringify({ roomId: get().roomId, userId: get().userId }), - ); - - return () => { - subscription.unsubscribe(); - }; - }); - }, - - // WebSocket 연결 해제 - disconnect: () => { - const client = get().stompClient; - if (client && client.connected) { - client.disconnect(() => { - console.log('❌ WebSocket Disconnected'); - set({ status: 'Disconnected', stompClient: null }); - }); - } else { - console.warn('웹소켓이 이미 연결되지 않았습니다.'); - } - }, - - // 메시지 전송 - sendMessage: (message: string) => { - const client = get().stompClient; - if (!client || !client.connected) { - console.warn('웹소켓이 연결되지 않아 메시지를 보낼 수 없습니다.'); - return; - } - client.send('/app/sendMessage', {}, JSON.stringify({ roomId: get().roomId, message })); - }, - - // 새 메시지 추가 (수신 시 또는 사용자가 보낼 때) - addMessage: (message: string, sender = 'Me') => { - const newChat = { - role: sender === 'Me' ? UserRole.MEMBER : UserRole.CREATOR, - nickname: sender, - time: new Date().toLocaleTimeString().slice(0, 5), - text: message, - }; - if (sender === 'Me') { - get().sendMessage(message); - } - set(state => ({ chatData: [...state.chatData, newChat] })); - }, -})); diff --git a/src/frontend/src/stores/useCurrentRoomStore.ts b/src/frontend/src/stores/useCurrentRoomStore.ts new file mode 100644 index 00000000..45016652 --- /dev/null +++ b/src/frontend/src/stores/useCurrentRoomStore.ts @@ -0,0 +1,49 @@ +// TODO: 새로운 사람 등장: user-info +// TODO: 방 정보 변경: room-update +// TODO: 영상 시간 변경: playlistTime +// TODO: 플레이리스트 변경: playlist-update +// TODO: 역할 변경: role-chage + +import { create } from 'zustand'; +import { CurrentRoomDto } from '@/api/endpoints/room/room.interface'; +import { useWebSocketStore } from './useWebSocketStore'; +import { useUserStore } from './useUserStore'; +import { ReceiveMessageDto, SendMessageDto } from '@/types/dto/Message.dto'; + +interface CurrentRoomStore { + currentRoom: CurrentRoomDto | null; + messages: ReceiveMessageDto[]; + sendMessage: (messageDto: SendMessageDto) => void; + subscribeChat: (roomId: number) => void; + addMessage: (message: ReceiveMessageDto) => void; + setCurrentRoom: (room: CurrentRoomDto) => void; + clearCurrentRoom: () => void; +} + +export const useCurrentRoomStore = create((set, get) => ({ + currentRoom: null, + messages: [], + + setCurrentRoom: (room: CurrentRoomDto) => set({ currentRoom: room }), + clearCurrentRoom: () => set({ currentRoom: null }), + + subscribeChat: (roomId: number) => { + const destination = `/topic/room/${roomId}/chat`; + useWebSocketStore.getState().subTopic(destination, (message: ReceiveMessageDto) => { + get().addMessage(message); + }); + }, + + sendMessage: (messageDto: SendMessageDto) => { + const { client } = useWebSocketStore.getState(); + if (!client) return; + + const { user } = useUserStore.getState(); + if (!user) return; + + client.send(`/app/send-message`, {}, JSON.stringify(messageDto)); + }, + + addMessage: (message: ReceiveMessageDto) => + set(state => ({ messages: [...state.messages, message] })), +})); diff --git a/src/frontend/src/stores/useMyRoomsStore.ts b/src/frontend/src/stores/useMyRoomsStore.ts new file mode 100644 index 00000000..0555d51d --- /dev/null +++ b/src/frontend/src/stores/useMyRoomsStore.ts @@ -0,0 +1,46 @@ +// - [x] 방 들어갔을 때 구독 처리 + +import { MyRoomDto } from '@/api/endpoints/room/room.interface'; +import { create } from 'zustand'; +import { roomApi } from '@/api/endpoints/room/room.api'; +import { persist } from 'zustand/middleware'; +import { useWebSocketStore } from './useWebSocketStore'; + +interface MyRoomsStore { + myRooms: MyRoomDto[]; + setMyRooms: (rooms: MyRoomDto[]) => void; + fetchMyRooms: () => Promise; + clearMyRooms: () => void; +} + +export const useMyRoomsStore = create( + persist( + set => ({ + myRooms: [], + + // 내 방 목록 설정 + setMyRooms: (rooms: MyRoomDto[]) => set({ myRooms: rooms }), + + // 내 방 목록 조회 + fetchMyRooms: async () => { + try { + const myRooms = await roomApi.getMyRooms(); + set({ myRooms }); + console.log('myRooms', myRooms); + const myRoomIds = myRooms.map(room => room.roomId); + useWebSocketStore.getState().subscribeRooms(myRoomIds); + return myRooms; + } catch (error) { + console.error('Failed to fetch rooms:', error); + return []; + } + }, + + // 내 방 목록 초기화 + clearMyRooms: () => set({ myRooms: [] }), + }), + { + name: 'my-rooms-storage', + }, + ), +); diff --git a/src/frontend/src/stores/useUserStore.ts b/src/frontend/src/stores/useUserStore.ts index e51e516c..1597fb47 100644 --- a/src/frontend/src/stores/useUserStore.ts +++ b/src/frontend/src/stores/useUserStore.ts @@ -10,7 +10,7 @@ interface UserStore { updateMyProfile: ( updateUserRequestDto: UpdateUserRequestDto, ) => Promise; - clear: () => void; + clearProfile: () => void; } export const useUserStore = create( @@ -33,7 +33,7 @@ export const useUserStore = create( set({ user: data }); return data; }, - clear: () => { + clearProfile: () => { set({ user: null }); }, }), diff --git a/src/frontend/src/stores/useWebSocketStore.ts b/src/frontend/src/stores/useWebSocketStore.ts new file mode 100644 index 00000000..895fcd09 --- /dev/null +++ b/src/frontend/src/stores/useWebSocketStore.ts @@ -0,0 +1,140 @@ +//- [x] 웹소켓 연결 후 서버에 유저 아이디 전송 +//- [x] 내 채팅방 목록 조회 후 구독하기 +//- [x] 초대 구독하기 +//- [x] 친구 접속 알림 +// v2 [ ] dm 받기 +// v2 [ ] dm 보내기 + +import SockJS from 'sockjs-client'; +import Stomp from 'stompjs'; +import { Client } from 'stompjs'; +import { create } from 'zustand'; +import { useUserStore } from './useUserStore'; + +interface WebSocketStore { + socket: WebSocket | null; + client: Client | null; + subscriptions: Map; // 구독 관리 + + // 연결 + connect: () => void; + disconnect: () => void; + + subTopic: (destination: string, callback: (message: T) => void) => void; + subscribeRoom: (roomId: number) => void; + subscribeRooms: (roomIds: number[]) => void; + subscribeInvitations: (userId: number) => void; + subscribeFriendConnection: (userId: number) => void; + unsubscribeAll: () => void; +} + +export const useWebSocketStore = create((set, get) => ({ + socket: null, + client: null, + subscriptions: new Map(), + + // 연결 + connect: () => { + if (get().client?.connected) { + get().disconnect(); + return; + } + // 기존 소켓 정리 + if (get().socket) { + get().socket?.close(); + } + + const socket = new SockJS(import.meta.env.VITE_WEBSOCKET_URL); + const client = Stomp.over(socket); + + client.connect( + {}, + () => { + console.log('✅ WebSocket Connected'); + set({ client, socket }); + + const userId = useUserStore.getState().user?.userId; + if (userId) { + client.send('/app/connect', {}, JSON.stringify({ userId })); + get().subscribeInvitations(userId); + get().subscribeFriendConnection(userId); + } + }, + error => { + console.error('❌ WebSocket Connection Error:', error); + set({ client: null }); + }, + ); + }, + + // 연결 해제 + disconnect: () => { + const store = get(); + if (!store.client) return; + + store.unsubscribeAll(); + + store.client.disconnect(() => { + set({ client: null }); + }); + }, + + // 토픽 구독 + subTopic: (destination: string, callback: (message: T) => void) => { + const { client } = get(); + if (!client) return; + + if (get().subscriptions.has(destination)) { + const existingSub = get().subscriptions.get(destination); + existingSub?.unsubscribe(); + get().subscriptions.delete(destination); + } + + const subscription = client.subscribe(destination, message => { + const receivedMessage = JSON.parse(message.body); + + callback(receivedMessage); + }); + + get().subscriptions.set(destination, subscription); + }, + + // 친구 접속 알림 구독 + subscribeFriendConnection: (userId: number) => { + const destination = `/topic/user/${userId}/friend-state`; + get().subTopic(destination, message => { + console.log('subscribeFriendConnection', message); + }); + }, + + // 초대 구독 + subscribeInvitations: (userId: number) => { + const destination = `/topic/user/${userId}/notification`; + get().subTopic(destination, message => { + console.log('subscribeInvitations', message); + }); + }, + + // 방 채팅 구독 + subscribeRoom: (roomId: number) => { + const destination = `/topic/room/${roomId}/chat`; + get().subTopic(destination, message => { + console.log('subscribeRoom', message); + }); + }, + + // 내가 속한 방 채팅 구독 + subscribeRooms: (roomIds: number[]) => { + if (!roomIds) return; + + roomIds.forEach(roomId => { + get().subscribeRoom(roomId); + }); + }, + + // 구독 해제 + unsubscribeAll: () => { + get().subscriptions.forEach(sub => sub.unsubscribe()); + set({ subscriptions: new Map() }); + }, +})); diff --git a/src/frontend/src/types/dto/Message.dto.ts b/src/frontend/src/types/dto/Message.dto.ts new file mode 100644 index 00000000..e48073cd --- /dev/null +++ b/src/frontend/src/types/dto/Message.dto.ts @@ -0,0 +1,10 @@ +export interface SendMessageDto { + roomId: number; + userId: number; + content?: string; + message?: string; +} + +export interface ReceiveMessageDto extends SendMessageDto { + timestamp: string; +} diff --git a/src/frontend/src/ui/Common.css.ts b/src/frontend/src/ui/Common.css.ts new file mode 100644 index 00000000..8134338c --- /dev/null +++ b/src/frontend/src/ui/Common.css.ts @@ -0,0 +1,65 @@ +import { styled } from 'styled-components'; + +export const Wrapper = styled.div` + display: flex; + justify-content: center; + width: 100%; + height: 100%; + padding: 1rem; +`; + +export const Container = styled.div` + width: 100%; + max-width: 720px; + display: flex; + flex-direction: column; + gap: 1rem; +`; + +export const Title = styled.h1` + font-size: 2rem; + font-weight: 600; + padding: 1.5rem 0; +`; + +export const SubTitle = styled.h1` + font-size: 1.5rem; + font-weight: 600; + padding: 2rem 0 1rem; +`; + +export const CommonParagraph = styled.p` + font-size: 1rem; +`; + +export const CommonUl = styled.ul` + width: 100%; + max-height: 300px; + overflow-y: auto; + border-radius: 10px; + text-align: left; + padding: 10px; + background-color: var(--palette-static-white); + border: 1px solid var(--palette-line-solid-alternative); +`; + +export const CommonLi = styled.li<{ $hoverColor?: string }>` + padding: 12px; + border-radius: 10px; + cursor: pointer; + display: flex; + transition: all 0.2s ease; + + &:hover { + font-weight: 600; + color: var(--palette-static-white); + background-color: ${({ $hoverColor = 'var(--palette-line-solid-alternative)' }) => $hoverColor}; + } +`; + +export const Copyright = styled.p` + font-size: 0.875rem; + color: var(--palette-font-gray); + text-align: center; + margin-top: 2rem; +`; diff --git a/src/infra/stomp-tester/stomp-tester.html b/src/infra/stomp-tester/stomp-tester.html new file mode 100644 index 00000000..f12d1a57 --- /dev/null +++ b/src/infra/stomp-tester/stomp-tester.html @@ -0,0 +1,370 @@ + + + + + + STOMP 테스트 + + + + + + + + +
+

WebSocket CONNECT

+
+ + +
+ +

SUBSCRIBE

+
+
+ +
+ +

자주 사용하는 요청

+
+ +
+ +

SEND MESSAGE

+
+ + +
+
+ + +
+ + +

MESSAGES

+
+
+ + + +