From 02ebaa2b314dc5a16e7a4a1c3b725d7b2282a764 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Sat, 24 Jan 2026 11:52:11 +0900 Subject: [PATCH 1/7] =?UTF-8?q?refactor=20:=20AI=20=EB=A7=90=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20routing=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=20(#203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Feat] : AI 프리토킹(Speaking) 기능 구현 (#195) * feat : AI 말하기 연습 화면 구현 * refactor : Rest API 변경에 따른 화면 구현 변경 * feature : AI와 대화하기 UI & STT서비스 구현 * [FEAT] SSE 기반 실시간 알림 시스템 연동 (#197) (#198) * [FEAT] SSE 기반 실시간 알림 시스템 연동 (#197) - SSE(Server-Sent Events)를 통한 실시간 알림 연동 - 알림 타입 정의 (BADGE_EARNED, DAILY_COMPLETE, TEST_COMPLETE 등 8종) - useNotifications 훅 (EventSource API, 자동 재연결) - NotificationContext (알림 상태 관리, 읽음 처리) - NotificationToast (토스트 알림 UI) - NotificationMenu (헤더 드롭다운 메뉴) - VITE_NOTIFICATION_URL 환경 변수 추가 - 기존 하드코딩된 목 알림을 실제 시스템으로 교체 * [FIX] FreetalkAiPage → SpeakingPage 라우트 수정 정의되지 않은 FreetalkAiPage를 SpeakingPage로 교체 * [DEBUG] SSE 연결 디버깅 로그 추가 - connect 함수에 상세 로그 추가 - NotificationContext에 auth 상태 로그 추가 - 브라우저 콘솔에서 연결 상태 확인 가능 * [FIX] StrictMode 중복 연결 방지를 위한 debounce 추가 React StrictMode에서 useEffect가 2번 실행되어 SSE 연결이 중복되는 문제 방지 * [FEAT] 알림 기능 on/off 플래그 추가 (VITE_NOTIFICATION_ENABLED) - VITE_NOTIFICATION_ENABLED 환경 변수로 SSE 연결 제어 - 기본값 false (환경변수 미설정 시 비활성화) - NotificationMenu에 비활성화 상태 UI 추가 - Lambda 동시성 이슈 대응용 * fix : 연속 선언된 변수 t 제거 (#199) * feat : AI 말하기 연습 화면 구현 * refactor : Rest API 변경에 따른 화면 구현 변경 * feature : AI와 대화하기 UI & STT서비스 구현 * [Feat] : AI 프리토킹(Speaking) 기능 구현 (#195) (#196) * feat : AI 말하기 연습 화면 구현 * refactor : Rest API 변경에 따른 화면 구현 변경 * feature : AI와 대화하기 UI & STT서비스 구현 * refactor : AI 말하기 routing 페이지 수정 (#202) * feat : AI 말하기 연습 화면 구현 * refactor : Rest API 변경에 따른 화면 구현 변경 * feature : AI와 대화하기 UI & STT서비스 구현 * refactor : AI 말하기 라우팅 페이지 수정 --------- Co-authored-by: DDING JOO --- src/App.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App.jsx b/src/App.jsx index 45f8415..e8a4800 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1164,7 +1164,7 @@ function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> From 2e1a97eab9de6f9d18d082d08b89a926b6efed8a Mon Sep 17 00:00:00 2001 From: hye-inA Date: Sun, 25 Jan 2026 03:04:23 +0900 Subject: [PATCH 2/7] =?UTF-8?q?refactor=20:=20=ED=94=84=EB=A6=AC=ED=86=A0?= =?UTF-8?q?=ED=82=B9=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=8B=89=EB=84=A4?= =?UTF-8?q?=EC=9E=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../freetalk/hooks/useChatWebSocket.js | 11 +- src/domains/freetalk/pages/ChatRoomPage.jsx | 101 +++++++++--------- 2 files changed, 57 insertions(+), 55 deletions(-) diff --git a/src/domains/freetalk/hooks/useChatWebSocket.js b/src/domains/freetalk/hooks/useChatWebSocket.js index fd6eaaa..633d755 100644 --- a/src/domains/freetalk/hooks/useChatWebSocket.js +++ b/src/domains/freetalk/hooks/useChatWebSocket.js @@ -1,6 +1,6 @@ -import {useCallback, useEffect, useRef, useState} from 'react' -import {chatWebSocketService} from '../services/chatWebSocketService' -import {chatRoomService} from '../../chat/services/chatService' +import { useCallback, useEffect, useRef, useState } from 'react' +import { chatWebSocketService } from '../services/chatWebSocketService' +import { chatRoomService } from '../../chat/services/chatService' /** * Chat WebSocket 훅 @@ -26,10 +26,10 @@ export function useChatWebSocket(roomId, userId) { * WebSocket 연결 */ const connect = useCallback(async (forceNewToken = false) => { - console.log('[useChatWebSocket] Attempting to connect...', {roomId, userId, forceNewToken}) + console.log('[useChatWebSocket] Attempting to connect...', { roomId, userId, forceNewToken }) if (!roomId || !userId) { - console.error('[useChatWebSocket] roomId and userId are required', {roomId, userId}) + console.error('[useChatWebSocket] roomId and userId are required', { roomId, userId }) return } @@ -74,6 +74,7 @@ export function useChatWebSocket(roomId, userId) { id: messageId, content: data.content, userId: data.userId, + nickname: data.nickname, messageType: data.messageType || 'TEXT', createdAt: data.createdAt || new Date().toISOString(), isOwn: data.userId === userId, diff --git a/src/domains/freetalk/pages/ChatRoomPage.jsx b/src/domains/freetalk/pages/ChatRoomPage.jsx index 2a220d6..781a891 100644 --- a/src/domains/freetalk/pages/ChatRoomPage.jsx +++ b/src/domains/freetalk/pages/ChatRoomPage.jsx @@ -1,5 +1,5 @@ -import {useCallback, useEffect, useRef, useState} from 'react' -import {useNavigate, useParams} from 'react-router-dom' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' import { Alert, AppBar, @@ -21,31 +21,31 @@ import { Send as SendIcon, VolumeUp as VolumeUpIcon, } from '@mui/icons-material' -import {chatRoomService, messageService, voiceService} from '../../chat/services/chatService' -import {useAuth} from '../../../contexts/AuthContext' -import {useChatWebSocket} from '../hooks/useChatWebSocket' -import {useThemeMode} from '../../../contexts/ThemeContext' +import { chatRoomService, messageService, voiceService } from '../../chat/services/chatService' +import { useAuth } from '../../../contexts/AuthContext' +import { useChatWebSocket } from '../hooks/useChatWebSocket' +import { useThemeMode } from '../../../contexts/ThemeContext' import CommandAutocomplete from '../components/CommandAutocomplete' import PollCard from '../components/PollCard' import SystemCommandMessage from '../components/SystemCommandMessage' -import {parseCommand, MessageType} from '../types/chatCommandTypes' +import { parseCommand, MessageType } from '../types/chatCommandTypes' const levelColors = { - beginner: {bg: '#e8f5e9', color: '#2e7d32', label: '초급'}, - intermediate: {bg: '#fff3e0', color: '#ef6c00', label: '중급'}, - advanced: {bg: '#fce4ec', color: '#c2185b', label: '고급'}, + beginner: { bg: '#e8f5e9', color: '#2e7d32', label: '초급' }, + intermediate: { bg: '#fff3e0', color: '#ef6c00', label: '중급' }, + advanced: { bg: '#fce4ec', color: '#c2185b', label: '고급' }, } const ChatRoomPage = () => { - const {roomId} = useParams() + const { roomId } = useParams() const navigate = useNavigate() - const {user} = useAuth() - const {mode} = useThemeMode() + const { user } = useAuth() + const { mode } = useThemeMode() const isDark = mode === 'dark' const currentUserId = user?.userId || user?.username || user?.sub // 디버깅: 사용자 정보 확인 - console.log('[ChatRoomPage] User info:', {user, currentUserId, roomId}) + console.log('[ChatRoomPage] User info:', { user, currentUserId, roomId }) const messagesEndRef = useRef(null) const messagesContainerRef = useRef(null) @@ -90,11 +90,12 @@ const ChatRoomPage = () => { // 기존 메시지 목록 조회 (초기 로드용) const fetchMessages = useCallback(async () => { try { - const response = await messageService.getList(roomId, {limit: 50}) + const response = await messageService.getList(roomId, { limit: 50 }) const transformedMessages = (response.messages || []).map((msg, index) => ({ id: msg.messageId || `msg-${index}-${Date.now()}`, content: msg.content, userId: msg.userId, + nickname: msg.nickname, messageType: msg.messageType, createdAt: new Date(msg.createdAt), isOwn: msg.userId === currentUserId, @@ -122,12 +123,12 @@ const ChatRoomPage = () => { // WebSocket 연결 (별도 effect) useEffect(() => { - console.log('[ChatRoomPage] WebSocket effect triggered:', {roomId, currentUserId, isConnected}) + console.log('[ChatRoomPage] WebSocket effect triggered:', { roomId, currentUserId, isConnected }) if (currentUserId && roomId) { - console.log('[ChatRoomPage] Connecting WebSocket...', {roomId, currentUserId}) + console.log('[ChatRoomPage] Connecting WebSocket...', { roomId, currentUserId }) wsConnect() } else { - console.log('[ChatRoomPage] Missing required values:', {roomId, currentUserId}) + console.log('[ChatRoomPage] Missing required values:', { roomId, currentUserId }) } return () => { @@ -138,7 +139,7 @@ const ChatRoomPage = () => { // 스크롤 맨 아래로 const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({behavior: 'smooth'}) + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) } useEffect(() => { @@ -161,7 +162,7 @@ const ChatRoomPage = () => { setShowCommandAutocomplete(false) // 명령어 파싱 - const {isCommand, command, args} = parseCommand(messageContent) + const { isCommand, command, args } = parseCommand(messageContent) // /leave 명령어는 클라이언트에서 직접 처리 if (isCommand && command === '/leave') { @@ -293,23 +294,23 @@ const ChatRoomPage = () => { if (loading) { return ( - - + + ) } return ( - + {/* 헤더 */} - navigate('/freetalk/people')} sx={{mr: 1}}> - + navigate('/freetalk/people')} sx={{ mr: 1 }}> + - - - + + + {room?.name || '채팅방'} {/* 연결 상태 표시 */} @@ -320,7 +321,7 @@ const ChatRoomPage = () => { }} /> - + {room?.level && ( { - + - + {/* 에러 메시지 */} {error && ( - + {error} )} @@ -370,7 +371,7 @@ const ChatRoomPage = () => { }} > {messages.length === 0 ? ( - + 아직 메시지가 없습니다. 첫 메시지를 보내보세요! @@ -380,7 +381,7 @@ const ChatRoomPage = () => { // 투표 메시지 if (message.messageType === MessageType.POLL_CREATE) { return ( - + { // 시스템 명령어 메시지 if (message.messageType === MessageType.SYSTEM_COMMAND) { return ( - + ) } @@ -411,7 +412,7 @@ const ChatRoomPage = () => { > {/* 시스템 메시지 */} {message.isSystem ? ( - + {message.content} @@ -420,8 +421,8 @@ const ChatRoomPage = () => { <> {/* 아바타 (상대방만) */} {!message.isOwn && ( - - {message.userId?.charAt(0)?.toUpperCase() || 'U'} + + {(message.nickname || message.userId)?.charAt(0)?.toUpperCase() || 'U'} )} @@ -435,13 +436,13 @@ const ChatRoomPage = () => { > {/* 사용자 이름 (상대방만) */} {!message.isOwn && ( - - {message.userId} + + {message.nickname || message.userId} )} {/* 메시지 버블 */} - + {message.isOwn && ( {formatTime(message.createdAt)} @@ -463,23 +464,23 @@ const ChatRoomPage = () => { opacity: message.isPending ? 0.7 : 1, }} > - + {message.content} {!message.isOwn && ( - + handlePlayTTS(message.id)} disabled={playingTTS === message.id} - sx={{p: 0.5}} + sx={{ p: 0.5 }} > {playingTTS === message.id ? ( - + ) : ( - + )} @@ -495,7 +496,7 @@ const ChatRoomPage = () => { ) }) )} -
+
{/* 입력 영역 */} @@ -539,11 +540,11 @@ const ChatRoomPage = () => { sx={{ bgcolor: 'primary.main', color: 'white', - '&:hover': {bgcolor: 'primary.dark'}, - '&:disabled': {bgcolor: 'grey.300'}, + '&:hover': { bgcolor: 'primary.dark' }, + '&:disabled': { bgcolor: 'grey.300' }, }} > - + From 640fdf219b611e32fd03ee5fef7b237958cf9cbe Mon Sep 17 00:00:00 2001 From: hye-inA Date: Sun, 25 Jan 2026 18:46:50 +0900 Subject: [PATCH 3/7] =?UTF-8?q?refactor=20:=20=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94=20=ED=94=84=EB=A1=9C=ED=95=84=20=EB=93=9C?= =?UTF-8?q?=EB=A1=AD=EB=8B=A4=EC=9A=B4=20=EB=A9=94=EB=89=B4=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=A0=95=EB=B3=B4=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.jsx | 11 ++ src/aws-config.js | 4 +- src/domains/profile/hooks/useProfile.js | 35 ++++++ .../profile/services/profileService.js | 39 ++++++ src/domains/profile/store/profileSlice.js | 114 ++++++++++++++++++ src/layouts/MainLayout/Header/index.jsx | 82 +++++++------ src/store/index.js | 5 +- 7 files changed, 247 insertions(+), 43 deletions(-) create mode 100644 src/domains/profile/hooks/useProfile.js create mode 100644 src/domains/profile/services/profileService.js create mode 100644 src/domains/profile/store/profileSlice.js diff --git a/src/App.jsx b/src/App.jsx index ab94f58..6896eeb 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react' import { Navigate, Route, Routes, useNavigate } from 'react-router-dom' +import { useDispatch } from 'react-redux' import { Box, Button, Card, CardContent, CircularProgress, Collapse, Container, Grid, Typography } from '@mui/material' import { ChevronRight as ChevronRightIcon, @@ -43,6 +44,7 @@ import { useSettings } from './contexts/SettingsContext' import { useAuth } from './contexts/AuthContext' import LoginPage from './pages/Login' import SignUpPage from './pages/SignUp' +import { fetchMyProfile } from "./domains/profile/store/profileSlice"; function ProtectedRoute({ children }) { @@ -1141,8 +1143,17 @@ function NotFound() { } function App() { + const dispatch = useDispatch() + const { isAuthenticated } = useAuth() const { activeRoom, closeChatRoom } = useChat() + useEffect(() => { + if (isAuthenticated) { + // redux로 프로필 정보 API(/users/profile/me) 호출 + dispatch(fetchMyProfile()) + } + }, [isAuthenticated, dispatch]) + const handleRefreshRooms = () => { // Refresh rooms list after leaving a room } diff --git a/src/aws-config.js b/src/aws-config.js index d4d0a9d..6d2b672 100644 --- a/src/aws-config.js +++ b/src/aws-config.js @@ -1,8 +1,8 @@ const awsConfig = { Auth: { Cognito: { - userPoolId: 'ap-northeast-2_ezDwzFCzR', - userPoolClientId: '4ns077jcr1pkue2vvisr6qdpu5', + userPoolId: import.meta.env.VITE_COGNITO_POOL_ID, + userPoolClientId: import.meta.env.VITE_COGNITO_CLIENT_ID, loginWith: { email: true, diff --git a/src/domains/profile/hooks/useProfile.js b/src/domains/profile/hooks/useProfile.js new file mode 100644 index 0000000..3e965c4 --- /dev/null +++ b/src/domains/profile/hooks/useProfile.js @@ -0,0 +1,35 @@ +import { useEffect } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { + fetchMyProfile, + updateProfile, + uploadProfileImage, + clearError +} from '../store/profileSlice' + +export const useProfile = () => { + const dispatch = useDispatch() + const { profile, loading, error, updateLoading, imageUploading } = useSelector( + (state) => state.profile + ) + + useEffect(() => { + if (!profile && !loading && !error) { + dispatch(fetchMyProfile()) + } + }, [dispatch, profile, loading, error]) + + return { + profile, + loading, + error, + updateLoading, + imageUploading, + updateProfile: (data) => dispatch(updateProfile(data)).unwrap(), + uploadImage: (file) => dispatch(uploadProfileImage(file)).unwrap(), + clearError: () => dispatch(clearError()), + refetch: () => dispatch(fetchMyProfile()) + } +} + +export default useProfile \ No newline at end of file diff --git a/src/domains/profile/services/profileService.js b/src/domains/profile/services/profileService.js new file mode 100644 index 0000000..c41fcdc --- /dev/null +++ b/src/domains/profile/services/profileService.js @@ -0,0 +1,39 @@ +import api from '../../../api/axios' + +const profileService = { + // 내 프로필 조회 + getMyProfile: async () => { + const response = await api.get('/users/profile/me') + return response.data + }, + + // 프로필 수정 (닉네임, 레벨) + updateProfile: async ({ nickname, level, profileUrl }) => { + const response = await api.put('/users/profile/me', { + nickname, + level, + profileUrl + }) + return response.data + }, + + // 이미지 업로드 URL 발급 + getImageUploadUrl: async (fileName, contentType) => { + const response = await api.post('/users/profile/me/image', { + fileName, + contentType + }) + return response.data + }, + + // S3에 이미지 직접 업로드 + uploadImageToS3: async (uploadUrl, file) => { + await fetch(uploadUrl, { + method: 'PUT', + headers: { 'Content-Type': file.type }, + body: file + }) + } +} + +export default profileService \ No newline at end of file diff --git a/src/domains/profile/store/profileSlice.js b/src/domains/profile/store/profileSlice.js new file mode 100644 index 0000000..76995ec --- /dev/null +++ b/src/domains/profile/store/profileSlice.js @@ -0,0 +1,114 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' +import profileService from '../services/profileService' + +// 프로필 조회 +export const fetchMyProfile = createAsyncThunk( + 'profile/fetchMyProfile', + async (_, { rejectWithValue }) => { + try { + const response = await profileService.getMyProfile() + return response.data || response + } catch (error) { + const status = error.response?.status + const message = error.response?.data?.error || error.message || '프로필 조회 실패' + return rejectWithValue({ message, status }) + } + } +) + +// 프로필 수정 +export const updateProfile = createAsyncThunk( + 'profile/updateProfile', + async (data, { rejectWithValue }) => { + try { + const response = await profileService.updateProfile(data) + return response.data + } catch (error) { + return rejectWithValue(error.response?.data?.error || '프로필 수정 실패') + } + } +) + +// 이미지 업로드 +export const uploadProfileImage = createAsyncThunk( + 'profile/uploadImage', + async (file, { dispatch, rejectWithValue }) => { + try { + const urlResponse = await profileService.getImageUploadUrl(file.name, file.type) + const { uploadUrl, imageUrl } = urlResponse.data + + await profileService.uploadImageToS3(uploadUrl, file) + await dispatch(updateProfile({ profileUrl: imageUrl })) + + return imageUrl + } catch (error) { + return rejectWithValue('이미지 업로드 실패') + } + } +) + +const profileSlice = createSlice({ + name: 'profile', + initialState: { + profile: null, + loading: false, + error: null, + updateLoading: false, + imageUploading: false, + authError: false + }, + reducers: { + clearError: (state) => { state.error = null }, + clearProfile: (state) => { state.profile = null } + }, + extraReducers: (builder) => { + builder + // fetchMyProfile + .addCase(fetchMyProfile.pending, (state) => { + state.loading = true + state.error = null + }) + .addCase(fetchMyProfile.fulfilled, (state, action) => { + state.loading = false + state.profile = action.payload + }) + .addCase(fetchMyProfile.rejected, (state, action) => { + state.loading = false + state.error = action.payload?.message || action.payload + + const status = action.payload?.status + const message = String(action.payload?.message || action.payload || '') + + if (status === 401 || message.includes('401') || message.includes('인증')) { + state.profile = null + state.authError = true + } + }) + // updateProfile + .addCase(updateProfile.pending, (state) => { + state.updateLoading = true + }) + .addCase(updateProfile.fulfilled, (state, action) => { + state.updateLoading = false + state.profile = action.payload + }) + .addCase(updateProfile.rejected, (state, action) => { + state.updateLoading = false + state.error = action.payload + }) + // uploadProfileImage + .addCase(uploadProfileImage.pending, (state) => { + state.imageUploading = true + }) + .addCase(uploadProfileImage.fulfilled, (state) => { + state.imageUploading = false + }) + .addCase(uploadProfileImage.rejected, (state, action) => { + state.imageUploading = false + state.error = action.payload + }) + } +}) + +export const { clearError, clearProfile } = profileSlice.actions +export default profileSlice.reducer \ No newline at end of file diff --git a/src/layouts/MainLayout/Header/index.jsx b/src/layouts/MainLayout/Header/index.jsx index f52deba..d4c6583 100644 --- a/src/layouts/MainLayout/Header/index.jsx +++ b/src/layouts/MainLayout/Header/index.jsx @@ -1,5 +1,6 @@ -import {useState} from 'react' -import {useNavigate} from 'react-router-dom' +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useSelector } from 'react-redux' import { AppBar, Avatar, @@ -24,25 +25,26 @@ import { Settings as SettingsIcon, Translate as TranslateIcon, } from '@mui/icons-material' -import {useThemeMode} from '../../../contexts/ThemeContext' -import {useSettings, useTranslation} from '../../../contexts/SettingsContext' -import {LANGUAGE_LABELS, LANGUAGES} from '../../../i18n/translations' -import {useAuth} from '../../../contexts/AuthContext' -import {useNotificationContext, NotificationMenu} from '../../../domains/notification' +import { useThemeMode } from '../../../contexts/ThemeContext' +import { useSettings, useTranslation } from '../../../contexts/SettingsContext' +import { LANGUAGE_LABELS, LANGUAGES } from '../../../i18n/translations' +import { useAuth } from '../../../contexts/AuthContext' +import { useNotificationContext, NotificationMenu } from '../../../domains/notification' -const Header = ({onMenuClick, sidebarOpen}) => { +const Header = ({ onMenuClick, sidebarOpen }) => { const theme = useTheme() const navigate = useNavigate() const isMobile = useMediaQuery(theme.breakpoints.down('md')) - const {mode, toggleTheme} = useThemeMode() - const {setLanguage, language} = useSettings() - const {t} = useTranslation() + const { mode, toggleTheme } = useThemeMode() + const { setLanguage, language } = useSettings() + const { t } = useTranslation() + const { profile } = useSelector((state) => state.profile) const [anchorEl, setAnchorEl] = useState(null) const [notificationAnchor, setNotificationAnchor] = useState(null) const [langAnchor, setLangAnchor] = useState(null) - const {logout} = useAuth() - const {unreadCount} = useNotificationContext() + const { logout } = useAuth() + const { unreadCount } = useNotificationContext() const handleProfileMenuOpen = (event) => { setAnchorEl(event.currentTarget) @@ -93,7 +95,7 @@ const Header = ({onMenuClick, sidebarOpen}) => { borderColor: mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)', }} > - + {/* Hamburger menu (mobile) */} {isMobile && ( { }, }} > - + )} @@ -145,7 +147,7 @@ const Header = ({onMenuClick, sidebarOpen}) => { L - + { - + {/* Right side icons */} - + {/* Language selector */} { }, }} > - + {/* Dark mode toggle */} @@ -192,7 +194,7 @@ const Header = ({onMenuClick, sidebarOpen}) => { }, }} > - {mode === 'dark' ? : } + {mode === 'dark' ? : } {/* Notifications */} @@ -219,14 +221,14 @@ const Header = ({onMenuClick, sidebarOpen}) => { }, }} > - + {/* Profile */} { fontSize: 14, }} > - U + {profile?.nickname ? profile.nickname.substring(0, 1).toUpperCase() : 'U'} @@ -255,15 +257,15 @@ const Header = ({onMenuClick, sidebarOpen}) => { minWidth: 160, }, }} - transformOrigin={{horizontal: 'right', vertical: 'top'}} - anchorOrigin={{horizontal: 'right', vertical: 'bottom'}} + transformOrigin={{ horizontal: 'right', vertical: 'top' }} + anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} > - + {t('settings.language')} - + {Object.entries(LANGUAGES).map(([key, value]) => ( { boxShadow: '0 10px 40px -10px rgba(0,0,0,0.2)', }, }} - transformOrigin={{horizontal: 'right', vertical: 'top'}} - anchorOrigin={{horizontal: 'right', vertical: 'bottom'}} + transformOrigin={{ horizontal: 'right', vertical: 'top' }} + anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} > - + - {t('header.user')} + {profile?.nickname || t('header.user')} - user@example.com + {profile?.email || 'user@example.com'} - + { handleProfileMenuClose(); navigate('/profile'); }} - sx={{py: 1.5, px: 2.5}} + sx={{ py: 1.5, px: 2.5 }} > - + {t('nav.profile')} { handleProfileMenuClose(); navigate('/settings'); }} - sx={{py: 1.5, px: 2.5}} + sx={{ py: 1.5, px: 2.5 }} > - + {t('nav.settings')} - + - + {t('nav.logout')} diff --git a/src/store/index.js b/src/store/index.js index 543e164..17e676c 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -1,4 +1,6 @@ -import {configureStore, createSlice} from '@reduxjs/toolkit' +import { configureStore, createSlice } from '@reduxjs/toolkit' + +import profileReducer from '../domains/profile/store/profileSlice' // 임시 슬라이스 (빈 store 에러 방지) const appSlice = createSlice({ @@ -12,6 +14,7 @@ const appSlice = createSlice({ export const store = configureStore({ reducer: { app: appSlice.reducer, + profile: profileReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ From 023841e396538a909a5ee33e2b60bfc7be3e2f77 Mon Sep 17 00:00:00 2001 From: hye-inA Date: Sun, 25 Jan 2026 19:39:56 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat=20:=20=EB=82=B4=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EB=B0=8F=20=EB=8B=89=EB=84=A4=EC=9E=84=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.jsx | 2 + src/domains/profile/pages/ProfilePage.jsx | 106 ++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 src/domains/profile/pages/ProfilePage.jsx diff --git a/src/App.jsx b/src/App.jsx index 6896eeb..dcebea2 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -45,6 +45,7 @@ import { useAuth } from './contexts/AuthContext' import LoginPage from './pages/Login' import SignUpPage from './pages/SignUp' import { fetchMyProfile } from "./domains/profile/store/profileSlice"; +import ProfilePage from './domains/profile/pages/ProfilePage' function ProtectedRoute({ children }) { @@ -1183,6 +1184,7 @@ function App() { }> } /> } /> + } /> } /> } /> } /> diff --git a/src/domains/profile/pages/ProfilePage.jsx b/src/domains/profile/pages/ProfilePage.jsx new file mode 100644 index 0000000..2f29b8b --- /dev/null +++ b/src/domains/profile/pages/ProfilePage.jsx @@ -0,0 +1,106 @@ +import { useState, useEffect } from 'react' +import { Box, Button, Card, CardContent, Container, TextField, Typography, Avatar, Alert } from '@mui/material' +import { useDispatch, useSelector } from 'react-redux' + +// ▼▼▼ [중요] 파일 위치가 바뀌었으니 import 경로도 수정했습니다! ▼▼▼ +// (pages 폴더와 store 폴더가 같은 profile 폴더 안에 형제(sibling) 관계이므로) +import { updateProfile } from '../store/profileSlice' + +const ProfilePage = () => { + const dispatch = useDispatch() + const { profile, updateLoading } = useSelector((state) => state.profile) + + const [nickname, setNickname] = useState('') + const [successMsg, setSuccessMsg] = useState('') + + // 리덕스에 있는 내 정보(profile)가 로드되면 입력창에 채워넣기 + useEffect(() => { + if (profile?.nickname) { + setNickname(profile.nickname) + } + }, [profile]) + + const handleSave = async () => { + try { + // 1. 닉네임 변경 요청 보내기 + await dispatch(updateProfile({ nickname })).unwrap() + + // 2. 성공 메시지 띄우기 + setSuccessMsg('닉네임이 변경되었습니다! 로그아웃 후 다시 로그인해주세요.') + } catch (error) { + alert('변경 실패: ' + error) + } + } + + return ( + + + 내 프로필 수정 + + + + + + {/* 프로필 이미지 */} + + + {nickname ? nickname.substring(0, 1).toUpperCase() : 'U'} + + + + {/* 이메일 (수정 불가) */} + + + {/* 닉네임 (수정 가능) */} + setNickname(e.target.value)} + fullWidth + variant="outlined" + helperText="채팅방에서 사용할 멋진 닉네임을 지어주세요!" + /> + + {/* 성공 메시지 */} + {successMsg && ( + {successMsg} + )} + + {/* 저장 버튼 */} + + + + + + ) +} + +export default ProfilePage \ No newline at end of file From 74f6fa32bbcb6439b18781034ec771f274273228 Mon Sep 17 00:00:00 2001 From: DDING JOO Date: Sun, 25 Jan 2026 21:28:16 +0900 Subject: [PATCH 5/7] =?UTF-8?q?[FIX]=20=EB=81=9D=EB=A7=90=EC=9E=87?= =?UTF-8?q?=EA=B8=B0=20=EA=B2=8C=EC=9E=84=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20UI=20=EA=B0=9C=EC=84=A0=20(#211)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Feat] : AI 프리토킹(Speaking) 기능 구현 (#195) * feat : AI 말하기 연습 화면 구현 * refactor : Rest API 변경에 따른 화면 구현 변경 * feature : AI와 대화하기 UI & STT서비스 구현 * [FEAT] SSE 기반 실시간 알림 시스템 연동 (#197) (#198) * [FEAT] SSE 기반 실시간 알림 시스템 연동 (#197) - SSE(Server-Sent Events)를 통한 실시간 알림 연동 - 알림 타입 정의 (BADGE_EARNED, DAILY_COMPLETE, TEST_COMPLETE 등 8종) - useNotifications 훅 (EventSource API, 자동 재연결) - NotificationContext (알림 상태 관리, 읽음 처리) - NotificationToast (토스트 알림 UI) - NotificationMenu (헤더 드롭다운 메뉴) - VITE_NOTIFICATION_URL 환경 변수 추가 - 기존 하드코딩된 목 알림을 실제 시스템으로 교체 * [FIX] FreetalkAiPage → SpeakingPage 라우트 수정 정의되지 않은 FreetalkAiPage를 SpeakingPage로 교체 * [DEBUG] SSE 연결 디버깅 로그 추가 - connect 함수에 상세 로그 추가 - NotificationContext에 auth 상태 로그 추가 - 브라우저 콘솔에서 연결 상태 확인 가능 * [FIX] StrictMode 중복 연결 방지를 위한 debounce 추가 React StrictMode에서 useEffect가 2번 실행되어 SSE 연결이 중복되는 문제 방지 * [FEAT] 알림 기능 on/off 플래그 추가 (VITE_NOTIFICATION_ENABLED) - VITE_NOTIFICATION_ENABLED 환경 변수로 SSE 연결 제어 - 기본값 false (환경변수 미설정 시 비활성화) - NotificationMenu에 비활성화 상태 UI 추가 - Lambda 동시성 이슈 대응용 * fix : 연속 선언된 변수 t 제거 (#199) * feat : AI 말하기 연습 화면 구현 * refactor : Rest API 변경에 따른 화면 구현 변경 * feature : AI와 대화하기 UI & STT서비스 구현 * [Feat] : AI 프리토킹(Speaking) 기능 구현 (#195) (#196) * feat : AI 말하기 연습 화면 구현 * refactor : Rest API 변경에 따른 화면 구현 변경 * feature : AI와 대화하기 UI & STT서비스 구현 * refactor : AI 말하기 routing 페이지 수정 (#202) * feat : AI 말하기 연습 화면 구현 * refactor : Rest API 변경에 따른 화면 구현 변경 * feature : AI와 대화하기 UI & STT서비스 구현 * refactor : AI 말하기 라우팅 페이지 수정 * [FEAT] 채팅 슬래시 명령어 시스템 구현 (#200) (#204) - 채팅 명령어 타입 정의 (chatCommandTypes.js) - CommandAutocomplete 컴포넌트 (/ 입력 시 명령어 자동완성) - PollCard, PollResultBar 컴포넌트 (투표 UI) - SystemCommandMessage 컴포넌트 (주사위, 동전, 랜덤 등) - WebSocket 서비스에 새 메시지 타입 핸들링 추가 - ChatRoomPage에 명령어 처리 로직 통합 지원 명령어: - /help, /members, /leave, /clear - /dice, /coin, /random - /poll, /vote, /endpoll * [FEAT] 영어 끝말잇기(Word Chain) 게임 구현 (#205) (#206) - wordchainService.js: API 서비스 (시작, 단어제출, 타임아웃, 종료) - WordchainLobbyPage: 게임 로비 (방 목록, 생성) - WordchainWaitingPage: 대기실 (참가자 대기, 게임 시작) - WordchainPlayPage: 게임 플레이 페이지 - WordDisplay: 현재 단어 & 다음 글자 표시 - WordchainTimer: 원형 타이머 (시간 감소 애니메이션) - PlayerList: 플레이어 목록 (활성/탈락 표시) - UsedWordsList: 사용된 단어 목록 - WordInput: 단어 입력 필드 - GameEndModal: 게임 종료 모달 (순위, 단어 학습) - WebSocket 핸들러 추가 (wordchain_*) - 라우트 및 사이드바 네비게이션 추가 * [FIX] 채팅 슬래시 명령어 버그 수정 (#207) * [FEAT] 채팅 슬래시 명령어 시스템 구현 (#200) - 채팅 명령어 타입 정의 (chatCommandTypes.js) - CommandAutocomplete 컴포넌트 (/ 입력 시 명령어 자동완성) - PollCard, PollResultBar 컴포넌트 (투표 UI) - SystemCommandMessage 컴포넌트 (주사위, 동전, 랜덤 등) - WebSocket 서비스에 새 메시지 타입 핸들링 추가 - ChatRoomPage에 명령어 처리 로직 통합 지원 명령어: - /help, /members, /leave, /clear - /dice, /coin, /random - /poll, /vote, /endpoll * [FIX] 시스템 명령어 메시지 백엔드 응답 구조 대응 - useChatWebSocket: 백엔드 응답을 프론트엔드 형식으로 변환 - SystemCommandMessage: 다양한 응답 구조 처리 - displayText(message) 필드 우선 표시 * [FIX] 시스템 명령어 content 필드 매핑 수정 * [DEBUG] 시스템 명령어 메시지 디버깅 로그 추가 * [DEBUG] 시스템 명령어 메시지 추가 로그 * [DEBUG] ChatRoomPage 메시지 렌더링 로그 추가 * [DEBUG] SYSTEM_COMMAND 조건 체크 로그 * [FIX] ChatRoomModal에 SYSTEM_COMMAND 메시지 렌더링 추가 * [CLEANUP] 디버깅 로그 제거 * refactor : 로그인/인증 로직 정상화 및 채팅 서버 연결 및 메인 화면 헤더 프로필 상태 표시 (#208) * refactor : 프리토킹 사용자 닉네임 수정 * refactor : 메인 헤더 프로필 드롭다운 메뉴 사용자 정보 동기화 리팩토링 * [FIX] 끝말잇기 게임 버그 수정 및 UI 개선 (#210) * fix: API 토큰 갱신 로직 통합 및 Cognito 설정 환경변수화 - 모든 API 호출을 공통 axios 인스턴스로 통합 - 401 오류 시 토큰 자동 갱신 로직 일원화 - aws-config.js의 Cognito 설정을 환경변수로 변경 - newsService.js를 fetch에서 axios로 변경 * [FIX] 끝말잇기 게임 버그 수정 및 UI 개선 - 타이머 타임아웃 중복 호출 버그 수정 (timeoutSent 플래그 추가) - 플레이어 닉네임 표시 수정 (userId 문자열 배열 → 객체 배열 매핑) - 번역 파일에 끝말잇기 관련 번역 추가 (games.wordchainTitle, games.wordchainDesc) - 대시보드 끝말잇기 카드 번역 함수 적용 --------- Co-authored-by: hyein Heo <128613248+hye-inA@users.noreply.github.com> --- src/App.jsx | 24 + src/api/axios.js | 15 +- src/api/badgeApi.js | 41 +- src/api/chatApi.js | 49 +- src/api/grammarApi.js | 51 +- src/api/speakingApi.js | 52 +- src/api/vocabApi.js | 49 +- src/aws-config.js | 4 +- .../freetalk/components/ChatRoomModal.jsx | 14 +- .../components/CommandAutocomplete.jsx | 150 ++++++ src/domains/freetalk/components/PollCard.jsx | 175 +++++++ .../freetalk/components/PollResultBar.jsx | 57 +++ .../components/SystemCommandMessage.jsx | 211 ++++++++ .../freetalk/hooks/useChatWebSocket.js | 199 +++++++- src/domains/freetalk/pages/ChatRoomPage.jsx | 326 +++++++----- .../freetalk/services/chatWebSocketService.js | 54 +- .../freetalk/types/chatCommandTypes.js | 219 ++++++++ .../games/components/CreateGameRoomModal.jsx | 147 ++++-- .../components/wordchain/GameEndModal.jsx | 179 +++++++ .../games/components/wordchain/PlayerList.jsx | 89 ++++ .../components/wordchain/UsedWordsList.jsx | 49 ++ .../components/wordchain/WordDisplay.jsx | 73 +++ .../games/components/wordchain/WordInput.jsx | 96 ++++ .../components/wordchain/WordchainTimer.jsx | 59 +++ .../games/pages/WordchainLobbyPage.jsx | 310 ++++++++++++ src/domains/games/pages/WordchainPlayPage.jsx | 477 ++++++++++++++++++ .../games/pages/WordchainWaitingPage.jsx | 394 +++++++++++++++ src/domains/games/services/gameService.js | 27 +- .../games/services/wordchainService.js | 58 +++ src/domains/news/services/newsService.js | 35 +- src/domains/profile/hooks/useProfile.js | 35 ++ .../profile/services/profileService.js | 39 ++ src/domains/profile/store/profileSlice.js | 114 +++++ src/i18n/translations.js | 4 + src/layouts/MainLayout/Header/index.jsx | 82 +-- .../MainLayout/HorizontalNav/index.jsx | 7 + src/layouts/MainLayout/Sidebar/index.jsx | 7 + src/store/index.js | 5 +- 38 files changed, 3543 insertions(+), 433 deletions(-) create mode 100644 src/domains/freetalk/components/CommandAutocomplete.jsx create mode 100644 src/domains/freetalk/components/PollCard.jsx create mode 100644 src/domains/freetalk/components/PollResultBar.jsx create mode 100644 src/domains/freetalk/components/SystemCommandMessage.jsx create mode 100644 src/domains/freetalk/types/chatCommandTypes.js create mode 100644 src/domains/games/components/wordchain/GameEndModal.jsx create mode 100644 src/domains/games/components/wordchain/PlayerList.jsx create mode 100644 src/domains/games/components/wordchain/UsedWordsList.jsx create mode 100644 src/domains/games/components/wordchain/WordDisplay.jsx create mode 100644 src/domains/games/components/wordchain/WordInput.jsx create mode 100644 src/domains/games/components/wordchain/WordchainTimer.jsx create mode 100644 src/domains/games/pages/WordchainLobbyPage.jsx create mode 100644 src/domains/games/pages/WordchainPlayPage.jsx create mode 100644 src/domains/games/pages/WordchainWaitingPage.jsx create mode 100644 src/domains/games/services/wordchainService.js create mode 100644 src/domains/profile/hooks/useProfile.js create mode 100644 src/domains/profile/services/profileService.js create mode 100644 src/domains/profile/store/profileSlice.js diff --git a/src/App.jsx b/src/App.jsx index e8a4800..1f9a6fe 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react' import { Navigate, Route, Routes, useNavigate } from 'react-router-dom' +import { useDispatch } from 'react-redux' import { Box, Button, Card, CardContent, CircularProgress, Collapse, Container, Grid, Typography } from '@mui/material' import { ChevronRight as ChevronRightIcon, @@ -32,6 +33,9 @@ import { BadgeSection } from './domains/badge' import CatchmindLobbyPage from './domains/games/pages/CatchmindLobbyPage' import CatchmindWaitingPage from './domains/games/pages/CatchmindWaitingPage' import CatchmindPlayPage from './domains/games/pages/CatchmindPlayPage' +import WordchainLobbyPage from './domains/games/pages/WordchainLobbyPage' +import WordchainWaitingPage from './domains/games/pages/WordchainWaitingPage' +import WordchainPlayPage from './domains/games/pages/WordchainPlayPage' import { NewsListPage, NewsDetailPage, NewsQuizPage, NewsWordsPage, NewsStatsPage } from './domains/news' import { dailyService, statsService } from './domains/vocab/services/vocabService' import { getNewsStats, getDashboardStats } from './domains/news/services/newsService' @@ -40,6 +44,7 @@ import { useSettings } from './contexts/SettingsContext' import { useAuth } from './contexts/AuthContext' import LoginPage from './pages/Login' import SignUpPage from './pages/SignUp' +import { fetchMyProfile } from "./domains/profile/store/profileSlice"; function ProtectedRoute({ children }) { @@ -269,6 +274,13 @@ function Dashboard() { path: '/games/catchmind', description: t('games.catchmindDesc') }, + { + id: 'wordchain', + title: t('games.wordchainTitle'), + icon: GameIcon, + path: '/games/wordchain', + description: t('games.wordchainDesc') + }, ], }, ] @@ -1131,8 +1143,17 @@ function NotFound() { } function App() { + const dispatch = useDispatch() + const { isAuthenticated } = useAuth() const { activeRoom, closeChatRoom } = useChat() + useEffect(() => { + if (isAuthenticated) { + // redux로 프로필 정보 API(/users/profile/me) 호출 + dispatch(fetchMyProfile()) + } + }, [isAuthenticated, dispatch]) + const handleRefreshRooms = () => { // Refresh rooms list after leaving a room } @@ -1176,6 +1197,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/src/api/axios.js b/src/api/axios.js index 944cc96..ae97915 100644 --- a/src/api/axios.js +++ b/src/api/axios.js @@ -42,18 +42,29 @@ api.interceptors.response.use( // 401 에러 && 재시도하지 않을 경우 if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true + console.log('[Axios] 401 detected, attempting token refresh...') try { - // 토큰 갱신 시도 + // 토큰 갱신 시도 (API Gateway는 idToken을 기대) const session = await fetchAuthSession({ forceRefresh: true }) - const newToken = session.tokens?.accessToken?.toString() + console.log('[Axios] Session fetched:', !!session, 'tokens:', !!session?.tokens) + const newToken = session.tokens?.idToken?.toString() + console.log('[Axios] New token obtained:', !!newToken, 'length:', newToken?.length) if (newToken) { localStorage.setItem('accessToken', newToken) originalRequest.headers['Authorization'] = `Bearer ${newToken}` + console.log('[Axios] Retrying request with new token') return api(originalRequest) + } else { + console.log('[Axios] No token received, redirecting to login') + localStorage.removeItem('accessToken') + if (window.location.pathname !== '/login') { + window.location.href = '/login' + } } } catch (refreshError) { + console.error('[Axios] Token refresh failed:', refreshError) try { await signOut() } catch (e) { diff --git a/src/api/badgeApi.js b/src/api/badgeApi.js index 52654bb..1df2de3 100644 --- a/src/api/badgeApi.js +++ b/src/api/badgeApi.js @@ -1,32 +1,13 @@ -import axios from 'axios' - -const badgeApi = axios.create({ - baseURL: import.meta.env.VITE_BADGE_API_URL || import.meta.env.VITE_API_URL, - timeout: 10000, - headers: { - 'Content-Type': 'application/json', - }, -}) - -// Request interceptor for JWT token -badgeApi.interceptors.request.use( - (config) => { - const token = localStorage.getItem('accessToken') - if (token) { - config.headers.Authorization = `Bearer ${token}` - } - return config - }, - (error) => Promise.reject(error) -) - -// Response interceptor for error handling -badgeApi.interceptors.response.use( - (response) => response.data, - (error) => { - console.error('Badge API Error:', error.response?.data || error.message) - return Promise.reject(error) - } -) +import api from './axios' + +// 공통 axios 인스턴스 사용 (토큰 갱신 로직 포함) +// response.data 자동 추출을 위한 래퍼 +const badgeApi = { + get: (url, config) => api.get(url, config).then(res => res.data), + post: (url, data, config) => api.post(url, data, config).then(res => res.data), + put: (url, data, config) => api.put(url, data, config).then(res => res.data), + patch: (url, data, config) => api.patch(url, data, config).then(res => res.data), + delete: (url, config) => api.delete(url, config).then(res => res.data), +} export default badgeApi diff --git a/src/api/chatApi.js b/src/api/chatApi.js index a41d0fe..101ceb0 100644 --- a/src/api/chatApi.js +++ b/src/api/chatApi.js @@ -1,40 +1,13 @@ -import axios from 'axios' - -const chatApi = axios.create({ - baseURL: import.meta.env.VITE_CHAT_API_URL || import.meta.env.VITE_API_URL, - timeout: 10000, - headers: { - 'Content-Type': 'application/json', - }, -}) - -// Request interceptor - JWT 토큰 자동 추가 -chatApi.interceptors.request.use( - (config) => { - const token = localStorage.getItem('accessToken') - if (token) { - config.headers.Authorization = `Bearer ${token}` - } - return config - }, - (error) => { - return Promise.reject(error) - } -) - -// Response interceptor - 401 에러 시 로그인 페이지로 이동 -chatApi.interceptors.response.use( - (response) => response.data, - (error) => { - console.error('Chat API Error:', error.response?.data || error.message) - - if (error.response?.status === 401) { - localStorage.removeItem('accessToken') - window.location.href = '/login' - } - - return Promise.reject(error) - } -) +import api from './axios' + +// 공통 axios 인스턴스 사용 (토큰 갱신 로직 포함) +// response.data 자동 추출을 위한 래퍼 +const chatApi = { + get: (url, config) => api.get(url, config).then(res => res.data), + post: (url, data, config) => api.post(url, data, config).then(res => res.data), + put: (url, data, config) => api.put(url, data, config).then(res => res.data), + patch: (url, data, config) => api.patch(url, data, config).then(res => res.data), + delete: (url, config) => api.delete(url, config).then(res => res.data), +} export default chatApi diff --git a/src/api/grammarApi.js b/src/api/grammarApi.js index 54c4dee..72b97a9 100644 --- a/src/api/grammarApi.js +++ b/src/api/grammarApi.js @@ -1,40 +1,15 @@ -import axios from 'axios' - -const grammarApi = axios.create({ - baseURL: import.meta.env.VITE_GRAMMAR_API_URL || import.meta.env.VITE_API_URL, - timeout: 30000, - headers: { - 'Content-Type': 'application/json', - }, -}) - -// Request interceptor -grammarApi.interceptors.request.use( - (config) => { - const token = localStorage.getItem('accessToken') - if (token) { - config.headers.Authorization = `Bearer ${token}` - } - return config - }, - (error) => { - return Promise.reject(error) - } -) - -// Response interceptor -grammarApi.interceptors.response.use( - (response) => response.data, - (error) => { - console.error('Grammar API Error:', error.response?.data || error.message) - - if (error.response?.status === 401) { - localStorage.removeItem('accessToken') - window.location.href = '/login' - } - - return Promise.reject(error) - } -) +import api from './axios' + +// 공통 axios 인스턴스 사용 (토큰 갱신 로직 포함) +// Grammar API는 AI 처리로 인해 timeout 30초 필요 +const GRAMMAR_TIMEOUT = 30000 + +const grammarApi = { + get: (url, config) => api.get(url, { timeout: GRAMMAR_TIMEOUT, ...config }).then(res => res.data), + post: (url, data, config) => api.post(url, data, { timeout: GRAMMAR_TIMEOUT, ...config }).then(res => res.data), + put: (url, data, config) => api.put(url, data, { timeout: GRAMMAR_TIMEOUT, ...config }).then(res => res.data), + patch: (url, data, config) => api.patch(url, data, { timeout: GRAMMAR_TIMEOUT, ...config }).then(res => res.data), + delete: (url, config) => api.delete(url, { timeout: GRAMMAR_TIMEOUT, ...config }).then(res => res.data), +} export default grammarApi diff --git a/src/api/speakingApi.js b/src/api/speakingApi.js index 64a5b17..ef1c0e4 100644 --- a/src/api/speakingApi.js +++ b/src/api/speakingApi.js @@ -1,41 +1,15 @@ -import axios from 'axios' - -// Bedrock/Polly 사용으로 응답 시간(timeout) 제한 늘림 -const speakingApi = axios.create({ - baseURL: import.meta.env.VITE_API_URL, - timeout: 30000, // 30초 - headers: { - 'Content-Type': 'application/json', - }, -}) - -// Request interceptor - JWT 토큰 자동 추가 -speakingApi.interceptors.request.use( - (config) => { - const token = localStorage.getItem('accessToken') - if (token) { - config.headers.Authorization = `Bearer ${token}` - } - return config - }, - (error) => { - return Promise.reject(error) - } -) - -// Response interceptor - 401 에러 처리 및 데이터 추출 -speakingApi.interceptors.response.use( - (response) => response.data, // response.data를 바로 반환하도록 설정 - (error) => { - console.error('Speaking API Error:', error.response?.data || error.message) - - if (error.response?.status === 401) { - localStorage.removeItem('accessToken') - window.location.href = '/login' - } - - return Promise.reject(error) - } -) +import api from './axios' + +// 공통 axios 인스턴스 사용 (토큰 갱신 로직 포함) +// Bedrock/Polly 사용으로 timeout 30초 필요 +const SPEAKING_TIMEOUT = 30000 + +const speakingApi = { + get: (url, config) => api.get(url, { timeout: SPEAKING_TIMEOUT, ...config }).then(res => res.data), + post: (url, data, config) => api.post(url, data, { timeout: SPEAKING_TIMEOUT, ...config }).then(res => res.data), + put: (url, data, config) => api.put(url, data, { timeout: SPEAKING_TIMEOUT, ...config }).then(res => res.data), + patch: (url, data, config) => api.patch(url, data, { timeout: SPEAKING_TIMEOUT, ...config }).then(res => res.data), + delete: (url, config) => api.delete(url, { timeout: SPEAKING_TIMEOUT, ...config }).then(res => res.data), +} export default speakingApi \ No newline at end of file diff --git a/src/api/vocabApi.js b/src/api/vocabApi.js index 0c78af4..1e25f4d 100644 --- a/src/api/vocabApi.js +++ b/src/api/vocabApi.js @@ -1,40 +1,13 @@ -import axios from 'axios' - -const vocabApi = axios.create({ - baseURL: import.meta.env.VITE_VOCAB_API_URL || import.meta.env.VITE_API_URL, - timeout: 10000, - headers: { - 'Content-Type': 'application/json', - }, -}) - -// Request interceptor - JWT 토큰 자동 추가 -vocabApi.interceptors.request.use( - (config) => { - const token = localStorage.getItem('accessToken') - if (token) { - config.headers.Authorization = `Bearer ${token}` - } - return config - }, - (error) => { - return Promise.reject(error) - } -) - -// Response interceptor - 401 에러 시 로그인 페이지로 이동 -vocabApi.interceptors.response.use( - (response) => response.data, - (error) => { - console.error('Vocab API Error:', error.response?.data || error.message) - - if (error.response?.status === 401) { - localStorage.removeItem('accessToken') - window.location.href = '/login' - } - - return Promise.reject(error) - } -) +import api from './axios' + +// 공통 axios 인스턴스 사용 (토큰 갱신 로직 포함) +// response.data 자동 추출을 위한 래퍼 +const vocabApi = { + get: (url, config) => api.get(url, config).then(res => res.data), + post: (url, data, config) => api.post(url, data, config).then(res => res.data), + put: (url, data, config) => api.put(url, data, config).then(res => res.data), + patch: (url, data, config) => api.patch(url, data, config).then(res => res.data), + delete: (url, config) => api.delete(url, config).then(res => res.data), +} export default vocabApi diff --git a/src/aws-config.js b/src/aws-config.js index d4d0a9d..6d2b672 100644 --- a/src/aws-config.js +++ b/src/aws-config.js @@ -1,8 +1,8 @@ const awsConfig = { Auth: { Cognito: { - userPoolId: 'ap-northeast-2_ezDwzFCzR', - userPoolClientId: '4ns077jcr1pkue2vvisr6qdpu5', + userPoolId: import.meta.env.VITE_COGNITO_POOL_ID, + userPoolClientId: import.meta.env.VITE_COGNITO_CLIENT_ID, loginWith: { email: true, diff --git a/src/domains/freetalk/components/ChatRoomModal.jsx b/src/domains/freetalk/components/ChatRoomModal.jsx index c62ca79..1856abc 100644 --- a/src/domains/freetalk/components/ChatRoomModal.jsx +++ b/src/domains/freetalk/components/ChatRoomModal.jsx @@ -35,6 +35,8 @@ import {useAuth} from '../../../contexts/AuthContext' import {useThemeMode} from '../../../contexts/ThemeContext' import {DESIGN_TOKENS, getChatStyles} from '../../../theme/theme' import {useChatWebSocket} from '../hooks/useChatWebSocket' +import SystemCommandMessage from './SystemCommandMessage' +import {MessageType} from '../types/chatCommandTypes' const ChatRoomModal = ({open, onClose, room, onLeave}) => { const theme = useTheme() @@ -456,7 +458,15 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { ) : ( - messages.map((message) => ( + messages.map((message) => { + // 시스템 명령어 메시지 (SYSTEM_COMMAND) + if (message.messageType === MessageType.SYSTEM_COMMAND || message.messageType === 'SYSTEM_COMMAND') { + return ( + + ) + } + + return ( { )} - )) + )}) )}
diff --git a/src/domains/freetalk/components/CommandAutocomplete.jsx b/src/domains/freetalk/components/CommandAutocomplete.jsx new file mode 100644 index 0000000..09b83e3 --- /dev/null +++ b/src/domains/freetalk/components/CommandAutocomplete.jsx @@ -0,0 +1,150 @@ +import { useEffect, useState } from 'react' +import { Box, Paper, Typography, List, ListItem, ListItemButton, ListItemText } from '@mui/material' +import { useThemeMode } from '../../../contexts/ThemeContext' +import { searchCommands } from '../types/chatCommandTypes' + +/** + * 채팅 명령어 자동완성 컴포넌트 + * 사용자가 "/"를 입력하면 사용 가능한 명령어 목록을 표시합니다. + * + * @param {Object} props + * @param {string} props.input - 현재 입력 값 + * @param {function} props.onSelect - 명령어 선택 시 호출되는 콜백 + * @param {boolean} props.show - 표시 여부 + * @returns {JSX.Element|null} + */ +const CommandAutocomplete = ({ input, onSelect, show }) => { + const { mode } = useThemeMode() + const isDark = mode === 'dark' + const [selectedIndex, setSelectedIndex] = useState(0) + const [filteredCommands, setFilteredCommands] = useState([]) + + // 입력값에 따라 명령어 필터링 + useEffect(() => { + if (!show || !input.startsWith('/')) { + setFilteredCommands([]) + setSelectedIndex(0) + return + } + + const commands = searchCommands(input) + setFilteredCommands(commands) + setSelectedIndex(0) + }, [input, show]) + + // 키보드 이벤트 처리 + useEffect(() => { + const handleKeyDown = (e) => { + if (!show || filteredCommands.length === 0) return + + if (e.key === 'ArrowDown') { + e.preventDefault() + setSelectedIndex((prev) => (prev + 1) % filteredCommands.length) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setSelectedIndex((prev) => (prev - 1 + filteredCommands.length) % filteredCommands.length) + } else if (e.key === 'Enter' && filteredCommands[selectedIndex]) { + e.preventDefault() + onSelect(filteredCommands[selectedIndex].command) + } else if (e.key === 'Escape') { + onSelect('') + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [show, filteredCommands, selectedIndex, onSelect]) + + // 표시할 항목이 없으면 렌더링하지 않음 + if (!show || filteredCommands.length === 0) { + return null + } + + return ( + + + + 사용 가능한 명령어 ({filteredCommands.length}) + + + + + {filteredCommands.map((cmd, index) => ( + + onSelect(cmd.command)} + sx={{ + py: 1.5, + px: 2, + '&.Mui-selected': { + bgcolor: isDark ? 'rgba(250, 204, 21, 0.2)' : 'rgba(254, 229, 0, 0.3)', + '&:hover': { + bgcolor: isDark ? 'rgba(250, 204, 21, 0.25)' : 'rgba(254, 229, 0, 0.4)', + }, + }, + '&:hover': { + bgcolor: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)', + }, + }} + > + + + {cmd.command} + + + {cmd.usage} + + + } + secondary={ + + {cmd.description} + + } + /> + + + ))} + + + + + 화살표로 선택, Enter로 입력, Esc로 닫기 + + + + ) +} + +export default CommandAutocomplete diff --git a/src/domains/freetalk/components/PollCard.jsx b/src/domains/freetalk/components/PollCard.jsx new file mode 100644 index 0000000..65e5bd6 --- /dev/null +++ b/src/domains/freetalk/components/PollCard.jsx @@ -0,0 +1,175 @@ +import { useState } from 'react' +import { Box, Button, Card, CardContent, Chip, Typography, IconButton } from '@mui/material' +import { + HowToVote as VoteIcon, + CheckCircle as CheckIcon, + Cancel as CancelIcon, + Person as PersonIcon, +} from '@mui/icons-material' +import { useThemeMode } from '../../../contexts/ThemeContext' +import { calculatePollResults } from '../types/chatCommandTypes' +import PollResultBar from './PollResultBar' + +/** + * 투표 카드 컴포넌트 + * 투표 생성, 투표하기, 결과 보기 기능을 제공합니다. + * + * @param {Object} props + * @param {Object} props.poll - 투표 데이터 + * @param {string} props.currentUserId - 현재 사용자 ID + * @param {function} props.onVote - 투표 시 호출되는 콜백 + * @param {function} props.onEndPoll - 투표 종료 시 호출되는 콜백 + * @returns {JSX.Element} + */ +const PollCard = ({ poll, currentUserId, onVote, onEndPoll }) => { + const { mode } = useThemeMode() + const isDark = mode === 'dark' + const [selectedOption, setSelectedOption] = useState(null) + + const { totalVotes, percentages } = calculatePollResults(poll.options) + const hasVoted = poll.options.some((opt) => opt.voters.includes(currentUserId)) + const isCreator = poll.creatorId === currentUserId + const showResults = !poll.isActive || hasVoted + + const handleVote = (optionId) => { + if (!poll.isActive || hasVoted) return + setSelectedOption(optionId) + onVote?.(poll.pollId, optionId) + } + + const handleEndPoll = () => { + onEndPoll?.(poll.pollId) + } + + return ( + + + {/* 헤더 */} + + + + 투표 + + {poll.isActive ? ( + + ) : ( + + )} + + + {/* 질문 */} + + {poll.question} + + + {/* 옵션 목록 */} + + {poll.options.map((option, index) => { + const percentage = percentages[index] + const isSelected = selectedOption === option.optionId + const userVoted = option.voters.includes(currentUserId) + + return ( + + {showResults ? ( + // 결과 보기 모드 + + + + + {option.text} + + {userVoted && } + + + {option.voteCount}표 ({percentage}%) + + + + + ) : ( + // 투표하기 모드 + + )} + + ) + })} + + + {/* 하단 정보 */} + + + + + 총 {totalVotes}명 참여 + + + + {isCreator && poll.isActive && ( + + + + )} + + + {/* 생성자 정보 */} + + 생성자: {poll.creatorId} + + + + ) +} + +export default PollCard diff --git a/src/domains/freetalk/components/PollResultBar.jsx b/src/domains/freetalk/components/PollResultBar.jsx new file mode 100644 index 0000000..bd46201 --- /dev/null +++ b/src/domains/freetalk/components/PollResultBar.jsx @@ -0,0 +1,57 @@ +import { Box, LinearProgress } from '@mui/material' +import { useThemeMode } from '../../../contexts/ThemeContext' + +/** + * 투표 결과 진행바 컴포넌트 + * 투표 옵션의 득표율을 시각적으로 표시합니다. + * + * @param {Object} props + * @param {number} props.percentage - 득표율 (0-100) + * @param {boolean} props.isUserVoted - 사용자가 해당 옵션에 투표했는지 여부 + * @returns {JSX.Element} + */ +const PollResultBar = ({ percentage, isUserVoted = false }) => { + const { mode } = useThemeMode() + const isDark = mode === 'dark' + + return ( + + + {isUserVoted && ( + + )} + + ) +} + +export default PollResultBar diff --git a/src/domains/freetalk/components/SystemCommandMessage.jsx b/src/domains/freetalk/components/SystemCommandMessage.jsx new file mode 100644 index 0000000..253382f --- /dev/null +++ b/src/domains/freetalk/components/SystemCommandMessage.jsx @@ -0,0 +1,211 @@ +import { Box, Paper, Typography } from '@mui/material' +import { useThemeMode } from '../../../contexts/ThemeContext' +import { SystemCommandConfig } from '../types/chatCommandTypes' + +/** + * 시스템 명령어 메시지 컴포넌트 + * /dice, /coin, /random, /members, /help 등의 명령어 결과를 표시합니다. + * + * @param {Object} props + * @param {Object} props.data - 시스템 명령어 데이터 + * @param {string} props.data.commandType - 명령어 타입 + * @param {string} props.data.userId - 실행한 사용자 ID + * @param {Object} props.data.result - 명령어 결과 + * @param {string} props.data.displayText - 표시할 텍스트 + * @returns {JSX.Element} + */ +const SystemCommandMessage = ({ data }) => { + const { mode } = useThemeMode() + const isDark = mode === 'dark' + + // 명령어 타입 추출 + const commandType = data?.commandType || data?.type || data?.raw?.type || 'help' + const config = SystemCommandConfig[commandType] || SystemCommandConfig.help + const { icon, color, bgColor } = config + + // 표시 텍스트 추출 + const displayText = data?.displayText || data?.message || data?.content || '' + const userId = data?.userId || data?.nickname || '' + + // 결과 값 추출 + const result = data?.result || data?.raw || {} + + return ( + + + + {/* 아이콘 */} + + {icon} + + + {/* 내용 */} + + {/* displayText가 있으면 그대로 표시 (백엔드에서 이미 포맷팅됨) */} + {displayText ? ( + + {displayText} + + ) : ( + <> + + + {userId} + + + {/* 추가 결과 정보 */} + {renderCommandResult(commandType, result, isDark)} + + )} + + + + + ) +} + +/** + * 명령어 타입별 결과 렌더링 + */ +function renderCommandResult(commandType, result, isDark) { + if (!result) return null + + switch (commandType) { + case 'dice': + return ( + + {result.value} + + ) + + case 'coin': + return ( + + {result.side === 'heads' ? '앞면' : '뒷면'} + + ) + + case 'random': + return ( + + {result.value} + {result.min !== undefined && result.max !== undefined && ( + + ({result.min}-{result.max}) + + )} + + ) + + case 'members': + return result.memberIds ? ( + + + 참여 중인 멤버 ({result.totalCount}명): + + + {result.memberIds.map((memberId) => ( + + {memberId} + + ))} + + + ) : null + + case 'help': + return result.commands ? ( + + + 사용 가능한 명령어: + + + {result.commands.map((cmd) => ( + + {cmd.command} - {cmd.description} + + ))} + + + ) : null + + default: + return null + } +} + +export default SystemCommandMessage diff --git a/src/domains/freetalk/hooks/useChatWebSocket.js b/src/domains/freetalk/hooks/useChatWebSocket.js index 7afd702..adc8037 100644 --- a/src/domains/freetalk/hooks/useChatWebSocket.js +++ b/src/domains/freetalk/hooks/useChatWebSocket.js @@ -1,6 +1,6 @@ -import {useCallback, useEffect, useRef, useState} from 'react' -import {chatWebSocketService} from '../services/chatWebSocketService' -import {chatRoomService} from '../../chat/services/chatService' +import { useCallback, useEffect, useRef, useState } from 'react' +import { chatWebSocketService } from '../services/chatWebSocketService' +import { chatRoomService } from '../../chat/services/chatService' /** * Chat WebSocket 훅 @@ -26,10 +26,10 @@ export function useChatWebSocket(roomId, userId) { * WebSocket 연결 */ const connect = useCallback(async (forceNewToken = false) => { - console.log('[useChatWebSocket] Attempting to connect...', {roomId, userId, forceNewToken}) + console.log('[useChatWebSocket] Attempting to connect...', { roomId, userId, forceNewToken }) if (!roomId || !userId) { - console.error('[useChatWebSocket] roomId and userId are required', {roomId, userId}) + console.error('[useChatWebSocket] roomId and userId are required', { roomId, userId }) return } @@ -74,9 +74,12 @@ export function useChatWebSocket(roomId, userId) { id: messageId, content: data.content, userId: data.userId, + nickname: data.nickname, messageType: data.messageType || 'TEXT', createdAt: data.createdAt || new Date().toISOString(), isOwn: data.userId === userId, + // 추가 데이터 (투표, 시스템 명령어 등) + data: data.data || data.payload, } setMessages((prev) => { // 중복 메시지 방지 (같은 messageId가 이미 있으면 추가하지 않음) @@ -87,6 +90,101 @@ export function useChatWebSocket(roomId, userId) { }) }, + onPollCreate: (data) => { + console.log('[useChatWebSocket] Poll created:', data) + const pollData = data.data || data + const pollMessage = { + id: `poll-${pollData.pollId}`, + messageType: 'POLL_CREATE', + userId: pollData.creatorId, + createdAt: pollData.createdAt || new Date().toISOString(), + isOwn: pollData.creatorId === userId, + data: pollData, + } + setMessages((prev) => [...prev, pollMessage]) + }, + + onPollVote: (data) => { + console.log('[useChatWebSocket] Poll vote:', data) + const voteData = data.data || data + setMessages((prev) => + prev.map((msg) => { + if (msg.id === `poll-${voteData.pollId}` && msg.data) { + return { + ...msg, + data: { + ...msg.data, + options: voteData.updatedOptions || msg.data.options, + }, + } + } + return msg + }) + ) + }, + + onPollEnd: (data) => { + console.log('[useChatWebSocket] Poll ended:', data) + const endData = data.data || data + setMessages((prev) => + prev.map((msg) => { + if (msg.id === `poll-${endData.pollId}` && msg.data) { + return { + ...msg, + data: { + ...msg.data, + isActive: false, + options: endData.finalResults || msg.data.options, + }, + } + } + return msg + }) + ) + }, + + onClearChat: (data) => { + console.log('[useChatWebSocket] Clear chat:', data) + const clearData = data.data || data + // 특정 사용자의 메시지만 삭제 + setMessages((prev) => + prev.filter((msg) => !clearData.messageIds?.includes(msg.id)) + ) + }, + + onLeaveRoom: (data) => { + console.log('[useChatWebSocket] User left room:', data) + const leaveData = data.data || data + const systemMessage = { + id: `leave-${Date.now()}`, + content: `${leaveData.userId}님이 채팅방을 나갔습니다.`, + messageType: 'SYSTEM', + createdAt: leaveData.leftAt || new Date().toISOString(), + isSystem: true, + } + setMessages((prev) => [...prev, systemMessage]) + }, + + onSystemCommand: (data) => { + const commandData = data.data || {} + const commandMessage = { + id: `syscmd-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + messageType: 'SYSTEM_COMMAND', + userId: commandData.userId || commandData.nickname || data.userId, + createdAt: data.createdAt || new Date().toISOString(), + data: { + commandType: commandData.type || 'help', + userId: commandData.userId || commandData.nickname, + displayText: data.content || data.message || '', + result: typeof commandData.result === 'object' + ? commandData.result + : { value: commandData.result }, + raw: commandData, + }, + } + setMessages((prev) => [...prev, commandMessage]) + }, + onGameStart: (data) => { console.log('[useChatWebSocket] Game started - FULL DATA:', JSON.stringify(data, null, 2)) // 실제 게임 데이터 추출 (data.data에 중첩되어 있을 수 있음) @@ -117,6 +215,97 @@ export function useChatWebSocket(roomId, userId) { })) }, + // 끝말잇기 게임 시작 + onWordchainStart: (data) => { + console.log('[useChatWebSocket] Wordchain started - FULL DATA:', JSON.stringify(data, null, 2)) + const gameData = data.data || data + // 서버 필드명 매핑: starterWord->currentWord, currentPlayerId->currentTurnUserId, timeLimit->turnTimeLimit + const wordchainState = { + status: 'PLAYING', + gameType: 'WORDCHAIN', + currentTurnUserId: gameData.currentPlayerId || gameData.currentTurnUserId, + currentWord: gameData.starterWord || gameData.currentWord, + nextLetter: gameData.nextLetter, + turnTimeLimit: gameData.timeLimit || gameData.turnTimeLimit || 15, + turnStartTime: gameData.turnStartTime || Date.now(), + scores: gameData.scores || {}, + players: gameData.players || gameData.activePlayers || [], + } + setGameState(wordchainState) + // PlayPage에서 사용할 수 있도록 sessionStorage에 저장 + sessionStorage.setItem(`wordchainState_${roomId}`, JSON.stringify(wordchainState)) + }, + + // 끝말잇기 정답 + onWordchainCorrect: (data) => { + console.log('[useChatWebSocket] Wordchain correct:', data) + const correctData = data.data || data + // 서버 필드명 매핑: word->currentWord, nextPlayerId->currentTurnUserId, nextTimeLimit->turnTimeLimit + setGameState((prev) => { + const newState = { + ...prev, + currentWord: correctData.word || correctData.currentWord, + nextLetter: correctData.nextLetter || prev?.nextLetter, + currentTurnUserId: correctData.nextPlayerId || correctData.nextTurnUserId, + turnTimeLimit: correctData.nextTimeLimit || prev?.turnTimeLimit, + turnStartTime: correctData.turnStartTime || Date.now(), + scores: correctData.scores || prev?.scores, + usedWords: prev?.usedWords ? [...prev.usedWords, correctData.word] : [correctData.word], + } + // sessionStorage 업데이트 + sessionStorage.setItem(`wordchainState_${roomId}`, JSON.stringify(newState)) + return newState + }) + }, + + // 끝말잇기 오답 + onWordchainWrong: (data) => { + console.log('[useChatWebSocket] Wordchain wrong:', data) + }, + + // 끝말잇기 타임아웃 + onWordchainTimeout: (data) => { + console.log('[useChatWebSocket] Wordchain timeout:', data) + const timeoutData = data.data || data + // 서버 필드명 매핑: nextPlayerId->currentTurnUserId, nextTimeLimit->turnTimeLimit + setGameState((prev) => { + const newState = { + ...prev, + currentTurnUserId: timeoutData.nextPlayerId || timeoutData.nextTurnUserId, + nextLetter: timeoutData.nextLetter || prev?.nextLetter, + turnTimeLimit: timeoutData.nextTimeLimit || prev?.turnTimeLimit, + turnStartTime: timeoutData.turnStartTime || Date.now(), + players: timeoutData.activePlayers || prev?.players, + } + // sessionStorage 업데이트 + sessionStorage.setItem(`wordchainState_${roomId}`, JSON.stringify(newState)) + return newState + }) + }, + + // 끝말잇기 게임 종료 + onWordchainEnd: (data) => { + console.log('[useChatWebSocket] Wordchain ended:', data) + const endData = data.data || data + setGameState((prev) => { + const newState = { + ...prev, + status: 'FINISHED', + winner: endData.winnerId ? { + id: endData.winnerId, + nickname: endData.winnerNickname, + } : null, + ranking: endData.ranking, + finalScores: endData.scores || prev?.scores, + usedWords: endData.usedWords || prev?.usedWords, + wordDefinitions: endData.wordDefinitions || {}, + } + // sessionStorage 업데이트 + sessionStorage.setItem(`wordchainState_${roomId}`, JSON.stringify(newState)) + return newState + }) + }, + onRoundStart: (data) => { console.log('[useChatWebSocket] Round started - FULL DATA:', JSON.stringify(data, null, 2)) // 실제 라운드 데이터 추출 (data.data에 중첩되어 있을 수 있음) diff --git a/src/domains/freetalk/pages/ChatRoomPage.jsx b/src/domains/freetalk/pages/ChatRoomPage.jsx index cc80e6e..781a891 100644 --- a/src/domains/freetalk/pages/ChatRoomPage.jsx +++ b/src/domains/freetalk/pages/ChatRoomPage.jsx @@ -1,5 +1,5 @@ -import {useCallback, useEffect, useRef, useState} from 'react' -import {useNavigate, useParams} from 'react-router-dom' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' import { Alert, AppBar, @@ -21,27 +21,31 @@ import { Send as SendIcon, VolumeUp as VolumeUpIcon, } from '@mui/icons-material' -import {chatRoomService, messageService, voiceService} from '../../chat/services/chatService' -import {useAuth} from '../../../contexts/AuthContext' -import {useChatWebSocket} from '../hooks/useChatWebSocket' -import {useThemeMode} from '../../../contexts/ThemeContext' +import { chatRoomService, messageService, voiceService } from '../../chat/services/chatService' +import { useAuth } from '../../../contexts/AuthContext' +import { useChatWebSocket } from '../hooks/useChatWebSocket' +import { useThemeMode } from '../../../contexts/ThemeContext' +import CommandAutocomplete from '../components/CommandAutocomplete' +import PollCard from '../components/PollCard' +import SystemCommandMessage from '../components/SystemCommandMessage' +import { parseCommand, MessageType } from '../types/chatCommandTypes' const levelColors = { - beginner: {bg: '#e8f5e9', color: '#2e7d32', label: '초급'}, - intermediate: {bg: '#fff3e0', color: '#ef6c00', label: '중급'}, - advanced: {bg: '#fce4ec', color: '#c2185b', label: '고급'}, + beginner: { bg: '#e8f5e9', color: '#2e7d32', label: '초급' }, + intermediate: { bg: '#fff3e0', color: '#ef6c00', label: '중급' }, + advanced: { bg: '#fce4ec', color: '#c2185b', label: '고급' }, } const ChatRoomPage = () => { - const {roomId} = useParams() + const { roomId } = useParams() const navigate = useNavigate() - const {user} = useAuth() - const {mode} = useThemeMode() + const { user } = useAuth() + const { mode } = useThemeMode() const isDark = mode === 'dark' const currentUserId = user?.userId || user?.username || user?.sub // 디버깅: 사용자 정보 확인 - console.log('[ChatRoomPage] User info:', {user, currentUserId, roomId}) + console.log('[ChatRoomPage] User info:', { user, currentUserId, roomId }) const messagesEndRef = useRef(null) const messagesContainerRef = useRef(null) @@ -51,6 +55,7 @@ const ChatRoomPage = () => { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [playingTTS, setPlayingTTS] = useState(null) + const [showCommandAutocomplete, setShowCommandAutocomplete] = useState(false) // WebSocket 훅 사용 (채팅방에서는 게임 관련 기능 제외) const { @@ -85,11 +90,12 @@ const ChatRoomPage = () => { // 기존 메시지 목록 조회 (초기 로드용) const fetchMessages = useCallback(async () => { try { - const response = await messageService.getList(roomId, {limit: 50}) + const response = await messageService.getList(roomId, { limit: 50 }) const transformedMessages = (response.messages || []).map((msg, index) => ({ id: msg.messageId || `msg-${index}-${Date.now()}`, content: msg.content, userId: msg.userId, + nickname: msg.nickname, messageType: msg.messageType, createdAt: new Date(msg.createdAt), isOwn: msg.userId === currentUserId, @@ -117,12 +123,12 @@ const ChatRoomPage = () => { // WebSocket 연결 (별도 effect) useEffect(() => { - console.log('[ChatRoomPage] WebSocket effect triggered:', {roomId, currentUserId, isConnected}) + console.log('[ChatRoomPage] WebSocket effect triggered:', { roomId, currentUserId, isConnected }) if (currentUserId && roomId) { - console.log('[ChatRoomPage] Connecting WebSocket...', {roomId, currentUserId}) + console.log('[ChatRoomPage] Connecting WebSocket...', { roomId, currentUserId }) wsConnect() } else { - console.log('[ChatRoomPage] Missing required values:', {roomId, currentUserId}) + console.log('[ChatRoomPage] Missing required values:', { roomId, currentUserId }) } return () => { @@ -133,7 +139,7 @@ const ChatRoomPage = () => { // 스크롤 맨 아래로 const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({behavior: 'smooth'}) + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) } useEffect(() => { @@ -153,6 +159,19 @@ const ChatRoomPage = () => { const messageContent = newMessage.trim() setNewMessage('') + setShowCommandAutocomplete(false) + + // 명령어 파싱 + const { isCommand, command, args } = parseCommand(messageContent) + + // /leave 명령어는 클라이언트에서 직접 처리 + if (isCommand && command === '/leave') { + handleLeaveRoom() + return + } + + // /clear 명령어는 서버로 전송 (서버에서 처리) + // 나머지 명령어들도 서버로 전송하여 처리 // WebSocket으로 전송 if (isConnected) { @@ -219,6 +238,41 @@ const ChatRoomPage = () => { } } + // 투표하기 + const handleVote = (pollId, optionId) => { + if (isConnected) { + wsSendMessage(`/vote ${pollId} ${optionId}`, 'TEXT') + } + } + + // 투표 종료 + const handleEndPoll = (pollId) => { + if (isConnected) { + wsSendMessage(`/endpoll ${pollId}`, 'TEXT') + } + } + + // 명령어 자동완성 선택 + const handleCommandSelect = (command) => { + if (command) { + setNewMessage(command + ' ') + } + setShowCommandAutocomplete(false) + } + + // 입력값 변경 처리 + const handleInputChange = (e) => { + const value = e.target.value + setNewMessage(value) + + // "/" 입력 시 자동완성 표시 + if (value.startsWith('/') && value.length > 0) { + setShowCommandAutocomplete(true) + } else { + setShowCommandAutocomplete(false) + } + } + // 새로고침 const handleRefresh = () => { fetchMessages() @@ -240,23 +294,23 @@ const ChatRoomPage = () => { if (loading) { return ( - - + + ) } return ( - + {/* 헤더 */} - navigate('/freetalk/people')} sx={{mr: 1}}> - + navigate('/freetalk/people')} sx={{ mr: 1 }}> + - - - + + + {room?.name || '채팅방'} {/* 연결 상태 표시 */} @@ -267,7 +321,7 @@ const ChatRoomPage = () => { }} /> - + {room?.level && ( { - + - + {/* 에러 메시지 */} {error && ( - + {error} )} @@ -317,108 +371,132 @@ const ChatRoomPage = () => { }} > {messages.length === 0 ? ( - + 아직 메시지가 없습니다. 첫 메시지를 보내보세요! ) : ( - messages.map((message) => ( - - {/* 시스템 메시지 */} - {message.isSystem ? ( - - - {message.content} - + messages.map((message) => { + // 투표 메시지 + if (message.messageType === MessageType.POLL_CREATE) { + return ( + + - ) : ( - <> - {/* 아바타 (상대방만) */} - {!message.isOwn && ( - - {message.userId?.charAt(0)?.toUpperCase() || 'U'} - - )} - - - {/* 사용자 이름 (상대방만) */} + ) + } + + // 시스템 명령어 메시지 + if (message.messageType === MessageType.SYSTEM_COMMAND) { + return ( + + ) + } + + // 일반 메시지 + return ( + + {/* 시스템 메시지 */} + {message.isSystem ? ( + + + {message.content} + + + ) : ( + <> + {/* 아바타 (상대방만) */} {!message.isOwn && ( - - {message.userId} - + + {(message.nickname || message.userId)?.charAt(0)?.toUpperCase() || 'U'} + )} - {/* 메시지 버블 */} - - {message.isOwn && ( - - {formatTime(message.createdAt)} + + {/* 사용자 이름 (상대방만) */} + {!message.isOwn && ( + + {message.nickname || message.userId} )} - - - {message.content} - - - - {!message.isOwn && ( - - handlePlayTTS(message.id)} - disabled={playingTTS === message.id} - sx={{p: 0.5}} - > - {playingTTS === message.id ? ( - - ) : ( - - )} - + {/* 메시지 버블 */} + + {message.isOwn && ( {formatTime(message.createdAt)} - - )} + )} + + + + {message.content} + + + + {!message.isOwn && ( + + handlePlayTTS(message.id)} + disabled={playingTTS === message.id} + sx={{ p: 0.5 }} + > + {playingTTS === message.id ? ( + + ) : ( + + )} + + + {formatTime(message.createdAt)} + + + )} + - - - )} - - )) + + )} + + ) + }) )} -
+
{/* 입력 영역 */} @@ -430,13 +508,21 @@ const ChatRoomPage = () => { alignItems: 'center', gap: 1, borderRadius: 0, + position: 'relative', }} > + {/* 명령어 자동완성 */} + + setNewMessage(e.target.value)} + onChange={handleInputChange} onKeyPress={handleKeyPress} size="small" multiline @@ -454,11 +540,11 @@ const ChatRoomPage = () => { sx={{ bgcolor: 'primary.main', color: 'white', - '&:hover': {bgcolor: 'primary.dark'}, - '&:disabled': {bgcolor: 'grey.300'}, + '&:hover': { bgcolor: 'primary.dark' }, + '&:disabled': { bgcolor: 'grey.300' }, }} > - + diff --git a/src/domains/freetalk/services/chatWebSocketService.js b/src/domains/freetalk/services/chatWebSocketService.js index f4d042c..f8fbe75 100644 --- a/src/domains/freetalk/services/chatWebSocketService.js +++ b/src/domains/freetalk/services/chatWebSocketService.js @@ -177,8 +177,33 @@ class ChatWebSocketConnection { break case 'system_command': case 'SYSTEM_COMMAND': - // 시스템 명령어 응답 (예: /member, /help 등) - this.callbacks.onMessage?.(data) + // 시스템 명령어 응답 (예: /dice, /coin, /random, /members, /help 등) + this.callbacks.onSystemCommand?.(data) + break + case 'poll_create': + case 'POLL_CREATE': + console.log('[ChatWebSocket] Poll create received:', data) + this.callbacks.onPollCreate?.(data) + break + case 'poll_vote': + case 'POLL_VOTE': + console.log('[ChatWebSocket] Poll vote received:', data) + this.callbacks.onPollVote?.(data) + break + case 'poll_end': + case 'POLL_END': + console.log('[ChatWebSocket] Poll end received:', data) + this.callbacks.onPollEnd?.(data) + break + case 'clear_chat': + case 'CLEAR_CHAT': + console.log('[ChatWebSocket] Clear chat received:', data) + this.callbacks.onClearChat?.(data) + break + case 'leave_room': + case 'LEAVE_ROOM': + console.log('[ChatWebSocket] Leave room received:', data) + this.callbacks.onLeaveRoom?.(data) break case 'error': case 'ERROR': @@ -188,6 +213,31 @@ class ChatWebSocketConnection { // 추측 메시지 - 일반 메시지로 처리 this.callbacks.onMessage?.(data) break + case 'wordchain_start': + case 'WORDCHAIN_START': + console.log('[ChatWebSocket] Wordchain start received:', data) + this.callbacks.onWordchainStart?.(data) + break + case 'wordchain_correct': + case 'WORDCHAIN_CORRECT': + console.log('[ChatWebSocket] Wordchain correct received:', data) + this.callbacks.onWordchainCorrect?.(data) + break + case 'wordchain_wrong': + case 'WORDCHAIN_WRONG': + console.log('[ChatWebSocket] Wordchain wrong received:', data) + this.callbacks.onWordchainWrong?.(data) + break + case 'wordchain_timeout': + case 'WORDCHAIN_TIMEOUT': + console.log('[ChatWebSocket] Wordchain timeout received:', data) + this.callbacks.onWordchainTimeout?.(data) + break + case 'wordchain_end': + case 'WORDCHAIN_END': + console.log('[ChatWebSocket] Wordchain end received:', data) + this.callbacks.onWordchainEnd?.(data) + break default: console.log('[ChatWebSocket] Unknown message type:', type || messageType, data) this.callbacks.onMessage?.(data) diff --git a/src/domains/freetalk/types/chatCommandTypes.js b/src/domains/freetalk/types/chatCommandTypes.js new file mode 100644 index 0000000..73d87c6 --- /dev/null +++ b/src/domains/freetalk/types/chatCommandTypes.js @@ -0,0 +1,219 @@ +/** + * 채팅 메시지 타입 상수 + * @enum {string} + */ +export const MessageType = { + TEXT: 'TEXT', + IMAGE: 'IMAGE', + VOICE: 'VOICE', + SYSTEM_COMMAND: 'SYSTEM_COMMAND', + POLL_CREATE: 'POLL_CREATE', + POLL_VOTE: 'POLL_VOTE', + POLL_END: 'POLL_END', + CLEAR_CHAT: 'CLEAR_CHAT', + LEAVE_ROOM: 'LEAVE_ROOM', +} + +/** + * 사용 가능한 채팅 명령어 목록 + */ +export const COMMANDS = [ + { + command: '/help', + description: '사용 가능한 명령어 목록 보기', + usage: '/help', + }, + { + command: '/members', + description: '현재 참여 중인 멤버 목록 보기', + usage: '/members', + }, + { + command: '/poll', + description: '투표 생성하기', + usage: '/poll [질문] [옵션1] [옵션2] ...', + }, + { + command: '/vote', + description: '투표하기', + usage: '/vote [투표ID] [옵션번호]', + }, + { + command: '/endpoll', + description: '투표 종료하기', + usage: '/endpoll [투표ID]', + }, + { + command: '/clear', + description: '내 메시지 모두 삭제', + usage: '/clear', + }, + { + command: '/leave', + description: '채팅방 나가기', + usage: '/leave', + }, + { + command: '/dice', + description: '주사위 굴리기 (1-6)', + usage: '/dice', + }, + { + command: '/coin', + description: '동전 던지기 (앞면/뒷면)', + usage: '/coin', + }, + { + command: '/random', + description: '무작위 숫자 생성', + usage: '/random [최소값] [최대값]', + }, +] + +/** + * @typedef {Object} PollOption + * @property {number} optionId - 옵션 ID + * @property {string} text - 옵션 텍스트 + * @property {number} voteCount - 투표 수 + * @property {string[]} voters - 투표한 사용자 ID 목록 + */ + +/** + * @typedef {Object} PollCreateData + * @property {string} pollId - 투표 ID + * @property {string} question - 투표 질문 + * @property {PollOption[]} options - 투표 옵션 목록 + * @property {string} creatorId - 생성자 ID + * @property {string} createdAt - ISO-8601 생성 시간 + * @property {boolean} isActive - 활성 상태 + */ + +/** + * @typedef {Object} PollVoteData + * @property {string} pollId - 투표 ID + * @property {number} optionId - 선택한 옵션 ID + * @property {string} userId - 투표한 사용자 ID + * @property {PollOption[]} updatedOptions - 업데이트된 옵션 목록 + */ + +/** + * @typedef {Object} PollEndData + * @property {string} pollId - 투표 ID + * @property {PollOption[]} finalResults - 최종 결과 + * @property {string} endedBy - 종료한 사용자 ID + * @property {string} endedAt - ISO-8601 종료 시간 + */ + +/** + * @typedef {Object} SystemCommandData + * @property {string} commandType - 명령어 타입 (dice, coin, random, members, help) + * @property {string} userId - 명령어 실행 사용자 ID + * @property {Object} result - 명령어 실행 결과 + * @property {string} displayText - 표시할 텍스트 + */ + +/** + * @typedef {Object} ClearChatData + * @property {string} userId - 삭제 요청 사용자 ID + * @property {string[]} messageIds - 삭제된 메시지 ID 목록 + */ + +/** + * @typedef {Object} LeaveRoomData + * @property {string} userId - 퇴장한 사용자 ID + * @property {string} roomId - 방 ID + * @property {string} leftAt - ISO-8601 퇴장 시간 + */ + +/** + * @typedef {Object} MembersListData + * @property {string[]} memberIds - 멤버 ID 목록 + * @property {number} totalCount - 총 멤버 수 + */ + +/** + * 명령어 파싱 유틸리티 + * @param {string} message - 입력된 메시지 + * @returns {{isCommand: boolean, command: string, args: string[]}} + */ +export function parseCommand(message) { + const trimmed = message.trim() + + if (!trimmed.startsWith('/')) { + return { isCommand: false, command: '', args: [] } + } + + const parts = trimmed.split(/\s+/) + const command = parts[0].toLowerCase() + const args = parts.slice(1) + + return { + isCommand: true, + command, + args, + } +} + +/** + * 명령어가 유효한지 확인 + * @param {string} command - 명령어 (예: '/help') + * @returns {boolean} + */ +export function isValidCommand(command) { + return COMMANDS.some(cmd => cmd.command === command.toLowerCase()) +} + +/** + * 명령어 검색 (자동완성용) + * @param {string} input - 사용자 입력 + * @returns {Array} 일치하는 명령어 목록 + */ +export function searchCommands(input) { + const lowerInput = input.toLowerCase() + return COMMANDS.filter(cmd => cmd.command.startsWith(lowerInput)) +} + +/** + * 투표 결과 계산 + * @param {PollOption[]} options - 투표 옵션 목록 + * @returns {{totalVotes: number, percentages: number[]}} + */ +export function calculatePollResults(options) { + const totalVotes = options.reduce((sum, opt) => sum + opt.voteCount, 0) + const percentages = options.map(opt => + totalVotes > 0 ? Math.round((opt.voteCount / totalVotes) * 100) : 0 + ) + + return { totalVotes, percentages } +} + +/** + * 시스템 명령어 아이콘 및 색상 설정 + */ +export const SystemCommandConfig = { + dice: { + icon: '🎲', + color: '#8b5cf6', + bgColor: '#f5f3ff', + }, + coin: { + icon: '🪙', + color: '#f59e0b', + bgColor: '#fffbeb', + }, + random: { + icon: '🔢', + color: '#06b6d4', + bgColor: '#ecfeff', + }, + members: { + icon: '👥', + color: '#10b981', + bgColor: '#ecfdf5', + }, + help: { + icon: '❓', + color: '#6366f1', + bgColor: '#eef2ff', + }, +} diff --git a/src/domains/games/components/CreateGameRoomModal.jsx b/src/domains/games/components/CreateGameRoomModal.jsx index 4545f24..fc03238 100644 --- a/src/domains/games/components/CreateGameRoomModal.jsx +++ b/src/domains/games/components/CreateGameRoomModal.jsx @@ -22,14 +22,17 @@ const levelOptions = [ { value: 'ADVANCED', label: '고급', color: '#EF4444' }, ] -const CreateGameRoomModal = ({ open, onClose, onCreate, loading }) => { +const CreateGameRoomModal = ({ open, onClose, onCreate, loading, gameType = 'CATCHMIND' }) => { + const isWordchain = gameType === 'WORDCHAIN' + const [formData, setFormData] = useState({ name: '', description: '', level: 'BEGINNER', - maxParticipants: 4, + maxParticipants: isWordchain ? 6 : 4, maxRounds: 5, roundTimeLimit: 60, + turnTimeLimit: 15, // 끝말잇기용 }) const handleChange = (field, value) => { @@ -46,9 +49,10 @@ const CreateGameRoomModal = ({ open, onClose, onCreate, loading }) => { name: '', description: '', level: 'BEGINNER', - maxParticipants: 4, + maxParticipants: isWordchain ? 6 : 4, maxRounds: 5, roundTimeLimit: 60, + turnTimeLimit: 15, }) onClose?.() } @@ -190,59 +194,96 @@ const CreateGameRoomModal = ({ open, onClose, onCreate, loading }) => { /> - {/* 라운드 수 */} - - - - 라운드 수 - - - {formData.maxRounds}라운드 - + {/* 캐치마인드: 라운드 수 */} + {!isWordchain && ( + + + + 라운드 수 + + + {formData.maxRounds}라운드 + + + handleChange('maxRounds', value)} + min={3} + max={10} + step={1} + marks + sx={{ + color: GAME_COLORS.primary, + }} + /> - handleChange('maxRounds', value)} - min={3} - max={10} - step={1} - marks - sx={{ - color: GAME_COLORS.primary, - }} - /> - + )} - {/* 라운드 시간 */} - - - - 라운드 시간 - - - {formData.roundTimeLimit}초 - + {/* 캐치마인드: 라운드 시간 */} + {!isWordchain && ( + + + + 라운드 시간 + + + {formData.roundTimeLimit}초 + + + handleChange('roundTimeLimit', value)} + min={30} + max={120} + step={15} + marks={[ + { value: 30, label: '30초' }, + { value: 60, label: '60초' }, + { value: 90, label: '90초' }, + { value: 120, label: '120초' }, + ]} + sx={{ + color: GAME_COLORS.primary, + '& .MuiSlider-markLabel': { + fontSize: '0.65rem', + }, + }} + /> - handleChange('roundTimeLimit', value)} - min={30} - max={120} - step={15} - marks={[ - { value: 30, label: '30초' }, - { value: 60, label: '60초' }, - { value: 90, label: '90초' }, - { value: 120, label: '120초' }, - ]} - sx={{ - color: GAME_COLORS.primary, - '& .MuiSlider-markLabel': { - fontSize: '0.65rem', - }, - }} - /> - + )} + + {/* 끝말잇기: 턴 시간 */} + {isWordchain && ( + + + + 턴 시간 + + + {formData.turnTimeLimit}초 + + + handleChange('turnTimeLimit', value)} + min={10} + max={30} + step={5} + marks={[ + { value: 10, label: '10초' }, + { value: 15, label: '15초' }, + { value: 20, label: '20초' }, + { value: 30, label: '30초' }, + ]} + sx={{ + color: GAME_COLORS.primary, + '& .MuiSlider-markLabel': { + fontSize: '0.65rem', + }, + }} + /> + + )} diff --git a/src/domains/games/components/wordchain/GameEndModal.jsx b/src/domains/games/components/wordchain/GameEndModal.jsx new file mode 100644 index 0000000..5b47a89 --- /dev/null +++ b/src/domains/games/components/wordchain/GameEndModal.jsx @@ -0,0 +1,179 @@ +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Box, + Typography, +} from '@mui/material' +import { + EmojiEvents as TrophyIcon, + ExitToApp as ExitIcon, + Replay as ReplayIcon, +} from '@mui/icons-material' +import { GAME_COLORS, GAME_TYPOGRAPHY } from '../../theme/gameTheme' + +/** + * GameEndModal - 게임 종료 모달 + * @param {Object} winner - { id, nickname } or { userId, nickname } + * @param {Array} finalPlayers - 백엔드 ranking 배열 또는 기존 players 배열 + * - 백엔드: [{ playerId, nickname, score, eliminated }] + * - 기존: [{ userId, nickname, isAlive, wordsSubmitted }] + */ +const GameEndModal = ({ open, winner, finalPlayers, ranking, onRestart, onExit, currentUserId }) => { + // 백엔드 ranking 또는 기존 finalPlayers 사용 + const players = ranking || finalPlayers || [] + + // 데이터 정규화 - 백엔드 형식과 기존 형식 모두 지원 + const sortedPlayers = [...players] + .map(player => ({ + userId: player.playerId || player.userId, + nickname: player.nickname || player.userId || player.playerId, + score: player.score || 0, + isAlive: player.eliminated !== undefined ? !player.eliminated : player.isAlive, + wordsSubmitted: player.wordsSubmitted || player.score || 0, + })) + .sort((a, b) => { + // 생존자가 우선 + if (a.isAlive && !b.isAlive) return -1 + if (!a.isAlive && b.isAlive) return 1 + // 점수로 정렬 + return (b.score || 0) - (a.score || 0) + }) + + // winner 정규화 + const normalizedWinner = winner ? { + userId: winner.id || winner.userId || winner.playerId, + nickname: winner.nickname || winner.id || winner.userId, + } : null + + return ( + + + + + 게임 종료! + + + + + {normalizedWinner && ( + + + 우승자 + + + {normalizedWinner.nickname || normalizedWinner.userId} + {normalizedWinner.userId === currentUserId && ' (나)'} + + + )} + + + 최종 순위 + + + + {sortedPlayers.map((player, index) => ( + + + + {player.isAlive ? ( + index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : `${index + 1}위` + ) : '❌'} + + + {player.nickname || player.userId} + {player.userId === currentUserId && ' (나)'} + + + + {player.score || 0}점 + + + ))} + + + + + + + + + ) +} + +export default GameEndModal diff --git a/src/domains/games/components/wordchain/PlayerList.jsx b/src/domains/games/components/wordchain/PlayerList.jsx new file mode 100644 index 0000000..2e31812 --- /dev/null +++ b/src/domains/games/components/wordchain/PlayerList.jsx @@ -0,0 +1,89 @@ +import { Box, Typography, Avatar } from '@mui/material' +import { CheckCircle as CheckIcon } from '@mui/icons-material' +import { GAME_COLORS } from '../../theme/gameTheme' +import { useThemeMode } from '../../../../contexts/ThemeContext' + +/** + * PlayerList - 플레이어 목록 (턴 & 생존 상태) + */ +const PlayerList = ({ players, currentTurnUserId, currentUserId }) => { + const { mode } = useThemeMode() + const isDark = mode === 'dark' + + return ( + + + 플레이어 + + + {players.map((player) => { + const isCurrentTurn = player.userId === currentTurnUserId + const isMe = player.userId === currentUserId + const isAlive = player.isAlive !== false + + return ( + + + {player.nickname?.[0] || player.userId?.[0] || '?'} + + + + {player.nickname || player.userId} + {isMe && ' (나)'} + + {isCurrentTurn && ( + + 턴 진행 중 + + )} + + {!isAlive && ( + + 탈락 + + )} + {isAlive && !isCurrentTurn && player.hasAnswered && ( + + )} + + ) + })} + + + ) +} + +export default PlayerList diff --git a/src/domains/games/components/wordchain/UsedWordsList.jsx b/src/domains/games/components/wordchain/UsedWordsList.jsx new file mode 100644 index 0000000..c366ec4 --- /dev/null +++ b/src/domains/games/components/wordchain/UsedWordsList.jsx @@ -0,0 +1,49 @@ +import { Box, Typography, Chip } from '@mui/material' +import { GAME_COLORS } from '../../theme/gameTheme' + +/** + * UsedWordsList - 사용된 단어 목록 + */ +const UsedWordsList = ({ words }) => { + return ( + + + 사용된 단어 ({words.length}) + + + {words.length === 0 ? ( + + 아직 사용된 단어가 없습니다 + + ) : ( + words.map((wordData, index) => ( + + )) + )} + + + ) +} + +export default UsedWordsList diff --git a/src/domains/games/components/wordchain/WordDisplay.jsx b/src/domains/games/components/wordchain/WordDisplay.jsx new file mode 100644 index 0000000..38a310e --- /dev/null +++ b/src/domains/games/components/wordchain/WordDisplay.jsx @@ -0,0 +1,73 @@ +import { Box, Typography } from '@mui/material' +import { GAME_COLORS, GAME_TYPOGRAPHY } from '../../theme/gameTheme' +import { useThemeMode } from '../../../../contexts/ThemeContext' + +/** + * WordDisplay - 현재 단어 & 다음 글자 표시 + */ +const WordDisplay = ({ currentWord, nextLetter, isMyTurn }) => { + const { mode } = useThemeMode() + const isDark = mode === 'dark' + + return ( + + {currentWord ? ( + <> + + 현재 단어 + + + {currentWord} + + + + 다음 시작 글자: + + + {nextLetter} + + + + ) : ( + + 첫 단어를 기다리는 중... + + )} + + ) +} + +export default WordDisplay diff --git a/src/domains/games/components/wordchain/WordInput.jsx b/src/domains/games/components/wordchain/WordInput.jsx new file mode 100644 index 0000000..c6d9587 --- /dev/null +++ b/src/domains/games/components/wordchain/WordInput.jsx @@ -0,0 +1,96 @@ +import { useState } from 'react' +import { Box, TextField, Button, Typography } from '@mui/material' +import { Send as SendIcon } from '@mui/icons-material' +import { GAME_COLORS } from '../../theme/gameTheme' + +/** + * WordInput - 단어 입력 필드 + */ +const WordInput = ({ onSubmit, disabled, nextLetter, isMyTurn }) => { + const [word, setWord] = useState('') + const [error, setError] = useState('') + + const handleSubmit = (e) => { + e.preventDefault() + + const trimmedWord = word.trim().toLowerCase() + + // 유효성 검사 + if (!trimmedWord) { + setError('단어를 입력하세요') + return + } + + if (nextLetter && trimmedWord[0] !== nextLetter.toLowerCase()) { + setError(`단어는 '${nextLetter}'로 시작해야 합니다`) + return + } + + // 알파벳만 허용 + if (!/^[a-z]+$/.test(trimmedWord)) { + setError('영어 알파벳만 입력하세요') + return + } + + setError('') + onSubmit(trimmedWord) + setWord('') + } + + const handleChange = (e) => { + setWord(e.target.value) + setError('') + } + + return ( + + {!isMyTurn && ( + + 다른 플레이어의 턴입니다 + + )} + + + + + + ) +} + +export default WordInput diff --git a/src/domains/games/components/wordchain/WordchainTimer.jsx b/src/domains/games/components/wordchain/WordchainTimer.jsx new file mode 100644 index 0000000..aac71cf --- /dev/null +++ b/src/domains/games/components/wordchain/WordchainTimer.jsx @@ -0,0 +1,59 @@ +import { Box, CircularProgress, Typography } from '@mui/material' +import { GAME_COLORS, GAME_TYPOGRAPHY } from '../../theme/gameTheme' + +/** + * WordchainTimer - 원형 타이머 + */ +const WordchainTimer = ({ timeLeft, timeLimit }) => { + const percentage = (timeLeft / timeLimit) * 100 + const isDanger = timeLeft <= 5 + + return ( + + + + + {timeLeft} + + + + ) +} + +export default WordchainTimer diff --git a/src/domains/games/pages/WordchainLobbyPage.jsx b/src/domains/games/pages/WordchainLobbyPage.jsx new file mode 100644 index 0000000..1298b07 --- /dev/null +++ b/src/domains/games/pages/WordchainLobbyPage.jsx @@ -0,0 +1,310 @@ +import { useCallback, useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Container, + FormControlLabel, + Grid, + IconButton, + MenuItem, + Select, + Switch, + Typography, +} from '@mui/material' +import { + Add as AddIcon, + Link as LinkIcon, + Refresh as RefreshIcon, +} from '@mui/icons-material' +import GameRoomCard from '../components/GameRoomCard' +import CreateGameRoomModal from '../components/CreateGameRoomModal' +import { gameService } from '../services/gameService' +import { GAME_COLORS } from '../theme/gameTheme' +import { useAuth } from '../../../contexts/AuthContext' + +const WordchainLobbyPage = () => { + const navigate = useNavigate() + const { user } = useAuth() + + const [rooms, setRooms] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [createModalOpen, setCreateModalOpen] = useState(false) + const [creating, setCreating] = useState(false) + + // 필터 + const [filters, setFilters] = useState({ + level: '', + waitingOnly: true, + }) + + // 방 목록 조회 - gameType을 WORDCHAIN으로 필터링 + const fetchRooms = useCallback(async () => { + try { + setLoading(true) + setError(null) + + const params = { + gameType: 'WORDCHAIN', + } + if (filters.level) params.level = filters.level + if (filters.waitingOnly) params.status = 'WAITING' + + const response = await gameService.getRooms(params) + setRooms(response.data.rooms || []) + } catch (err) { + console.error('Failed to fetch rooms:', err) + setError('방 목록을 불러오는데 실패했습니다') + } finally { + setLoading(false) + } + }, [filters]) + + useEffect(() => { + fetchRooms() + }, [fetchRooms]) + + // 방 생성 + const handleCreateRoom = async (data) => { + try { + setCreating(true) + // gameType을 WORDCHAIN으로 설정 + const response = await gameService.createRoom({ + ...data, + gameType: 'WORDCHAIN', + }) + setCreateModalOpen(false) + // 생성된 방의 대기실로 이동 + navigate(`/games/wordchain/${response.data.roomId}/waiting`) + } catch (err) { + console.error('Failed to create room:', err) + setError('방 생성에 실패했습니다') + } finally { + setCreating(false) + } + } + + // 방 참가 + const handleJoinRoom = async (room) => { + try { + const response = await gameService.joinRoom(room.roomId) + // roomToken을 sessionStorage에 저장 (WebSocket 연결 시 사용) + if (response.data?.roomToken) { + sessionStorage.setItem(`roomToken_${room.roomId}`, response.data.roomToken) + } + navigate(`/games/wordchain/${room.roomId}/waiting`) + } catch (err) { + console.error('Failed to join room:', err) + setError(err.message || '방 참가에 실패했습니다') + } + } + + // 관전 + const handleSpectateRoom = (room) => { + navigate(`/games/wordchain/${room.roomId}/play?spectate=true`) + } + + return ( + + {/* 헤더 */} + + + + + + + + 끝말잇기 + + + 영어 단어로 끝말잇기를 즐겨보세요 + + + + + + {/* 필터 + 액션 */} + + {/* 필터 */} + + + + setFilters(prev => ({ ...prev, waitingOnly: e.target.checked }))} + sx={{ + '& .MuiSwitch-switchBase.Mui-checked': { + color: GAME_COLORS.primary, + }, + '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { + bgcolor: GAME_COLORS.primary, + }, + }} + /> + } + label={대기중만} + /> + + + + + + + + + {/* 방 만들기 버튼 */} + + + + {/* 에러 */} + {error && ( + setError(null)} sx={{ mb: 3, borderRadius: '12px' }}> + {error} + + )} + + {/* 방 목록 */} + {loading ? ( + + + + ) : rooms.length === 0 ? ( + + + + + + 대기 중인 방이 없습니다 + + + 새로운 게임방을 만들어보세요! + + + + ) : ( + + {rooms.map((room) => ( + + + + ))} + + )} + + {/* 방 생성 모달 */} + setCreateModalOpen(false)} + onCreate={handleCreateRoom} + loading={creating} + gameType="WORDCHAIN" + /> + + ) +} + +export default WordchainLobbyPage diff --git a/src/domains/games/pages/WordchainPlayPage.jsx b/src/domains/games/pages/WordchainPlayPage.jsx new file mode 100644 index 0000000..f248609 --- /dev/null +++ b/src/domains/games/pages/WordchainPlayPage.jsx @@ -0,0 +1,477 @@ +import { useCallback, useEffect, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Container, + Grid, + IconButton, + Typography, +} from '@mui/material' +import { + ExitToApp as ExitIcon, + Stop as StopIcon, +} from '@mui/icons-material' +import WordDisplay from '../components/wordchain/WordDisplay' +import WordchainTimer from '../components/wordchain/WordchainTimer' +import PlayerList from '../components/wordchain/PlayerList' +import UsedWordsList from '../components/wordchain/UsedWordsList' +import WordInput from '../components/wordchain/WordInput' +import GameEndModal from '../components/wordchain/GameEndModal' +import { gameService } from '../services/gameService' +import wordchainService from '../services/wordchainService' +import { GAME_COLORS } from '../theme/gameTheme' +import { useAuth } from '../../../contexts/AuthContext' +import { useThemeMode } from '../../../contexts/ThemeContext' +import { useChatWebSocket } from '../../freetalk/hooks/useChatWebSocket' + +const WordchainPlayPage = () => { + const { roomId } = useParams() + const navigate = useNavigate() + const { user } = useAuth() + const { mode } = useThemeMode() + const isDark = mode === 'dark' + const currentUserId = user?.userId || user?.username || user?.sub + + // WebSocket 연결 + const { + isConnected, + gameState: wsGameState, + error: wsError, + connect, + disconnect, + } = useChatWebSocket(roomId, currentUserId) + + // 로컬 게임 상태 + const [gameState, setGameState] = useState({ + status: 'PLAYING', + currentWord: null, + nextLetter: null, + currentTurnUserId: null, + turnStartTime: Date.now(), + turnTimeLimit: 15, + players: [], + usedWords: [], + winner: null, + ranking: null, + finalScores: null, + }) + + const [room, setRoom] = useState(null) + const [loading, setLoading] = useState(true) + const [timeLeft, setTimeLeft] = useState(15) + const [showEndModal, setShowEndModal] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [timeoutSent, setTimeoutSent] = useState(false) // 타임아웃 중복 방지 + + const isMyTurn = gameState.currentTurnUserId === currentUserId + console.log('[WordchainPlayPage] Turn check:', { currentUserId, currentTurnUserId: gameState.currentTurnUserId, isMyTurn }) + + // WebSocket에서 온 players 배열을 객체 배열로 변환 (nickname 매핑) + const mapPlayersWithNickname = (playerIds, existingPlayers, participants) => { + if (!playerIds || !Array.isArray(playerIds)) return existingPlayers + + // playerIds가 이미 객체 배열이면 그대로 반환 + if (playerIds.length > 0 && typeof playerIds[0] === 'object') { + return playerIds + } + + // 문자열 배열이면 nickname 매핑 + return playerIds.map(userId => { + // 기존 players에서 찾기 + const existing = existingPlayers?.find(p => p.userId === userId) + if (existing) return existing + + // room.participants에서 찾기 + const participant = participants?.find(p => p.id === userId || p.participantId === userId || p.userId === userId) + if (participant) { + return { + userId, + nickname: participant.nickname || participant.name || userId.substring(0, 8), + isAlive: true, + } + } + + // 못 찾으면 userId로 표시 + return { + userId, + nickname: userId.substring(0, 8), + isAlive: true, + } + }) + } + + // WebSocket gameState 업데이트 반영 + useEffect(() => { + if (wsGameState) { + console.log('[WordchainPlayPage] Received wsGameState:', wsGameState) + + setGameState(prev => { + // 턴이 변경되면 타이머 리셋 + if (wsGameState.currentTurnUserId && wsGameState.currentTurnUserId !== prev.currentTurnUserId) { + setTimeLeft(wsGameState.turnTimeLimit ?? prev.turnTimeLimit ?? 15) + } + + // players 매핑 + const mappedPlayers = wsGameState.players + ? mapPlayersWithNickname(wsGameState.players, prev.players, room?.participants) + : prev.players + + return { + ...prev, + status: wsGameState.status ?? prev.status, + currentWord: wsGameState.currentWord ?? prev.currentWord, + nextLetter: wsGameState.nextLetter ?? prev.nextLetter, + currentTurnUserId: wsGameState.currentTurnUserId ?? prev.currentTurnUserId, + turnStartTime: wsGameState.turnStartTime ?? prev.turnStartTime, + turnTimeLimit: wsGameState.turnTimeLimit ?? prev.turnTimeLimit ?? 15, + players: mappedPlayers, + usedWords: wsGameState.usedWords ?? prev.usedWords, + winner: wsGameState.winner ?? prev.winner, + ranking: wsGameState.ranking ?? prev.ranking, + finalScores: wsGameState.finalScores ?? prev.finalScores, + } + }) + + // 게임 종료 처리 + if (wsGameState.status === 'FINISHED' && !showEndModal) { + setShowEndModal(true) + } + } + }, [wsGameState, showEndModal, room]) + + // 초기 로드 및 WebSocket 연결 + useEffect(() => { + const initGame = async () => { + try { + setLoading(true) + + // 방 정보 조회 + const roomResponse = await gameService.getRoom(roomId) + setRoom(roomResponse.data) + + // sessionStorage에서 WORDCHAIN_START 데이터 확인 + const savedState = sessionStorage.getItem(`wordchainState_${roomId}`) + let gameData = null + + if (savedState) { + gameData = JSON.parse(savedState) + console.log('[WordchainPlayPage] Got saved wordchain state:', gameData) + // 페이지 이탈 시 삭제하도록 변경 (StrictMode 두 번 마운트 대응) + } else { + // sessionStorage에 없으면 API 조회 시도 + try { + const statusResponse = await wordchainService.getStatus(roomId) + gameData = statusResponse.data || statusResponse + console.log('[WordchainPlayPage] Got game status:', gameData) + } catch (err) { + // 게임 상태 조회 실패 시 WebSocket 이벤트 대기 + console.log('[WordchainPlayPage] Failed to get status, waiting for WebSocket:', err.message) + // 기본 상태로 시작하고 WebSocket에서 업데이트 받음 + gameData = { + currentWord: null, + nextLetter: null, + currentTurnUserId: null, + turnTimeLimit: 15, + players: roomResponse.data.participants || [], + usedWords: [], + } + } + } + + setGameState({ + status: 'PLAYING', + currentWord: gameData.currentWord || null, + nextLetter: gameData.nextLetter || null, + currentTurnUserId: gameData.currentTurnUserId, + turnStartTime: gameData.turnStartTime || Date.now(), + turnTimeLimit: gameData.turnTimeLimit || 15, + players: gameData.players || roomResponse.data.participants || [], + usedWords: gameData.usedWords || [], + winner: null, + }) + + setTimeLeft(gameData.turnTimeLimit || 15) + + // WebSocket 연결 + console.log('[WordchainPlayPage] Connecting WebSocket...') + await connect() + console.log('[WordchainPlayPage] WebSocket connected') + } catch (err) { + console.error('Failed to init game:', err) + } finally { + setLoading(false) + } + } + + initGame() + + return () => { + console.log('[WordchainPlayPage] Disconnecting WebSocket...') + disconnect() + } + }, [roomId, currentUserId, connect, disconnect]) + + // 타이머 + useEffect(() => { + if (gameState.status !== 'PLAYING') return + + const interval = setInterval(() => { + setTimeLeft(prev => { + if (prev <= 1) { + return 0 + } + return prev - 1 + }) + }, 1000) + + return () => clearInterval(interval) + }, [gameState.status]) + + // 타임아웃 처리 (한 번만 전송) + useEffect(() => { + if (timeLeft === 0 && isMyTurn && isConnected && !timeoutSent) { + console.log('[WordchainPlayPage] Timer expired, sending timeout') + setTimeoutSent(true) + wordchainService.timeout(roomId).catch(err => { + console.error('Failed to send timeout:', err) + }) + } + }, [timeLeft, isMyTurn, isConnected, timeoutSent, roomId]) + + // 턴 변경 시 타임아웃 플래그 리셋 + useEffect(() => { + setTimeoutSent(false) + }, [gameState.currentTurnUserId]) + + // 단어 제출 + const handleSubmitWord = async (word) => { + if (!isMyTurn || submitting) return + + try { + setSubmitting(true) + await wordchainService.submit(roomId, word) + // 서버에서 WebSocket으로 결과 브로드캐스트 + } catch (err) { + console.error('Failed to submit word:', err) + alert(err.response?.data?.message || err.message || '단어 제출에 실패했습니다') + } finally { + setSubmitting(false) + } + } + + // 게임 종료 + const handleStopGame = async () => { + disconnect() + try { + await wordchainService.stop(roomId) + } catch (err) { + console.error('Failed to stop game:', err) + // 에러가 나도 무시하고 진행 + } + sessionStorage.removeItem(`roomToken_${roomId}`) + sessionStorage.removeItem(`wordchainState_${roomId}`) + navigate('/games/wordchain') + } + + // 재시작 + const handleRestart = async () => { + try { + disconnect() + await gameService.restartGame(roomId) + setShowEndModal(false) + navigate(`/games/wordchain/${roomId}/waiting`) + } catch (err) { + console.error('Failed to restart:', err) + setShowEndModal(false) + navigate(`/games/wordchain/${roomId}/waiting`) + } + } + + // 나가기 + const handleLeave = async () => { + try { + disconnect() + await gameService.leaveRoom(roomId) + } catch (err) { + console.error('Failed to leave room:', err) + } + sessionStorage.removeItem(`roomToken_${roomId}`) + navigate('/games/wordchain') + } + + if (loading) { + return ( + + + + ) + } + + return ( + + {/* 헤더 */} + + + + + + {room?.name || '끝말잇기'} + + + + + + + + + + + + {/* WebSocket 에러 */} + {wsError && ( + + + {wsError} + + + )} + + {/* 메인 컨텐츠 */} + + + {/* 좌측: 게임 영역 */} + + {/* 타이머 & 현재 턴 */} + + + + 현재 턴 + + + {gameState.players.find(p => p.userId === gameState.currentTurnUserId)?.nickname || + gameState.currentTurnUserId || + '대기 중'} + + {isMyTurn && ( + + )} + + + + + {/* 현재 단어 & 다음 글자 */} + + + + + {/* 단어 입력 */} + + + + + + {/* 우측: 플레이어 & 사용된 단어 */} + + {/* 플레이어 목록 */} + + + + + {/* 사용된 단어 */} + + + + + + + + {/* 게임 종료 모달 */} + + + ) +} + +export default WordchainPlayPage diff --git a/src/domains/games/pages/WordchainWaitingPage.jsx b/src/domains/games/pages/WordchainWaitingPage.jsx new file mode 100644 index 0000000..d8c8102 --- /dev/null +++ b/src/domains/games/pages/WordchainWaitingPage.jsx @@ -0,0 +1,394 @@ +import { useCallback, useEffect, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { + Alert, + Box, + Button, + Card, + Chip, + CircularProgress, + Container, + IconButton, + Typography, +} from '@mui/material' +import { + ArrowBack as ArrowBackIcon, + PlayArrow as PlayIcon, + Settings as SettingsIcon, +} from '@mui/icons-material' +import ParticipantList from '../components/ParticipantList' +import WaitingChat from '../components/WaitingChat' +import { gameService } from '../services/gameService' +import { wordchainService } from '../services/wordchainService' +import { GAME_COLORS } from '../theme/gameTheme' +import { useAuth } from '../../../contexts/AuthContext' +import { useThemeMode } from '../../../contexts/ThemeContext' +import { useChatWebSocket } from '../../freetalk/hooks/useChatWebSocket' + +const WordchainWaitingPage = () => { + const { roomId } = useParams() + const navigate = useNavigate() + const { user } = useAuth() + const { mode } = useThemeMode() + const isDark = mode === 'dark' + const currentUserId = user?.userId || user?.username || user?.sub + + // WebSocket 연결 + const { + isConnected, + messages: wsMessages, + gameState: wsGameState, + error: wsError, + connect, + disconnect, + sendMessage: wsSendMessage, + } = useChatWebSocket(roomId, currentUserId) + + const [room, setRoom] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [starting, setStarting] = useState(false) + + // 채팅 메시지 (WebSocket 연동) + const [messages, setMessages] = useState([ + { + id: 'system-1', + content: '게임 대기실에 입장했습니다.', + isSystem: true, + createdAt: new Date().toISOString(), + }, + ]) + + // WebSocket 메시지 동기화 + useEffect(() => { + if (wsMessages && wsMessages.length > 0) { + setMessages(prev => { + const systemMessages = prev.filter(m => m.isSystem && m.id === 'system-1') + const existingIds = new Set(systemMessages.map(m => m.id)) + const uniqueWsMessages = wsMessages.filter(m => !existingIds.has(m.id)) + const allMessages = [...systemMessages, ...uniqueWsMessages] + const seen = new Set() + return allMessages.filter(m => { + if (seen.has(m.id)) return false + seen.add(m.id) + return true + }) + }) + } + }, [wsMessages]) + + // 게임 시작 감지 (WebSocket GAME_START 이벤트) + useEffect(() => { + if (wsGameState?.status === 'PLAYING') { + console.log('[WordchainWaitingPage] Game started via WebSocket, navigating to play page') + navigate(`/games/wordchain/${roomId}/play`) + } + }, [wsGameState?.status, roomId, navigate]) + + // 방 정보 조회 + const fetchRoom = useCallback(async (showLoading = false) => { + try { + if (showLoading) { + setLoading(true) + } + const response = await gameService.getRoom(roomId) + setRoom(response.data) + + // 게임이 시작되면 플레이 페이지로 이동 + if (response.data.status === 'PLAYING') { + navigate(`/games/wordchain/${roomId}/play`) + } + } catch (err) { + console.error('Failed to fetch room:', err) + setError('방 정보를 불러오는데 실패했습니다') + } finally { + if (showLoading) { + setLoading(false) + } + } + }, [roomId, navigate]) + + // 초기 로딩 및 WebSocket 연결 + useEffect(() => { + const init = async () => { + await fetchRoom(true) + console.log('[WordchainWaitingPage] Connecting WebSocket...') + try { + await connect() + console.log('[WordchainWaitingPage] WebSocket connected') + } catch (err) { + console.error('[WordchainWaitingPage] WebSocket connection failed:', err) + } + } + init() + + return () => { + console.log('[WordchainWaitingPage] Disconnecting WebSocket...') + disconnect() + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + // 주기적 새로고침 + useEffect(() => { + const interval = setInterval(() => fetchRoom(false), 3000) + return () => clearInterval(interval) + }, [fetchRoom]) + + // 게임 시작 + const handleStartGame = async () => { + try { + setStarting(true) + await wordchainService.start(roomId) + navigate(`/games/wordchain/${roomId}/play`) + } catch (err) { + console.error('Failed to start game:', err) + const errorMessage = err.response?.data?.message || err.message || '게임 시작에 실패했습니다' + setError(errorMessage) + } finally { + setStarting(false) + } + } + + // 방 나가기 + const handleLeaveRoom = async () => { + try { + disconnect() + await gameService.leaveRoom(roomId) + sessionStorage.removeItem(`roomToken_${roomId}`) + navigate('/games/wordchain') + } catch (err) { + console.error('Failed to leave room:', err) + } + } + + // 채팅 메시지 전송 + const handleSendMessage = (content) => { + if (isConnected) { + console.log('[WordchainWaitingPage] Sending message via WebSocket:', content) + wsSendMessage(content, 'TEXT') + } else { + const newMessage = { + id: `msg-${Date.now()}`, + userId: currentUserId, + nickname: user?.nickname || user?.username || '플레이어', + content, + createdAt: new Date().toISOString(), + } + setMessages(prev => [...prev, newMessage]) + } + } + + const isHost = room?.hostId === currentUserId + const canStart = isHost && room?.currentParticipants >= 2 + + if (loading) { + return ( + + + + ) + } + + if (!room) { + return ( + + + 방을 찾을 수 없습니다 + + + + ) + } + + return ( + + {/* 헤더 */} + + + + + + + + + + {room.name} + + + + + + + + + + {isHost && ( + + + + )} + + + + + {/* 에러 */} + {(error || wsError) && ( + + setError(null)} sx={{ borderRadius: '12px' }}> + {error || wsError} + + + )} + + {/* 메인 컨텐츠 */} + + + {/* 좌측: 참가자 목록 */} + + + + + {/* 우측: 대기 채팅 */} + + + + 대기 채팅 + + + + + + + + + {/* 하단: 게임 설정 + 시작 버튼 */} + + + + + 턴 시간 + + + {room.gameSettings?.turnTimeLimit || 15}초 + + + + + 난이도 + + + {room.level || 'INTERMEDIATE'} + + + + + {isHost ? ( + + ) : ( + + + 방장이 게임을 시작할 때까지 기다려주세요 + + + + )} + + + + ) +} + +export default WordchainWaitingPage diff --git a/src/domains/games/services/gameService.js b/src/domains/games/services/gameService.js index 1c3958f..03844d4 100644 --- a/src/domains/games/services/gameService.js +++ b/src/domains/games/services/gameService.js @@ -20,7 +20,8 @@ export const gameRoomService = { const params = new URLSearchParams() // 게임방 필터 params.append('type', 'GAME') - params.append('gameType', 'CATCHMIND') + const gameType = filters.gameType || 'CATCHMIND' + params.append('gameType', gameType) // 백엔드는 소문자 level 값 사용 if (filters.status) params.append('status', filters.status) @@ -35,11 +36,11 @@ export const gameRoomService = { let data = response.data if (data?.rooms) { data.rooms = data.rooms.filter(room => - room.type === 'GAME' || room.gameType === 'CATCHMIND' + room.type === 'GAME' || room.gameType === gameType ) } else if (Array.isArray(data)) { data = data.filter(room => - room.type === 'GAME' || room.gameType === 'CATCHMIND' + room.type === 'GAME' || room.gameType === gameType ) } @@ -67,6 +68,19 @@ export const gameRoomService = { * @param {Object} data.gameSettings - 게임 설정 */ create: async (data) => { + const gameType = data.gameType || 'CATCHMIND' + const isWordchain = gameType === 'WORDCHAIN' + + // 게임 타입별 gameSettings 설정 + const gameSettings = isWordchain + ? { + turnTimeLimit: data.turnTimeLimit || 15, + } + : { + maxRounds: data.maxRounds || 5, + roundTimeLimit: data.roundTimeLimit || 60, + } + const payload = { name: data.name, description: data.description || '', @@ -75,11 +89,8 @@ export const gameRoomService = { isPrivate: data.isPrivate || false, password: data.password, type: 'GAME', - gameType: 'CATCHMIND', - gameSettings: { - maxRounds: data.maxRounds || 5, - roundTimeLimit: data.roundTimeLimit || 60, - }, + gameType: gameType, + gameSettings: gameSettings, } console.log('[gameService] create payload:', payload) diff --git a/src/domains/games/services/wordchainService.js b/src/domains/games/services/wordchainService.js new file mode 100644 index 0000000..b63a441 --- /dev/null +++ b/src/domains/games/services/wordchainService.js @@ -0,0 +1,58 @@ +/** + * Word Chain Service - 백엔드 API 연동 + * 영어 끝말잇기 게임 관련 REST API 호출 + */ +import chatApi from '../../../api/chatApi' + +/** + * 끝말잇기 게임 진행 관련 API + */ +export const wordchainService = { + /** + * 게임 시작 + * @param {string} roomId + */ + start: async (roomId) => { + const response = await chatApi.post(`/chat/rooms/${roomId}/wordchain/start`, {}) + return response.data + }, + + /** + * 단어 제출 + * @param {string} roomId + * @param {string} word + */ + submit: async (roomId, word) => { + const response = await chatApi.post(`/chat/rooms/${roomId}/wordchain/submit`, { word }) + return response.data + }, + + /** + * 타임아웃 처리 + * @param {string} roomId + */ + timeout: async (roomId) => { + const response = await chatApi.post(`/chat/rooms/${roomId}/wordchain/timeout`, {}) + return response.data + }, + + /** + * 게임 중단 + * @param {string} roomId + */ + stop: async (roomId) => { + const response = await chatApi.post(`/chat/rooms/${roomId}/wordchain/stop`, {}) + return response.data + }, + + /** + * 게임 상태 조회 + * @param {string} roomId + */ + getStatus: async (roomId) => { + const response = await chatApi.get(`/chat/rooms/${roomId}/wordchain/status`) + return response.data + }, +} + +export default wordchainService diff --git a/src/domains/news/services/newsService.js b/src/domains/news/services/newsService.js index a417fd8..4a30eee 100644 --- a/src/domains/news/services/newsService.js +++ b/src/domains/news/services/newsService.js @@ -3,28 +3,33 @@ * 뉴스 영어 학습 관련 API 호출 */ -const API_URL = import.meta.env.VITE_API_URL +import api from '../../../api/axios' /** - * API 요청 헬퍼 + * API 요청 헬퍼 - 공통 axios 인스턴스 사용 (토큰 갱신 로직 포함) */ const fetchWithAuth = async (endpoint, options = {}) => { - const token = localStorage.getItem('accessToken') - const response = await fetch(`${API_URL}${endpoint}`, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...(token && { Authorization: `Bearer ${token}` }), - ...options.headers, - }, - }) + const { method = 'GET', body, ...restOptions } = options + + const config = { + ...restOptions, + } - if (!response.ok) { - const error = await response.json().catch(() => ({})) - throw new Error(error.message || 'API request failed') + if (method === 'GET') { + const response = await api.get(endpoint, config) + return response.data + } else if (method === 'POST') { + const response = await api.post(endpoint, body ? JSON.parse(body) : undefined, config) + return response.data + } else if (method === 'PUT') { + const response = await api.put(endpoint, body ? JSON.parse(body) : undefined, config) + return response.data + } else if (method === 'DELETE') { + const response = await api.delete(endpoint, config) + return response.data } - return response.json() + throw new Error(`Unsupported method: ${method}`) } /** diff --git a/src/domains/profile/hooks/useProfile.js b/src/domains/profile/hooks/useProfile.js new file mode 100644 index 0000000..3e965c4 --- /dev/null +++ b/src/domains/profile/hooks/useProfile.js @@ -0,0 +1,35 @@ +import { useEffect } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { + fetchMyProfile, + updateProfile, + uploadProfileImage, + clearError +} from '../store/profileSlice' + +export const useProfile = () => { + const dispatch = useDispatch() + const { profile, loading, error, updateLoading, imageUploading } = useSelector( + (state) => state.profile + ) + + useEffect(() => { + if (!profile && !loading && !error) { + dispatch(fetchMyProfile()) + } + }, [dispatch, profile, loading, error]) + + return { + profile, + loading, + error, + updateLoading, + imageUploading, + updateProfile: (data) => dispatch(updateProfile(data)).unwrap(), + uploadImage: (file) => dispatch(uploadProfileImage(file)).unwrap(), + clearError: () => dispatch(clearError()), + refetch: () => dispatch(fetchMyProfile()) + } +} + +export default useProfile \ No newline at end of file diff --git a/src/domains/profile/services/profileService.js b/src/domains/profile/services/profileService.js new file mode 100644 index 0000000..c41fcdc --- /dev/null +++ b/src/domains/profile/services/profileService.js @@ -0,0 +1,39 @@ +import api from '../../../api/axios' + +const profileService = { + // 내 프로필 조회 + getMyProfile: async () => { + const response = await api.get('/users/profile/me') + return response.data + }, + + // 프로필 수정 (닉네임, 레벨) + updateProfile: async ({ nickname, level, profileUrl }) => { + const response = await api.put('/users/profile/me', { + nickname, + level, + profileUrl + }) + return response.data + }, + + // 이미지 업로드 URL 발급 + getImageUploadUrl: async (fileName, contentType) => { + const response = await api.post('/users/profile/me/image', { + fileName, + contentType + }) + return response.data + }, + + // S3에 이미지 직접 업로드 + uploadImageToS3: async (uploadUrl, file) => { + await fetch(uploadUrl, { + method: 'PUT', + headers: { 'Content-Type': file.type }, + body: file + }) + } +} + +export default profileService \ No newline at end of file diff --git a/src/domains/profile/store/profileSlice.js b/src/domains/profile/store/profileSlice.js new file mode 100644 index 0000000..76995ec --- /dev/null +++ b/src/domains/profile/store/profileSlice.js @@ -0,0 +1,114 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' +import profileService from '../services/profileService' + +// 프로필 조회 +export const fetchMyProfile = createAsyncThunk( + 'profile/fetchMyProfile', + async (_, { rejectWithValue }) => { + try { + const response = await profileService.getMyProfile() + return response.data || response + } catch (error) { + const status = error.response?.status + const message = error.response?.data?.error || error.message || '프로필 조회 실패' + return rejectWithValue({ message, status }) + } + } +) + +// 프로필 수정 +export const updateProfile = createAsyncThunk( + 'profile/updateProfile', + async (data, { rejectWithValue }) => { + try { + const response = await profileService.updateProfile(data) + return response.data + } catch (error) { + return rejectWithValue(error.response?.data?.error || '프로필 수정 실패') + } + } +) + +// 이미지 업로드 +export const uploadProfileImage = createAsyncThunk( + 'profile/uploadImage', + async (file, { dispatch, rejectWithValue }) => { + try { + const urlResponse = await profileService.getImageUploadUrl(file.name, file.type) + const { uploadUrl, imageUrl } = urlResponse.data + + await profileService.uploadImageToS3(uploadUrl, file) + await dispatch(updateProfile({ profileUrl: imageUrl })) + + return imageUrl + } catch (error) { + return rejectWithValue('이미지 업로드 실패') + } + } +) + +const profileSlice = createSlice({ + name: 'profile', + initialState: { + profile: null, + loading: false, + error: null, + updateLoading: false, + imageUploading: false, + authError: false + }, + reducers: { + clearError: (state) => { state.error = null }, + clearProfile: (state) => { state.profile = null } + }, + extraReducers: (builder) => { + builder + // fetchMyProfile + .addCase(fetchMyProfile.pending, (state) => { + state.loading = true + state.error = null + }) + .addCase(fetchMyProfile.fulfilled, (state, action) => { + state.loading = false + state.profile = action.payload + }) + .addCase(fetchMyProfile.rejected, (state, action) => { + state.loading = false + state.error = action.payload?.message || action.payload + + const status = action.payload?.status + const message = String(action.payload?.message || action.payload || '') + + if (status === 401 || message.includes('401') || message.includes('인증')) { + state.profile = null + state.authError = true + } + }) + // updateProfile + .addCase(updateProfile.pending, (state) => { + state.updateLoading = true + }) + .addCase(updateProfile.fulfilled, (state, action) => { + state.updateLoading = false + state.profile = action.payload + }) + .addCase(updateProfile.rejected, (state, action) => { + state.updateLoading = false + state.error = action.payload + }) + // uploadProfileImage + .addCase(uploadProfileImage.pending, (state) => { + state.imageUploading = true + }) + .addCase(uploadProfileImage.fulfilled, (state) => { + state.imageUploading = false + }) + .addCase(uploadProfileImage.rejected, (state, action) => { + state.imageUploading = false + state.error = action.payload + }) + } +}) + +export const { clearError, clearProfile } = profileSlice.actions +export default profileSlice.reducer \ No newline at end of file diff --git a/src/i18n/translations.js b/src/i18n/translations.js index 5a8b7af..d5a1384 100644 --- a/src/i18n/translations.js +++ b/src/i18n/translations.js @@ -388,6 +388,8 @@ export const translations = { description: '재미있는 게임으로 영어 실력을 향상하세요', catchmindTitle: '캐치마인드', catchmindDesc: '그림 맞추기 게임', + wordchainTitle: '끝말잇기', + wordchainDesc: '영어 끝말잇기', }, // News @@ -837,6 +839,8 @@ export const translations = { description: 'Improve your English with fun games', catchmindTitle: 'Catchmind', catchmindDesc: 'Drawing guessing game', + wordchainTitle: 'Word Chain', + wordchainDesc: 'English word chain game', }, // News diff --git a/src/layouts/MainLayout/Header/index.jsx b/src/layouts/MainLayout/Header/index.jsx index f52deba..d4c6583 100644 --- a/src/layouts/MainLayout/Header/index.jsx +++ b/src/layouts/MainLayout/Header/index.jsx @@ -1,5 +1,6 @@ -import {useState} from 'react' -import {useNavigate} from 'react-router-dom' +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useSelector } from 'react-redux' import { AppBar, Avatar, @@ -24,25 +25,26 @@ import { Settings as SettingsIcon, Translate as TranslateIcon, } from '@mui/icons-material' -import {useThemeMode} from '../../../contexts/ThemeContext' -import {useSettings, useTranslation} from '../../../contexts/SettingsContext' -import {LANGUAGE_LABELS, LANGUAGES} from '../../../i18n/translations' -import {useAuth} from '../../../contexts/AuthContext' -import {useNotificationContext, NotificationMenu} from '../../../domains/notification' +import { useThemeMode } from '../../../contexts/ThemeContext' +import { useSettings, useTranslation } from '../../../contexts/SettingsContext' +import { LANGUAGE_LABELS, LANGUAGES } from '../../../i18n/translations' +import { useAuth } from '../../../contexts/AuthContext' +import { useNotificationContext, NotificationMenu } from '../../../domains/notification' -const Header = ({onMenuClick, sidebarOpen}) => { +const Header = ({ onMenuClick, sidebarOpen }) => { const theme = useTheme() const navigate = useNavigate() const isMobile = useMediaQuery(theme.breakpoints.down('md')) - const {mode, toggleTheme} = useThemeMode() - const {setLanguage, language} = useSettings() - const {t} = useTranslation() + const { mode, toggleTheme } = useThemeMode() + const { setLanguage, language } = useSettings() + const { t } = useTranslation() + const { profile } = useSelector((state) => state.profile) const [anchorEl, setAnchorEl] = useState(null) const [notificationAnchor, setNotificationAnchor] = useState(null) const [langAnchor, setLangAnchor] = useState(null) - const {logout} = useAuth() - const {unreadCount} = useNotificationContext() + const { logout } = useAuth() + const { unreadCount } = useNotificationContext() const handleProfileMenuOpen = (event) => { setAnchorEl(event.currentTarget) @@ -93,7 +95,7 @@ const Header = ({onMenuClick, sidebarOpen}) => { borderColor: mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)', }} > - + {/* Hamburger menu (mobile) */} {isMobile && ( { }, }} > - + )} @@ -145,7 +147,7 @@ const Header = ({onMenuClick, sidebarOpen}) => { L - + { - + {/* Right side icons */} - + {/* Language selector */} { }, }} > - + {/* Dark mode toggle */} @@ -192,7 +194,7 @@ const Header = ({onMenuClick, sidebarOpen}) => { }, }} > - {mode === 'dark' ? : } + {mode === 'dark' ? : } {/* Notifications */} @@ -219,14 +221,14 @@ const Header = ({onMenuClick, sidebarOpen}) => { }, }} > - + {/* Profile */} { fontSize: 14, }} > - U + {profile?.nickname ? profile.nickname.substring(0, 1).toUpperCase() : 'U'} @@ -255,15 +257,15 @@ const Header = ({onMenuClick, sidebarOpen}) => { minWidth: 160, }, }} - transformOrigin={{horizontal: 'right', vertical: 'top'}} - anchorOrigin={{horizontal: 'right', vertical: 'bottom'}} + transformOrigin={{ horizontal: 'right', vertical: 'top' }} + anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} > - + {t('settings.language')} - + {Object.entries(LANGUAGES).map(([key, value]) => ( { boxShadow: '0 10px 40px -10px rgba(0,0,0,0.2)', }, }} - transformOrigin={{horizontal: 'right', vertical: 'top'}} - anchorOrigin={{horizontal: 'right', vertical: 'bottom'}} + transformOrigin={{ horizontal: 'right', vertical: 'top' }} + anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} > - + - {t('header.user')} + {profile?.nickname || t('header.user')} - user@example.com + {profile?.email || 'user@example.com'} - + { handleProfileMenuClose(); navigate('/profile'); }} - sx={{py: 1.5, px: 2.5}} + sx={{ py: 1.5, px: 2.5 }} > - + {t('nav.profile')} { handleProfileMenuClose(); navigate('/settings'); }} - sx={{py: 1.5, px: 2.5}} + sx={{ py: 1.5, px: 2.5 }} > - + {t('nav.settings')} - + - + {t('nav.logout')} diff --git a/src/layouts/MainLayout/HorizontalNav/index.jsx b/src/layouts/MainLayout/HorizontalNav/index.jsx index 0c6e3ff..fb62d41 100644 --- a/src/layouts/MainLayout/HorizontalNav/index.jsx +++ b/src/layouts/MainLayout/HorizontalNav/index.jsx @@ -141,6 +141,13 @@ const HorizontalNav = () => { path: '/games/catchmind', desc: t('games.catchmindDesc') }, + { + id: 'wordchain', + label: t('games.wordchainTitle') || '끝말잇기', + icon: GameIcon, + path: '/games/wordchain', + desc: t('games.wordchainDesc') || '영어 끝말잇기' + }, ], }, { diff --git a/src/layouts/MainLayout/Sidebar/index.jsx b/src/layouts/MainLayout/Sidebar/index.jsx index fa9dbc5..abc6754 100644 --- a/src/layouts/MainLayout/Sidebar/index.jsx +++ b/src/layouts/MainLayout/Sidebar/index.jsx @@ -169,6 +169,13 @@ const Sidebar = ({open, collapsed, onToggleCollapse, onClose}) => { path: '/games/catchmind', description: t('games.catchmindDesc'), }, + { + id: 'wordchain', + label: '끝말잇기', + icon: GameIcon, + path: '/games/wordchain', + description: '영어 끝말잇기', + }, ], }, ], diff --git a/src/store/index.js b/src/store/index.js index 543e164..17e676c 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -1,4 +1,6 @@ -import {configureStore, createSlice} from '@reduxjs/toolkit' +import { configureStore, createSlice } from '@reduxjs/toolkit' + +import profileReducer from '../domains/profile/store/profileSlice' // 임시 슬라이스 (빈 store 에러 방지) const appSlice = createSlice({ @@ -12,6 +14,7 @@ const appSlice = createSlice({ export const store = configureStore({ reducer: { app: appSlice.reducer, + profile: profileReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ From b83654fa453fc3822496eeba87c88f9ed79e724d Mon Sep 17 00:00:00 2001 From: hye-inA Date: Mon, 26 Jan 2026 01:17:08 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix=20:=20=EC=A4=91=EB=B3=B5=20import?= =?UTF-8?q?=EB=AC=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/App.jsx b/src/App.jsx index e797c04..861df19 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,7 +1,6 @@ import { useState, useEffect } from 'react' import { Navigate, Route, Routes, useNavigate } from 'react-router-dom' import { useDispatch } from 'react-redux' -import { useDispatch } from 'react-redux' import { Box, Button, Card, CardContent, CircularProgress, Collapse, Container, Grid, Typography } from '@mui/material' import { ChevronRight as ChevronRightIcon, From 13ddd2362a4bc52edf714e419b3915b6b7c8bfcc Mon Sep 17 00:00:00 2001 From: hye-inA Date: Mon, 26 Jan 2026 11:50:18 +0900 Subject: [PATCH 7/7] =?UTF-8?q?featrue=20:=20=EC=B1=84=ED=8C=85=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=97=90=20?= =?UTF-8?q?=EB=B3=B4=EB=82=B4=EB=8A=94=20=EC=82=AC=EB=9E=8C(=EB=8B=89?= =?UTF-8?q?=EB=84=A4=EC=9E=84)=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../freetalk/components/ChatRoomModal.jsx | 209 +++++++++--------- 1 file changed, 105 insertions(+), 104 deletions(-) diff --git a/src/domains/freetalk/components/ChatRoomModal.jsx b/src/domains/freetalk/components/ChatRoomModal.jsx index 1856abc..653be25 100644 --- a/src/domains/freetalk/components/ChatRoomModal.jsx +++ b/src/domains/freetalk/components/ChatRoomModal.jsx @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useRef, useState} from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Alert, Avatar, @@ -30,20 +30,20 @@ import { messageService, voiceService } from '../../chat/services/chatService' -import {useSettings} from '../../../contexts/SettingsContext' -import {useAuth} from '../../../contexts/AuthContext' -import {useThemeMode} from '../../../contexts/ThemeContext' -import {DESIGN_TOKENS, getChatStyles} from '../../../theme/theme' -import {useChatWebSocket} from '../hooks/useChatWebSocket' +import { useSettings } from '../../../contexts/SettingsContext' +import { useAuth } from '../../../contexts/AuthContext' +import { useThemeMode } from '../../../contexts/ThemeContext' +import { DESIGN_TOKENS, getChatStyles } from '../../../theme/theme' +import { useChatWebSocket } from '../hooks/useChatWebSocket' import SystemCommandMessage from './SystemCommandMessage' -import {MessageType} from '../types/chatCommandTypes' +import { MessageType } from '../types/chatCommandTypes' -const ChatRoomModal = ({open, onClose, room, onLeave}) => { +const ChatRoomModal = ({ open, onClose, room, onLeave }) => { const theme = useTheme() - const {mode} = useThemeMode() + const { mode } = useThemeMode() const isDark = mode === 'dark' - const {settings} = useSettings() - const {user} = useAuth() + const { settings } = useSettings() + const { user } = useAuth() const currentUserId = user?.userId || user?.username || user?.sub const messagesEndRef = useRef(null) const dragRef = useRef(null) @@ -65,10 +65,10 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { const [error, setError] = useState(null) const [playingTTS, setPlayingTTS] = useState(null) const [minimized, setMinimized] = useState(false) - const [position, setPosition] = useState({x: 0, y: 0}) - const [savedPosition, setSavedPosition] = useState({x: 0, y: 0}) + const [position, setPosition] = useState({ x: 0, y: 0 }) + const [savedPosition, setSavedPosition] = useState({ x: 0, y: 0 }) const [isDragging, setIsDragging] = useState(false) - const [dragOffset, setDragOffset] = useState({x: 0, y: 0}) + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }) const [opacity, setOpacity] = useState(100) const [opacityAnchorEl, setOpacityAnchorEl] = useState(null) // 메시지 목록 조회 @@ -76,7 +76,7 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { if (!room?.id) return try { - const response = await messageService.getList(room.id, {limit: 50}) + const response = await messageService.getList(room.id, { limit: 50 }) const responseData = response.data || response const transformedMessages = (responseData.messages || []).map((msg) => ({ id: msg.messageId || msg.pk?.replace('MESSAGE#', ''), @@ -102,9 +102,9 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { // 초기 로드 useEffect(() => { - console.log('[ChatRoomModal] useEffect triggered:', {open, roomId: room?.id, currentUserId}) + console.log('[ChatRoomModal] useEffect triggered:', { open, roomId: room?.id, currentUserId }) if (open && room?.id && currentUserId) { - console.log('[ChatRoomModal] Initializing...', {roomId: room.id, userId: currentUserId}) + console.log('[ChatRoomModal] Initializing...', { roomId: room.id, userId: currentUserId }) setLoading(true) setMinimized(false) @@ -125,7 +125,7 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { // 스크롤 맨 아래로 const scrollToBottom = (instant = false) => { - messagesEndRef.current?.scrollIntoView({behavior: instant ? 'instant' : 'smooth'}) + messagesEndRef.current?.scrollIntoView({ behavior: instant ? 'instant' : 'smooth' }) } // 메시지 로드 완료 후 스크롤 @@ -259,7 +259,7 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { if (!minimized) { // 최소화: 현재 위치 저장 후 우측 하단으로 이동 setSavedPosition(position) - setPosition({x: 0, y: 0}) + setPosition({ x: 0, y: 0 }) } else { // 최대화: 저장된 위치로 복원 후 스크롤 맨 아래로 setPosition(savedPosition) @@ -299,7 +299,7 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { { pointerEvents: 'auto', }} > - + {room?.name || '채팅방'} @@ -358,28 +358,28 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { /> )} - + setOpacityAnchorEl(e.currentTarget)} - sx={{color: 'white'}} + sx={{ color: 'white' }} title="투명도" > - + setOpacityAnchorEl(null)} - anchorOrigin={{vertical: 'top', horizontal: 'center'}} - transformOrigin={{vertical: 'bottom', horizontal: 'center'}} + anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + transformOrigin={{ vertical: 'bottom', horizontal: 'center' }} slotProps={{ paper: { - sx: {pointerEvents: 'auto'} + sx: { pointerEvents: 'auto' } } }} > - + 투명도: {opacity}% @@ -392,18 +392,18 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { /> - - + + - - {minimized ? : } + + {minimized ? : } - - + + - - + + @@ -412,61 +412,61 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { <> {/* 에러 메시지 */} {error && ( - setError(null)} sx={{borderRadius: 0}}> + setError(null)} sx={{ borderRadius: 0 }}> {error} )} {/* 메시지 영역 */} {loading ? ( - + + + ) : ( + - - - ) : ( - - {messages.length === 0 ? ( - - - 첫 메시지를 보내보세요! - - - ) : ( - messages.map((message) => { - // 시스템 명령어 메시지 (SYSTEM_COMMAND) - if (message.messageType === MessageType.SYSTEM_COMMAND || message.messageType === 'SYSTEM_COMMAND') { - return ( - - ) - } - + // 스크롤바 숨김 (hover 시만 표시) + '&::-webkit-scrollbar': { + width: 6, + }, + '&::-webkit-scrollbar-thumb': { + bgcolor: 'transparent', + borderRadius: 3, + }, + '&:hover::-webkit-scrollbar-thumb': { + bgcolor: isDark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)', + }, + }} + > + {messages.length === 0 ? ( + + + 첫 메시지를 보내보세요! + + + ) : ( + messages.map((message) => { + // 시스템 명령어 메시지 (SYSTEM_COMMAND) + if (message.messageType === MessageType.SYSTEM_COMMAND || message.messageType === 'SYSTEM_COMMAND') { return ( + + ) + } + + return ( { whiteSpace: 'pre-wrap', height: 'auto', py: 0.5, - '& .MuiChip-label': {whiteSpace: 'pre-wrap'}, + '& .MuiChip-label': { whiteSpace: 'pre-wrap' }, }} /> ) : ( @@ -518,7 +518,7 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { mr: message.isOwn ? 0.5 : 0, fontSize: '0.6rem' }}> - {message.userId} + {message.nickname || message.userId} { size="small" onClick={() => handlePlayTTS(message.id)} disabled={playingTTS === message.id} - sx={{p: 0.25}} + sx={{ p: 0.25 }} > {playingTTS === message.id ? ( - + ) : ( + }} /> )} + color="text.secondary" + sx={{ fontSize: '0.55rem' }}> {formatTime(message.createdAt)} @@ -584,17 +584,17 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { size="small" onClick={() => handlePlayTTS(message.id)} disabled={playingTTS === message.id} - sx={{p: 0.25}} + sx={{ p: 0.25 }} > {playingTTS === message.id ? ( - + ) : ( - + )} + color="text.secondary" + sx={{ fontSize: '0.55rem' }}> {formatTime(message.createdAt)} @@ -604,11 +604,12 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { )} - )}) - )} -
- - )} + ) + }) + )} +
+ + )} {/* 입력 영역 */} { color: 'white', width: 32, height: 32, - '&:hover': {bgcolor: 'primary.dark'}, - '&:disabled': {bgcolor: 'grey.300'}, + '&:hover': { bgcolor: 'primary.dark' }, + '&:disabled': { bgcolor: 'grey.300' }, }} > - {sendingMessage ? : - } + {sendingMessage ? : + }