From 43a350313bdde5e756a5afa205f5cb139724b049 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 14:26:37 +0900 Subject: [PATCH] [FEAT] Real-time chat WebSocket integration and related enhancements - Implemented `chatWebSocketService` for WebSocket-based real-time chat and game commands - Added `useChatWebSocket` hook for managing WebSocket state and actions in components - Enhanced `ChatRoomPage`: integrated WebSocket for real-time messaging, added optimistic UI updates, and fallback for REST API - Updated `FreetalkPeoplePage` and chat lists to handle duplicate entries effectively - Extended `AuthContext` to store and clear tokens during login/logout - Improved error handling and realtime feedback in chat components --- src/App.jsx | 20 +- src/api/chatApi.js | 14 +- src/api/vocabApi.js | 14 +- src/contexts/AuthContext.jsx | 11 +- src/domains/badge/components/BadgeGrid.jsx | 117 ++- src/domains/badge/services/badgeService.js | 9 +- src/domains/chat/services/chatService.js | 52 +- .../freetalk/components/ChatRoomModal.jsx | 152 ++- .../freetalk/components/GameModePanel.jsx | 34 +- .../freetalk/hooks/useChatWebSocket.js | 277 ++++++ src/domains/freetalk/pages/ChatRoomPage.jsx | 282 ++++-- .../freetalk/pages/FreetalkPeoplePage.jsx | 30 +- .../freetalk/services/chatWebSocketService.js | 366 +++++++ .../grammar/components/ChatMessage.jsx | 19 + src/domains/grammar/pages/WritingPage.jsx | 27 +- .../grammar/services/grammarService.js | 9 +- .../grammar/services/grammarStreamService.js | 4 +- src/domains/vocab/pages/DailyLearning.jsx | 228 ++++- src/domains/vocab/pages/StatsPage.jsx | 814 +++++++++++----- src/domains/vocab/pages/TestPage.jsx | 11 +- src/domains/vocab/pages/VocabDashboard.jsx | 890 ++++++++++-------- src/domains/vocab/pages/WordListPage.jsx | 9 +- src/domains/vocab/services/vocabService.js | 188 ++-- vite.config.js | 10 +- 24 files changed, 2590 insertions(+), 997 deletions(-) create mode 100644 src/domains/freetalk/hooks/useChatWebSocket.js create mode 100644 src/domains/freetalk/services/chatWebSocketService.js diff --git a/src/App.jsx b/src/App.jsx index 7078471..2109fd4 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -219,7 +219,7 @@ function Dashboard() { const hasChildren = mode.children && mode.children.length > 0 return ( - + handleCardHover(mode.id)} onMouseLeave={handleCardLeave} @@ -467,7 +467,7 @@ function ReportsPage() { {/* 통계 요약 카드 */} - + {isKorean ? '총 학습일' : 'Study Days'} @@ -480,7 +480,7 @@ function ReportsPage() { - + {isKorean ? '학습한 단어' : 'Words Learned'} @@ -493,7 +493,7 @@ function ReportsPage() { - + {isKorean ? '테스트 완료' : 'Tests Taken'} @@ -506,7 +506,7 @@ function ReportsPage() { - + {isKorean ? '평균 점수' : 'Average Score'} @@ -527,7 +527,7 @@ function ReportsPage() { {isKorean ? '연속 학습 기록' : 'Study Streak'} - + - + {languageOptions.map((option) => ( - + setLanguage(option.value)} sx={{ @@ -678,7 +678,7 @@ function SettingsPage() { - + setTtsVoice('FEMALE')} sx={{ @@ -720,7 +720,7 @@ function SettingsPage() { - + setTtsVoice('MALE')} sx={{ diff --git a/src/api/chatApi.js b/src/api/chatApi.js index 193dd13..0c79422 100644 --- a/src/api/chatApi.js +++ b/src/api/chatApi.js @@ -8,9 +8,13 @@ const chatApi = axios.create({ }, }) -// Request interceptor +// Request interceptor - JWT 토큰 자동 추가 chatApi.interceptors.request.use( (config) => { + const token = localStorage.getItem('accessToken') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } return config }, (error) => { @@ -18,11 +22,17 @@ chatApi.interceptors.request.use( } ) -// Response interceptor +// 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) } ) diff --git a/src/api/vocabApi.js b/src/api/vocabApi.js index b18b7f8..8ab1c6f 100644 --- a/src/api/vocabApi.js +++ b/src/api/vocabApi.js @@ -8,9 +8,13 @@ const vocabApi = axios.create({ }, }) -// Request interceptor +// Request interceptor - JWT 토큰 자동 추가 vocabApi.interceptors.request.use( (config) => { + const token = localStorage.getItem('accessToken') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } return config }, (error) => { @@ -18,11 +22,17 @@ vocabApi.interceptors.request.use( } ) -// Response interceptor +// 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) } ) diff --git a/src/contexts/AuthContext.jsx b/src/contexts/AuthContext.jsx index 6098a51..7df6794 100644 --- a/src/contexts/AuthContext.jsx +++ b/src/contexts/AuthContext.jsx @@ -20,11 +20,18 @@ export function AuthProvider({ children }) { checkAuthUser() }, []) - // 현재 인증된 사용자 확인 + // 현재 인증된 사용자 확인 및 토큰 저장 const checkAuthUser = async () => { try { const currentUser = await getCurrentUser() + // Cognito 세션에서 토큰 가져와서 localStorage에 저장 + const session = await fetchAuthSession() + const idToken = session.tokens?.idToken?.toString() + if (idToken) { + localStorage.setItem('accessToken', idToken) + } + setUser({ ...currentUser, email: currentUser.signInDetails?.loginId || currentUser.username, @@ -32,6 +39,7 @@ export function AuthProvider({ children }) { setIsAuthenticated(true) } catch (error) { // 로그인되지 않은 상태 + localStorage.removeItem('accessToken') setUser(null) setIsAuthenticated(false) } finally { @@ -131,6 +139,7 @@ export function AuthProvider({ children }) { const logout = useCallback(async () => { try { await signOut() + localStorage.removeItem('accessToken') setUser(null) setIsAuthenticated(false) return { success: true } diff --git a/src/domains/badge/components/BadgeGrid.jsx b/src/domains/badge/components/BadgeGrid.jsx index 757b49f..9a06e1d 100644 --- a/src/domains/badge/components/BadgeGrid.jsx +++ b/src/domains/badge/components/BadgeGrid.jsx @@ -1,29 +1,72 @@ +import {useRef, useState} from 'react' import {Box, Skeleton, Typography} from '@mui/material' import BadgeCard from './BadgeCard' import {useSettings} from '../../../contexts/SettingsContext' export default function BadgeGrid({badges = [], loading = false, size = 'medium'}) { const {isKorean} = useSettings() + const scrollRef = useRef(null) + const [isDragging, setIsDragging] = useState(false) + const [startX, setStartX] = useState(0) + const [scrollLeft, setScrollLeft] = useState(0) + + // 드래그 스크롤 핸들러 + const handleMouseDown = (e) => { + setIsDragging(true) + setStartX(e.pageX - scrollRef.current.offsetLeft) + setScrollLeft(scrollRef.current.scrollLeft) + } + + const handleMouseLeave = () => { + setIsDragging(false) + } + + const handleMouseUp = () => { + setIsDragging(false) + } + + const handleMouseMove = (e) => { + if (!isDragging) return + e.preventDefault() + const x = e.pageX - scrollRef.current.offsetLeft + const walk = (x - startX) * 1.5 + scrollRef.current.scrollLeft = scrollLeft - walk + } + + // 터치 이벤트 핸들러 + const handleTouchStart = (e) => { + setIsDragging(true) + setStartX(e.touches[0].pageX - scrollRef.current.offsetLeft) + setScrollLeft(scrollRef.current.scrollLeft) + } + + const handleTouchMove = (e) => { + if (!isDragging) return + const x = e.touches[0].pageX - scrollRef.current.offsetLeft + const walk = (x - startX) * 1.5 + scrollRef.current.scrollLeft = scrollLeft - walk + } + + const handleTouchEnd = () => { + setIsDragging(false) + } if (loading) { return ( - {Array.from({length: 12}).map((_, index) => ( - - - + {Array.from({length: 6}).map((_, index) => ( + + + ))} @@ -32,12 +75,7 @@ export default function BadgeGrid({badges = [], loading = false, size = 'medium' if (badges.length === 0) { return ( - + {isKorean ? '배지가 없습니다' : 'No badges available'} @@ -54,20 +92,45 @@ export default function BadgeGrid({badges = [], loading = false, size = 'medium' return ( {sortedBadges.map((badge) => ( - + + + ))} ) diff --git a/src/domains/badge/services/badgeService.js b/src/domains/badge/services/badgeService.js index e449d89..dc3c77d 100644 --- a/src/domains/badge/services/badgeService.js +++ b/src/domains/badge/services/badgeService.js @@ -1,7 +1,7 @@ import badgeApi from '../../../api/badgeApi' -// Mock 데이터 사용 여부 -const USE_MOCK = true +// Mock 데이터 사용 여부 (환경변수로 제어: VITE_USE_MOCK=true) +const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true' // Placeholder 이미지 (실제 S3 이미지가 없을 경우 대비) const PLACEHOLDER_BADGE = 'https://via.placeholder.com/100x100/FFD700/000000?text=Badge' @@ -188,7 +188,10 @@ const withMock = (apiCall, mockData) => { setTimeout(() => resolve(mockData), 500) }) } - return apiCall().catch(() => mockData) + // 실제 API 호출 시 응답의 data 필드 추출 (백엔드 응답: { isSuccess, message, data }) + return apiCall() + .then(response => response.data || response) + .catch(() => mockData) } /** diff --git a/src/domains/chat/services/chatService.js b/src/domains/chat/services/chatService.js index f81918d..21bcab3 100644 --- a/src/domains/chat/services/chatService.js +++ b/src/domains/chat/services/chatService.js @@ -2,8 +2,8 @@ import chatApi from '../../../api/chatApi' const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1' -// Mock 데이터 사용 여부 (true: 목 데이터 사용, false: 실제 API 호출) -const USE_MOCK = true +// Mock 데이터 사용 여부 (환경변수로 제어: VITE_USE_MOCK=true) +const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true' // ============================================ // Mock 데이터 (백엔드 API 응답 형식과 동일) @@ -200,19 +200,13 @@ const mockMessages = { const withMock = (apiCall, mockData) => { if (USE_MOCK) { - // interceptor가 response.data를 반환하므로 동일한 형식으로 반환 - // 실제 API: { success: true, message: ..., data: {...} } - return Promise.resolve({ - success: true, - message: 'Mock data', - data: mockData - }) + // Mock 모드에서는 직접 데이터 반환 + return Promise.resolve(mockData) } - return apiCall().catch(() => ({ - success: true, - message: 'Fallback mock data', - data: mockData - })) + // 실제 API 호출 시 응답의 data 필드 추출 (백엔드 응답: { isSuccess, message, data }) + return apiCall() + .then(response => response.data || response) + .catch(() => mockData) } /** @@ -235,7 +229,7 @@ export const chatRoomService = { lastMessageAt: new Date().toISOString(), } return withMock( - () => chatApi.post('/rooms', {...data, createdBy: TEMP_USER_ID}), + () => chatApi.post('/chat/rooms', {...data, createdBy: TEMP_USER_ID}), newRoom ) }, @@ -254,7 +248,7 @@ export const chatRoomService = { queryParams.append('userId', TEMP_USER_ID) } if (cursor) queryParams.append('cursor', cursor) - return chatApi.get(`/rooms?${queryParams.toString()}`) + return chatApi.get(`/chat/rooms?${queryParams.toString()}`) }, { rooms: mockChatRooms @@ -273,7 +267,7 @@ export const chatRoomService = { // GET /rooms/{roomId} - 채팅방 상세 조회 getDetail: async (roomId) => { return withMock( - () => chatApi.get(`/rooms/${roomId}`), + () => chatApi.get(`/chat/rooms/${roomId}`), mockChatRooms.find(r => r.roomId === roomId) || mockChatRooms[0] ) }, @@ -282,7 +276,7 @@ export const chatRoomService = { join: async (roomId, password) => { const room = mockChatRooms.find(r => r.roomId === roomId) return withMock( - () => chatApi.post(`/rooms/${roomId}/join`, { + () => chatApi.post(`/chat/rooms/${roomId}/join`, { userId: TEMP_USER_ID, ...(password && {password}), }), @@ -301,7 +295,7 @@ export const chatRoomService = { // POST /rooms/{roomId}/leave - 채팅방 퇴장 leave: async (roomId) => { return withMock( - () => chatApi.post(`/rooms/${roomId}/leave`, {userId: TEMP_USER_ID}), + () => chatApi.post(`/chat/rooms/${roomId}/leave`, {userId: TEMP_USER_ID}), {roomId, currentMembers: 2} ) }, @@ -322,7 +316,7 @@ export const messageService = { createdAt: new Date().toISOString(), } return withMock( - () => chatApi.post(`/rooms/${roomId}/messages`, { + () => chatApi.post(`/chat/rooms/${roomId}/messages`, { userId: TEMP_USER_ID, content, messageType, @@ -340,7 +334,7 @@ export const messageService = { const queryParams = new URLSearchParams() queryParams.append('limit', limit) if (cursor) queryParams.append('cursor', cursor) - return chatApi.get(`/rooms/${roomId}/messages?${queryParams.toString()}`) + return chatApi.get(`/chat/rooms/${roomId}/messages?${queryParams.toString()}`) }, { messages: (mockMessages[roomId] || []).slice(0, limit), @@ -358,7 +352,7 @@ export const voiceService = { // POST /voice/synthesize - TTS 변환 synthesize: async (messageId, roomId, voice = 'FEMALE') => { return withMock( - () => chatApi.post('/voice/synthesize', {messageId, roomId, voice}), + () => chatApi.post('/chat/voice/synthesize', {messageId, roomId, voice}), {audioUrl: null, cached: false} ) }, @@ -412,7 +406,7 @@ export const gameService = { } return withMock( - () => chatApi.post(`/rooms/${roomId}/game/start`, {userId: TEMP_USER_ID}), + () => chatApi.post(`/chat/rooms/${roomId}/game/start`, {userId: TEMP_USER_ID}), { gameStatus: mockGameState.gameStatus, currentRound: mockGameState.currentRound, @@ -447,7 +441,7 @@ export const gameService = { } return withMock( - () => chatApi.post(`/rooms/${roomId}/game/stop`, {userId: TEMP_USER_ID}), + () => chatApi.post(`/chat/rooms/${roomId}/game/stop`, {userId: TEMP_USER_ID}), {message: '게임이 종료되었습니다.', scores: finalScores} ) }, @@ -455,7 +449,7 @@ export const gameService = { // GET /rooms/{roomId}/game/status - 게임 상태 조회 getStatus: async (roomId) => { return withMock( - () => chatApi.get(`/rooms/${roomId}/game/status`), + () => chatApi.get(`/chat/rooms/${roomId}/game/status`), { gameStatus: mockGameState.gameStatus, currentRound: mockGameState.currentRound, @@ -480,7 +474,7 @@ export const gameService = { .map(([userId, score], index) => ({rank: index + 1, userId, score})) return withMock( - () => chatApi.get(`/rooms/${roomId}/game/scores`), + () => chatApi.get(`/chat/rooms/${roomId}/game/scores`), {scores: sortedScores, currentRound: mockGameState.currentRound, totalRounds: mockGameState.totalRounds} ) }, @@ -522,7 +516,7 @@ export const gameService = { mockGameState.hintUsed = true return withMock( - () => chatApi.post(`/rooms/${roomId}/game/hint`, {userId: TEMP_USER_ID}), + () => chatApi.post(`/chat/rooms/${roomId}/game/hint`, {userId: TEMP_USER_ID}), {hint, hintUsed: true} ) }, @@ -532,7 +526,7 @@ export const gameService = { if (mockGameState.currentRound >= mockGameState.totalRounds) { mockGameState.gameStatus = 'FINISHED' return withMock( - () => chatApi.post(`/rooms/${roomId}/game/skip`, {userId: TEMP_USER_ID}), + () => chatApi.post(`/chat/rooms/${roomId}/game/skip`, {userId: TEMP_USER_ID}), {gameStatus: 'FINISHED', message: '게임이 종료되었습니다.'} ) } @@ -553,7 +547,7 @@ export const gameService = { } return withMock( - () => chatApi.post(`/rooms/${roomId}/game/skip`, {userId: TEMP_USER_ID}), + () => chatApi.post(`/chat/rooms/${roomId}/game/skip`, {userId: TEMP_USER_ID}), { currentRound: mockGameState.currentRound, currentDrawerId: mockGameState.currentDrawerId, diff --git a/src/domains/freetalk/components/ChatRoomModal.jsx b/src/domains/freetalk/components/ChatRoomModal.jsx index 9d58692..a6b9e7f 100644 --- a/src/domains/freetalk/components/ChatRoomModal.jsx +++ b/src/domains/freetalk/components/ChatRoomModal.jsx @@ -18,6 +18,7 @@ import { } from '@mui/material' import { Chat as ChatIcon, + Circle as CircleIcon, Close as CloseIcon, ExitToApp as ExitToAppIcon, Minimize as MinimizeIcon, @@ -34,21 +35,36 @@ import { gameService, MESSAGE_TYPES, messageService, - TEMP_USER_ID, voiceService } from '../../chat/services/chatService' import {useSettings} from '../../../contexts/SettingsContext' +import {useAuth} from '../../../contexts/AuthContext' import {DESIGN_TOKENS, getChatStyles} from '../../../theme/theme' import GameModePanel from './GameModePanel' +import {useChatWebSocket} from '../hooks/useChatWebSocket' const ChatRoomModal = ({open, onClose, room, onLeave}) => { const theme = useTheme() const isDark = theme.palette.mode === 'dark' const {settings} = useSettings() + const {user} = useAuth() + const currentUserId = user?.userId || user?.username || user?.sub const messagesEndRef = useRef(null) const dragRef = useRef(null) - const [messages, setMessages] = useState([]) + // WebSocket 훅 사용 + const { + isConnected, + messages, + gameState: wsGameState, + error: wsError, + connect: wsConnect, + disconnect: wsDisconnect, + sendMessage: wsSendMessage, + clearError: wsClearError, + setMessages, + } = useChatWebSocket(room?.id, currentUserId) + const [newMessage, setNewMessage] = useState('') const [loading, setLoading] = useState(true) const [sendingMessage, setSendingMessage] = useState(false) @@ -76,7 +92,7 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { userId: msg.userId, messageType: msg.messageType, createdAt: new Date(msg.createdAt), - isOwn: msg.userId === TEMP_USER_ID, + isOwn: msg.userId === currentUserId, })) setMessages(transformedMessages.reverse()) } catch (err) { @@ -101,19 +117,49 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { } }, [room?.id]) + // WebSocket 에러 통합 + useEffect(() => { + if (wsError) { + setError(wsError) + } + }, [wsError]) + + // WebSocket 게임 상태 변경 감지 - 탭 자동 전환 + useEffect(() => { + if (wsGameState?.status === 'PLAYING') { + setGameStatus(GAME_STATUS.PLAYING) + setActiveTab(1) + } else if (wsGameState?.status === 'FINISHED') { + setGameStatus(GAME_STATUS.NONE) + setActiveTab(0) + } + }, [wsGameState?.status]) + // 초기 로드 useEffect(() => { - if (open && room?.id) { + if (open && room?.id && currentUserId) { + console.log('[ChatRoomModal] Initializing...', {roomId: room.id, userId: currentUserId}) setLoading(true) - setMessages([]) setMinimized(false) setActiveTab(0) + + // WebSocket 연결 + wsConnect() + + // 기존 메시지 및 게임 상태 로드 Promise.all([ fetchMessages(), fetchGameStatus(), ]).finally(() => setLoading(false)) } - }, [open, room?.id, fetchMessages, fetchGameStatus]) + + return () => { + if (open && room?.id) { + console.log('[ChatRoomModal] Disconnecting WebSocket...') + wsDisconnect() + } + } + }, [open, room?.id, currentUserId]) // eslint-disable-line react-hooks/exhaustive-deps // 게임 메시지 처리 const handleGameMessage = (gameMessage) => { @@ -158,6 +204,13 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { } }, [messages.length]) + // 탭 전환 시 채팅 탭으로 돌아오면 스크롤 맨 아래로 + useEffect(() => { + if (activeTab === 0 && messages.length > 0 && !loading) { + setTimeout(() => scrollToBottom(true), 100) + } + }, [activeTab]) + // 드래그 핸들러 const handleMouseDown = (e) => { // 버튼, 입력창, 슬라이더, 팝오버 클릭 시 드래그 방지 @@ -199,7 +252,7 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { } }, [isDragging, dragOffset]) - // 메시지 전송 + // 메시지 전송 (WebSocket 사용) const handleSendMessage = async () => { if (!newMessage.trim() || sendingMessage) return @@ -207,25 +260,38 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { const messageContent = newMessage.trim() setNewMessage('') - const tempMessage = { - id: `temp-${Date.now()}`, - content: messageContent, - userId: TEMP_USER_ID, - messageType: 'TEXT', - createdAt: new Date(), - isOwn: true, - } - setMessages((prev) => [...prev, tempMessage]) + // /start, /stop 명령어는 서버 응답(WebSocket game_start/game_end)을 기다림 + // 탭 전환은 useEffect의 wsGameState 감지에서 처리 - try { - await messageService.send(room.id, messageContent) - await fetchMessages() - } catch (err) { - console.error('Failed to send message:', err) - setMessages((prev) => prev.filter((m) => m.id !== tempMessage.id)) - setError('메시지 전송에 실패했습니다') - } finally { + // WebSocket으로 전송 + if (isConnected) { + const success = wsSendMessage(messageContent, 'TEXT') + if (!success) { + setError('메시지 전송에 실패했습니다') + } setSendingMessage(false) + } else { + // WebSocket 연결이 안 된 경우 REST API fallback + const tempMessage = { + id: `temp-${Date.now()}`, + content: messageContent, + userId: currentUserId, + messageType: 'TEXT', + createdAt: new Date(), + isOwn: true, + } + setMessages((prev) => [...prev, tempMessage]) + + try { + await messageService.send(room.id, messageContent) + await fetchMessages() + } catch (err) { + console.error('Failed to send message:', err) + setMessages((prev) => prev.filter((m) => m.id !== tempMessage.id)) + setError('메시지 전송에 실패했습니다') + } finally { + setSendingMessage(false) + } } } @@ -283,10 +349,15 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { } const formatTime = (date) => { + // date가 문자열이거나 Date 객체일 수 있음 + const dateObj = date instanceof Date ? date : new Date(date) + if (isNaN(dateObj.getTime())) { + return '' // 유효하지 않은 날짜는 빈 문자열 반환 + } return new Intl.DateTimeFormat('ko-KR', { hour: '2-digit', minute: '2-digit', - }).format(date) + }).format(dateObj) } if (!open) return null @@ -334,6 +405,14 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { {room?.name || '채팅방'} + {/* 연결 상태 표시 */} + {room?.level && ( { {/* 게임 모드 */} {activeTab === 1 && ( - + )} @@ -525,15 +608,14 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { maxWidth: '70%', }} > - {!message.isOwn && ( - - {message.userId} - - )} + + {message.userId} + { +const GameModePanel = ({roomId, onGameMessage, initialGameStatus}) => { const theme = useTheme() const isDark = theme.palette.mode === 'dark' + const {user} = useAuth() + const currentUserId = user?.userId || user?.username const canvasRef = useRef(null) const [gameState, setGameState] = useState({ - gameStatus: GAME_STATUS.NONE, + gameStatus: initialGameStatus || GAME_STATUS.NONE, currentRound: 0, totalRounds: 5, currentDrawerId: null, @@ -35,7 +38,7 @@ const GameModePanel = ({roomId, onGameMessage}) => { const [brushSize, setBrushSize] = useState(3) const [loading, setLoading] = useState(false) - const isDrawer = gameState.currentDrawerId === TEMP_USER_ID + const isDrawer = gameState.currentDrawerId === currentUserId const isGameActive = gameState.gameStatus === GAME_STATUS.PLAYING // 게임 상태 조회 @@ -43,12 +46,33 @@ const GameModePanel = ({roomId, onGameMessage}) => { try { const response = await gameService.getStatus(roomId) const data = response.data || response - setGameState(data) + setGameState(prev => ({ + ...prev, + ...data, + gameStatus: data.gameStatus || GAME_STATUS.NONE, + })) } catch (err) { console.error('Failed to fetch game status:', err) } }, [roomId]) + // 마운트 시 게임 상태 조회 + useEffect(() => { + if (roomId) { + fetchGameStatus() + } + }, [roomId, fetchGameStatus]) + + // 부모 컴포넌트의 게임 상태 변경 반영 + useEffect(() => { + if (initialGameStatus) { + setGameState(prev => ({ + ...prev, + gameStatus: initialGameStatus, + })) + } + }, [initialGameStatus]) + // 타이머 useEffect(() => { if (!isGameActive || !gameState.roundStartTime) return diff --git a/src/domains/freetalk/hooks/useChatWebSocket.js b/src/domains/freetalk/hooks/useChatWebSocket.js new file mode 100644 index 0000000..51b4ae5 --- /dev/null +++ b/src/domains/freetalk/hooks/useChatWebSocket.js @@ -0,0 +1,277 @@ +import {useCallback, useEffect, useRef, useState} from 'react' +import {chatWebSocketService} from '../services/chatWebSocketService' +import {chatRoomService} from '../../chat/services/chatService' + +/** + * Chat WebSocket 훅 + * 실시간 채팅 및 게임을 위한 상태 관리 + * + * 연결 흐름: + * 1. POST /chat/rooms/{roomId}/join 호출 → roomToken 발급 + * 2. roomToken으로 WebSocket 연결 + */ +export function useChatWebSocket(roomId, userId) { + const [isConnected, setIsConnected] = useState(false) + const [messages, setMessages] = useState([]) + const [gameState, setGameState] = useState(null) + const [error, setError] = useState(null) + + const isConnectedRef = useRef(false) + const roomTokenRef = useRef(null) + + /** + * WebSocket 연결 + */ + const connect = useCallback(async () => { + console.log('[useChatWebSocket] Attempting to connect...', {roomId, userId}) + + if (!roomId || !userId) { + console.error('[useChatWebSocket] roomId and userId are required', {roomId, userId}) + return + } + + try { + setError(null) + + // 1. 먼저 REST API로 roomToken 발급받기 + console.log('[useChatWebSocket] Getting roomToken via join API...') + const joinResponse = await chatRoomService.join(roomId) + const roomToken = joinResponse.roomToken || joinResponse.data?.roomToken + console.log('[useChatWebSocket] Got roomToken:', roomToken ? 'exists' : 'missing') + + if (!roomToken) { + throw new Error('roomToken not received from join API') + } + + roomTokenRef.current = roomToken + + // 콜백 설정 + chatWebSocketService.setCallbacks({ + onMessage: (data) => { + const newMessage = { + id: data.messageId || `msg-${Date.now()}`, + content: data.content, + userId: data.userId, + messageType: data.messageType || 'TEXT', + createdAt: data.createdAt || new Date().toISOString(), + isOwn: data.userId === userId, + } + setMessages((prev) => [...prev, newMessage]) + }, + + onGameStart: (data) => { + setGameState({ + status: 'PLAYING', + currentRound: data.currentRound || 1, + totalRounds: data.totalRounds || 5, + currentDrawerId: data.currentDrawerId, + currentWord: data.currentWord, + roundStartTime: Date.now(), + scores: data.scores || {}, + }) + }, + + onGameEnd: (data) => { + setGameState((prev) => ({ + ...prev, + status: 'FINISHED', + finalScores: data.scores, + })) + }, + + onRoundStart: (data) => { + setGameState((prev) => ({ + ...prev, + currentRound: data.currentRound, + currentDrawerId: data.currentDrawerId, + currentWord: data.currentWord, + roundStartTime: Date.now(), + hintUsed: false, + correctGuessers: [], + })) + }, + + onRoundEnd: (data) => { + setGameState((prev) => ({ + ...prev, + scores: data.scores || prev?.scores, + })) + }, + + onCorrectAnswer: (data) => { + setGameState((prev) => ({ + ...prev, + correctGuessers: [...(prev?.correctGuessers || []), data.userId], + scores: data.scores || prev?.scores, + })) + }, + + onScoreUpdate: (data) => { + setGameState((prev) => ({ + ...prev, + scores: data.scores, + })) + }, + + onHint: (data) => { + setGameState((prev) => ({ + ...prev, + hint: data.hint, + hintUsed: true, + })) + }, + + onDrawing: (data) => { + // 캔버스에 그리기 데이터 적용 (별도 핸들러로 처리) + }, + + onUserJoin: (data) => { + const systemMessage = { + id: `system-${Date.now()}`, + content: `${data.userId}님이 입장했습니다.`, + messageType: 'SYSTEM', + createdAt: new Date().toISOString(), + isSystem: true, + } + setMessages((prev) => [...prev, systemMessage]) + }, + + onUserLeave: (data) => { + const systemMessage = { + id: `system-${Date.now()}`, + content: `${data.userId}님이 퇴장했습니다.`, + messageType: 'SYSTEM', + createdAt: new Date().toISOString(), + isSystem: true, + } + setMessages((prev) => [...prev, systemMessage]) + }, + + onError: (data) => { + setError(data.message || '연결 오류가 발생했습니다') + }, + + onClose: () => { + setIsConnected(false) + isConnectedRef.current = false + }, + }) + + // 2. roomToken으로 WebSocket 연결 + await chatWebSocketService.connect(roomToken, roomId, userId) + setIsConnected(true) + isConnectedRef.current = true + } catch (err) { + console.error('[useChatWebSocket] Connection error:', err) + setError('연결에 실패했습니다') + setIsConnected(false) + isConnectedRef.current = false + } + }, [roomId, userId]) + + /** + * 연결 종료 + */ + const disconnect = useCallback(() => { + chatWebSocketService.disconnect() + setIsConnected(false) + isConnectedRef.current = false + }, []) + + /** + * 메시지 전송 + */ + const sendMessage = useCallback((content, messageType = 'TEXT') => { + if (!isConnectedRef.current) { + setError('연결되지 않았습니다') + return false + } + + const success = chatWebSocketService.sendMessage(content, messageType) + + if (success) { + // Optimistic update - 자신의 메시지 즉시 추가 + const optimisticMessage = { + id: `temp-${Date.now()}`, + content, + userId, + messageType, + createdAt: new Date().toISOString(), + isOwn: true, + isPending: true, + } + setMessages((prev) => [...prev, optimisticMessage]) + } + + return success + }, [userId]) + + /** + * 게임 시작 + */ + const startGame = useCallback(() => { + return chatWebSocketService.startGame() + }, []) + + /** + * 게임 종료 + */ + const stopGame = useCallback(() => { + return chatWebSocketService.stopGame() + }, []) + + /** + * 그리기 데이터 전송 + */ + const sendDrawing = useCallback((drawingData) => { + chatWebSocketService.sendDrawing(drawingData) + }, []) + + /** + * 에러 초기화 + */ + const clearError = useCallback(() => { + setError(null) + }, []) + + /** + * 메시지 초기화 + */ + const clearMessages = useCallback(() => { + setMessages([]) + }, []) + + /** + * 컴포넌트 언마운트 시 정리 + */ + useEffect(() => { + return () => { + if (isConnectedRef.current) { + chatWebSocketService.disconnect() + } + } + }, []) + + return { + // 상태 + isConnected, + messages, + gameState, + error, + + // 액션 + connect, + disconnect, + sendMessage, + startGame, + stopGame, + sendDrawing, + clearError, + clearMessages, + + // 유틸 + setMessages, + } +} + +export default useChatWebSocket diff --git a/src/domains/freetalk/pages/ChatRoomPage.jsx b/src/domains/freetalk/pages/ChatRoomPage.jsx index 77f9d75..0e21178 100644 --- a/src/domains/freetalk/pages/ChatRoomPage.jsx +++ b/src/domains/freetalk/pages/ChatRoomPage.jsx @@ -15,12 +15,15 @@ import { } from '@mui/material' import { ArrowBack as ArrowBackIcon, + Circle as CircleIcon, ExitToApp as ExitToAppIcon, Refresh as RefreshIcon, Send as SendIcon, VolumeUp as VolumeUpIcon, } from '@mui/icons-material' -import {chatRoomService, messageService, TEMP_USER_ID, voiceService} from '../../chat/services/chatService' +import {chatRoomService, messageService, voiceService} from '../../chat/services/chatService' +import {useAuth} from '../../../contexts/AuthContext' +import {useChatWebSocket} from '../hooks/useChatWebSocket' const levelColors = { beginner: {bg: '#e8f5e9', color: '#2e7d32', label: '초급'}, @@ -31,17 +34,34 @@ const levelColors = { const ChatRoomPage = () => { const {roomId} = useParams() const navigate = useNavigate() + const {user} = useAuth() + const currentUserId = user?.userId || user?.username || user?.sub + + // 디버깅: 사용자 정보 확인 + console.log('[ChatRoomPage] User info:', {user, currentUserId, roomId}) + const messagesEndRef = useRef(null) const messagesContainerRef = useRef(null) const [room, setRoom] = useState(null) - const [messages, setMessages] = useState([]) const [newMessage, setNewMessage] = useState('') const [loading, setLoading] = useState(true) - const [sendingMessage, setSendingMessage] = useState(false) const [error, setError] = useState(null) const [playingTTS, setPlayingTTS] = useState(null) + // WebSocket 훅 사용 + const { + isConnected, + messages, + gameState, + error: wsError, + connect: wsConnect, + disconnect: wsDisconnect, + sendMessage: wsSendMessage, + clearError: wsClearError, + setMessages, + } = useChatWebSocket(roomId, currentUserId) + // 채팅방 정보 조회 const fetchRoomDetail = useCallback(async () => { try { @@ -60,25 +80,28 @@ const ChatRoomPage = () => { } }, [roomId]) - // 메시지 목록 조회 + // 기존 메시지 목록 조회 (초기 로드용) const fetchMessages = useCallback(async () => { try { const response = await messageService.getList(roomId, {limit: 50}) - const transformedMessages = (response.messages || []).map((msg) => ({ - id: msg.messageId, + const transformedMessages = (response.messages || []).map((msg, index) => ({ + id: msg.messageId || `msg-${index}-${Date.now()}`, content: msg.content, userId: msg.userId, messageType: msg.messageType, createdAt: new Date(msg.createdAt), - isOwn: msg.userId === TEMP_USER_ID, + isOwn: msg.userId === currentUserId, })) - // 오래된 메시지가 위에 오도록 정렬 - setMessages(transformedMessages.reverse()) + // 중복 제거 후 정렬 + const uniqueMessages = transformedMessages.filter( + (msg, index, self) => index === self.findIndex(m => m.id === msg.id) + ) + setMessages(uniqueMessages.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))) } catch (err) { console.error('Failed to fetch messages:', err) setError('메시지를 불러오는데 실패했습니다') } - }, [roomId]) + }, [roomId, currentUserId, setMessages]) // 초기 로드 useEffect(() => { @@ -88,7 +111,20 @@ const ChatRoomPage = () => { setLoading(false) } loadData() - }, [fetchRoomDetail, fetchMessages]) + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + // WebSocket 연결 (별도 effect) + useEffect(() => { + if (currentUserId && roomId) { + console.log('[ChatRoomPage] Connecting WebSocket...', {roomId, currentUserId}) + wsConnect() + } + + return () => { + console.log('[ChatRoomPage] Disconnecting WebSocket...') + wsDisconnect() + } + }, [roomId, currentUserId]) // eslint-disable-line react-hooks/exhaustive-deps // 스크롤 맨 아래로 const scrollToBottom = () => { @@ -99,36 +135,42 @@ const ChatRoomPage = () => { scrollToBottom() }, [messages]) - // 메시지 전송 + // 에러 통합 + useEffect(() => { + if (wsError) { + setError(wsError) + } + }, [wsError]) + + // 메시지 전송 (WebSocket 사용) const handleSendMessage = async () => { - if (!newMessage.trim() || sendingMessage) return + if (!newMessage.trim()) return - setSendingMessage(true) const messageContent = newMessage.trim() setNewMessage('') - // Optimistic update - const tempMessage = { - id: `temp-${Date.now()}`, - content: messageContent, - userId: TEMP_USER_ID, - messageType: 'TEXT', - createdAt: new Date(), - isOwn: true, - } - setMessages((prev) => [...prev, tempMessage]) + // WebSocket으로 전송 + if (isConnected) { + wsSendMessage(messageContent, 'TEXT') + } else { + // WebSocket 연결이 안 된 경우 REST API fallback + const tempMessage = { + id: `temp-${Date.now()}`, + content: messageContent, + userId: currentUserId, + messageType: 'TEXT', + createdAt: new Date(), + isOwn: true, + } + setMessages((prev) => [...prev, tempMessage]) - try { - await messageService.send(roomId, messageContent) - // 전송 성공 시 메시지 새로고침 - await fetchMessages() - } catch (err) { - console.error('Failed to send message:', err) - // 실패 시 임시 메시지 제거 - setMessages((prev) => prev.filter((m) => m.id !== tempMessage.id)) - setError('메시지 전송에 실패했습니다') - } finally { - setSendingMessage(false) + try { + await messageService.send(roomId, messageContent) + } catch (err) { + console.error('Failed to send message:', err) + setMessages((prev) => prev.filter((m) => m.id !== tempMessage.id)) + setError('메시지 전송에 실패했습니다') + } } } @@ -163,6 +205,7 @@ const ChatRoomPage = () => { // 채팅방 퇴장 const handleLeaveRoom = async () => { try { + wsDisconnect() await chatRoomService.leave(roomId) navigate('/freetalk/people') } catch (err) { @@ -176,12 +219,18 @@ const ChatRoomPage = () => { fetchMessages() } + // 에러 닫기 + const handleCloseError = () => { + setError(null) + wsClearError() + } + // 시간 포맷 const formatTime = (date) => { return new Intl.DateTimeFormat('ko-KR', { hour: '2-digit', minute: '2-digit', - }).format(date) + }).format(new Date(date)) } if (loading) { @@ -201,9 +250,18 @@ const ChatRoomPage = () => { - - {room?.name || '채팅방'} - + + + {room?.name || '채팅방'} + + {/* 연결 상태 표시 */} + + {room?.level && ( { {room?.currentMembers}/{room?.maxMembers}명 + + {isConnected ? '실시간' : '오프라인'} + @@ -233,11 +294,19 @@ const ChatRoomPage = () => { {/* 에러 메시지 */} {error && ( - setError(null)} sx={{m: 1}}> + {error} )} + {/* 게임 상태 표시 */} + {gameState?.status === 'PLAYING' && ( + + 🎮 게임 진행 중! 라운드 {gameState.currentRound}/{gameState.totalRounds} + {gameState.currentDrawerId === currentUserId && ` - 제시어: ${gameState.currentWord}`} + + )} + {/* 메시지 영역 */} { 아직 메시지가 없습니다. 첫 메시지를 보내보세요! + + /start - 게임 시작 | /stop - 게임 종료 + ) : ( messages.map((message) => ( @@ -267,71 +339,83 @@ const ChatRoomPage = () => { gap: 1, }} > - {/* 아바타 (상대방만) */} - {!message.isOwn && ( - - {message.userId?.charAt(0)?.toUpperCase() || 'U'} - - )} - - - {/* 사용자 이름 (상대방만) */} - {!message.isOwn && ( - - {message.userId} + {/* 시스템 메시지 */} + {message.isSystem ? ( + + + {message.content} - )} - - {/* 메시지 버블 */} - - {message.isOwn && ( - - {formatTime(message.createdAt)} - + + ) : ( + <> + {/* 아바타 (상대방만) */} + {!message.isOwn && ( + + {message.userId?.charAt(0)?.toUpperCase() || 'U'} + )} - - - {message.content} - - + {/* 사용자 이름 (상대방만) */} + {!message.isOwn && ( + + {message.userId} + + )} - {!message.isOwn && ( - - handlePlayTTS(message.id)} - disabled={playingTTS === message.id} - sx={{p: 0.5}} + {/* 메시지 버블 */} + + {message.isOwn && ( + + {formatTime(message.createdAt)} + + )} + + - {playingTTS === message.id ? ( - - ) : ( - - )} - - - {formatTime(message.createdAt)} - + + {message.content} + + + + {!message.isOwn && ( + + handlePlayTTS(message.id)} + disabled={playingTTS === message.id} + sx={{p: 0.5}} + > + {playingTTS === message.id ? ( + + ) : ( + + )} + + + {formatTime(message.createdAt)} + + + )} - )} - - + + + )} )) )} @@ -351,7 +435,7 @@ const ChatRoomPage = () => { > setNewMessage(e.target.value)} onKeyPress={handleKeyPress} @@ -367,7 +451,7 @@ const ChatRoomPage = () => { { '&:disabled': {bgcolor: 'grey.300'}, }} > - {sendingMessage ? : } + diff --git a/src/domains/freetalk/pages/FreetalkPeoplePage.jsx b/src/domains/freetalk/pages/FreetalkPeoplePage.jsx index 3d8ee47..66ca4f7 100644 --- a/src/domains/freetalk/pages/FreetalkPeoplePage.jsx +++ b/src/domains/freetalk/pages/FreetalkPeoplePage.jsx @@ -97,9 +97,18 @@ const FreetalkPeoplePage = () => { const transformedRooms = (responseData.rooms || []).map(transformRoomData) if (isLoadMore) { - setRooms((prev) => [...prev, ...transformedRooms]) + // 중복 제거 + setRooms((prev) => { + const existingIds = new Set(prev.map(r => r.id)) + const newRooms = transformedRooms.filter(r => !existingIds.has(r.id)) + return [...prev, ...newRooms] + }) } else { - setRooms(transformedRooms) + // 중복 제거 + const uniqueRooms = transformedRooms.filter( + (room, index, self) => index === self.findIndex(r => r.id === room.id) + ) + setRooms(uniqueRooms) } setCursor(responseData.nextCursor || null) @@ -131,7 +140,11 @@ const FreetalkPeoplePage = () => { const response = await chatRoomService.getList(params) const responseData = response.data || response const transformedRooms = (responseData.rooms || []).map(transformRoomData) - setRooms(transformedRooms) + // 중복 제거 + const uniqueRooms = transformedRooms.filter( + (room, index, self) => index === self.findIndex(r => r.id === room.id) + ) + setRooms(uniqueRooms) setCursor(responseData.nextCursor || null) setHasMore(!!responseData.nextCursor) } catch (err) { @@ -177,7 +190,11 @@ const FreetalkPeoplePage = () => { const response = await chatRoomService.getList(params) const responseData = response.data || response const transformedRooms = (responseData.rooms || []).map(transformRoomData) - setRooms(transformedRooms) + // 중복 제거 + const uniqueRooms = transformedRooms.filter( + (room, index, self) => index === self.findIndex(r => r.id === room.id) + ) + setRooms(uniqueRooms) setCursor(responseData.nextCursor || null) setHasMore(!!responseData.nextCursor) } catch (err) { @@ -308,11 +325,8 @@ const FreetalkPeoplePage = () => { {filteredRooms.map((room, index) => ( diff --git a/src/domains/freetalk/services/chatWebSocketService.js b/src/domains/freetalk/services/chatWebSocketService.js new file mode 100644 index 0000000..6624cee --- /dev/null +++ b/src/domains/freetalk/services/chatWebSocketService.js @@ -0,0 +1,366 @@ +/** + * Chat WebSocket Service + * 실시간 채팅 및 게임 명령을 위한 WebSocket 서비스 + * + * 연결 흐름: + * 1. REST API로 채팅방 입장: POST /chat/rooms/{roomId}/join → roomToken 발급 + * 2. roomToken으로 WebSocket 연결: wss://...?roomToken={roomToken} + */ + +const WS_URL = import.meta.env.VITE_WS_URL || + 'wss://t378dif43l.execute-api.ap-northeast-2.amazonaws.com/dev' + +/** + * Chat WebSocket 연결 클래스 + */ +class ChatWebSocketConnection { + constructor() { + this.ws = null + this.callbacks = {} + this.isConnected = false + this.reconnectAttempts = 0 + this.maxReconnectAttempts = 5 + this.reconnectDelay = 1000 + this.roomId = null + this.userId = null + this.roomToken = null + } + + /** + * WebSocket 연결 + * @param {string} roomToken - POST /chat/rooms/{roomId}/join에서 발급받은 roomToken + * @param {string} roomId - 채팅방 ID + * @param {string} userId - 사용자 ID + */ + connect(roomToken, roomId, userId) { + return new Promise((resolve, reject) => { + try { + this.roomId = roomId + this.userId = userId + this.roomToken = roomToken + + // roomToken 파라미터로 연결 (token이 아님!) + const url = roomToken ? `${WS_URL}?roomToken=${roomToken}` : WS_URL + console.log('[ChatWebSocket] Connecting to:', url) + this.ws = new WebSocket(url) + + this.ws.onopen = () => { + this.isConnected = true + this.reconnectAttempts = 0 + console.log('[ChatWebSocket] Connected') + + // 연결 후 방 입장 메시지 전송 + if (roomId) { + this.joinRoom(roomId) + } + + resolve() + } + + this.ws.onclose = (event) => { + this.isConnected = false + console.log('[ChatWebSocket] Disconnected:', event.code) + this.callbacks.onClose?.(event) + + // 비정상 종료 시 재연결 시도 (roomToken 만료 시 재발급 필요할 수 있음) + if (event.code !== 1000 && this.reconnectAttempts < this.maxReconnectAttempts) { + this.attemptReconnect(roomToken, roomId, userId) + } + } + + this.ws.onerror = (error) => { + console.error('[ChatWebSocket] Error:', error) + this.callbacks.onError?.({type: 'error', message: 'WebSocket connection error'}) + reject(error) + } + + this.ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data) + this.handleMessage(data) + } catch (e) { + console.error('[ChatWebSocket] Parse error:', e) + } + } + } catch (error) { + reject(error) + } + }) + } + + /** + * 재연결 시도 + */ + attemptReconnect(roomToken, roomId, userId) { + this.reconnectAttempts++ + console.log(`[ChatWebSocket] Reconnecting... (${this.reconnectAttempts}/${this.maxReconnectAttempts})`) + + setTimeout(() => { + this.connect(roomToken, roomId, userId).catch(() => { + console.error('[ChatWebSocket] Reconnect failed') + }) + }, this.reconnectDelay * this.reconnectAttempts) + } + + /** + * 메시지 핸들링 + */ + handleMessage(data) { + const {type, messageType} = data + + // 메시지 타입에 따라 콜백 호출 + switch (type || messageType) { + case 'message': + case 'TEXT': + this.callbacks.onMessage?.(data) + break + case 'game_start': + this.callbacks.onGameStart?.(data) + break + case 'game_end': + this.callbacks.onGameEnd?.(data) + break + case 'round_start': + this.callbacks.onRoundStart?.(data) + break + case 'round_end': + this.callbacks.onRoundEnd?.(data) + break + case 'drawing': + this.callbacks.onDrawing?.(data) + break + case 'correct_answer': + this.callbacks.onCorrectAnswer?.(data) + break + case 'score_update': + this.callbacks.onScoreUpdate?.(data) + break + case 'hint': + this.callbacks.onHint?.(data) + break + case 'user_join': + this.callbacks.onUserJoin?.(data) + break + case 'user_leave': + this.callbacks.onUserLeave?.(data) + break + case 'system_command': + // 시스템 명령어 응답 (예: /member, /help 등) + this.callbacks.onMessage?.(data) + break + case 'error': + this.callbacks.onError?.(data) + break + default: + console.log('[ChatWebSocket] Unknown message type:', type || messageType, data) + this.callbacks.onMessage?.(data) + } + } + + /** + * 방 입장 + */ + joinRoom(roomId) { + this.send({ + action: 'joinRoom', + roomId, + userId: this.userId, + }) + } + + /** + * 방 퇴장 + */ + leaveRoom(roomId) { + this.send({ + action: 'leaveRoom', + roomId, + userId: this.userId, + }) + } + + /** + * 메시지 전송 + */ + sendMessage(content, messageType = 'TEXT') { + if (!this.isConnected || !this.ws) { + console.error('[ChatWebSocket] Not connected') + return false + } + + this.send({ + action: 'sendMessage', + roomId: this.roomId, + userId: this.userId, + content, + messageType, + }) + return true + } + + /** + * 게임 시작 명령 + */ + startGame() { + return this.sendMessage('/start', 'TEXT') + } + + /** + * 게임 종료 명령 + */ + stopGame() { + return this.sendMessage('/stop', 'TEXT') + } + + /** + * 그리기 데이터 전송 + */ + sendDrawing(drawingData) { + this.send({ + action: 'sendDrawing', + roomId: this.roomId, + userId: this.userId, + drawingData, + }) + } + + /** + * WebSocket 메시지 전송 + */ + send(data) { + if (!this.isConnected || !this.ws) { + console.error('[ChatWebSocket] Not connected') + return + } + + this.ws.send(JSON.stringify(data)) + } + + /** + * 콜백 설정 + */ + setCallbacks(callbacks) { + this.callbacks = callbacks + } + + /** + * 연결 종료 + */ + disconnect() { + if (this.roomId) { + this.leaveRoom(this.roomId) + } + + if (this.ws) { + this.ws.close(1000, 'Client disconnect') + this.ws = null + } + this.isConnected = false + this.roomId = null + this.userId = null + } + + /** + * 연결 상태 확인 + */ + getConnectionState() { + if (!this.ws) return 'disconnected' + + switch (this.ws.readyState) { + case WebSocket.CONNECTING: + return 'connecting' + case WebSocket.OPEN: + return 'connected' + case WebSocket.CLOSING: + return 'closing' + case WebSocket.CLOSED: + return 'disconnected' + default: + return 'unknown' + } + } +} + +// 싱글톤 인스턴스 +let wsInstance = null + +/** + * Chat WebSocket 서비스 + */ +export const chatWebSocketService = { + /** + * 인스턴스 가져오기 (싱글톤) + */ + getInstance() { + if (!wsInstance) { + wsInstance = new ChatWebSocketConnection() + } + return wsInstance + }, + + /** + * 연결 + */ + async connect(token, roomId, userId) { + const instance = this.getInstance() + return instance.connect(token, roomId, userId) + }, + + /** + * 메시지 전송 + */ + sendMessage(content, messageType = 'TEXT') { + const instance = this.getInstance() + return instance.sendMessage(content, messageType) + }, + + /** + * 게임 시작 + */ + startGame() { + const instance = this.getInstance() + return instance.startGame() + }, + + /** + * 게임 종료 + */ + stopGame() { + const instance = this.getInstance() + return instance.stopGame() + }, + + /** + * 그리기 데이터 전송 + */ + sendDrawing(drawingData) { + const instance = this.getInstance() + return instance.sendDrawing(drawingData) + }, + + /** + * 콜백 설정 + */ + setCallbacks(callbacks) { + const instance = this.getInstance() + instance.setCallbacks(callbacks) + }, + + /** + * 연결 종료 + */ + disconnect() { + const instance = this.getInstance() + instance.disconnect() + }, + + /** + * 연결 상태 + */ + getConnectionState() { + const instance = this.getInstance() + return instance.getConnectionState() + }, +} + +export default chatWebSocketService diff --git a/src/domains/grammar/components/ChatMessage.jsx b/src/domains/grammar/components/ChatMessage.jsx index 5703418..aa2524e 100644 --- a/src/domains/grammar/components/ChatMessage.jsx +++ b/src/domains/grammar/components/ChatMessage.jsx @@ -30,6 +30,8 @@ export default function ChatMessage({ correctedContent, grammarScore, errors = [], + feedback, + isCorrect, aiResponse, conversationTip, } = message @@ -176,6 +178,23 @@ export default function ChatMessage({ ))} + + {/* Feedback */} + {feedback && ( + + + 💡 {feedback} + + + )} diff --git a/src/domains/grammar/pages/WritingPage.jsx b/src/domains/grammar/pages/WritingPage.jsx index 1f479ef..a484cea 100644 --- a/src/domains/grammar/pages/WritingPage.jsx +++ b/src/domains/grammar/pages/WritingPage.jsx @@ -124,17 +124,22 @@ export default function WritingPage() { if (response.session) { setLevel(response.session.level || GRAMMAR_LEVELS.BEGINNER) } - // Convert messages to our format - const formattedMessages = (response.messages || []).map((msg) => ({ - id: msg.messageId, - content: msg.content, - correctedContent: msg.correctedContent, - grammarScore: msg.grammarScore, - errors: msg.errorsJson ? JSON.parse(msg.errorsJson) : [], - aiResponse: msg.role === 'ASSISTANT' ? msg.content : null, - isUser: msg.role === 'USER', - createdAt: msg.createdAt, - })) + // Convert messages to our format and sort by createdAt (oldest first) + const formattedMessages = (response.messages || []) + .map((msg) => ({ + id: msg.messageId, + content: msg.content, + correctedContent: msg.correctedContent, + grammarScore: msg.grammarScore, + errors: msg.errors || (msg.errorsJson ? JSON.parse(msg.errorsJson) : []), + feedback: msg.feedback || null, + isCorrect: msg.isCorrect, + aiResponse: msg.role === 'ASSISTANT' ? msg.content : null, + conversationTip: msg.conversationTip || null, + isUser: msg.role === 'USER', + createdAt: msg.createdAt, + })) + .sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)) setMessages(formattedMessages) } catch (err) { console.error('Failed to load session messages:', err) diff --git a/src/domains/grammar/services/grammarService.js b/src/domains/grammar/services/grammarService.js index 30de7fa..cfc1f00 100644 --- a/src/domains/grammar/services/grammarService.js +++ b/src/domains/grammar/services/grammarService.js @@ -1,7 +1,7 @@ import grammarApi from '../../../api/grammarApi' -// Mock 데이터 사용 여부 (true: 목 데이터 사용, false: 실제 API 호출) -const USE_MOCK = true +// Mock 데이터 사용 여부 (환경변수로 제어: VITE_USE_MOCK=true) +const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true' // ============================================ // Mock 데이터 @@ -116,7 +116,10 @@ const withMock = (apiCall, mockData) => { setTimeout(() => resolve(mockData), 800) }) } - return apiCall().catch(() => mockData) + // 실제 API 호출 시 응답의 data 필드 추출 (백엔드 응답: { isSuccess, message, data }) + return apiCall() + .then(response => response.data || response) + .catch(() => mockData) } /** diff --git a/src/domains/grammar/services/grammarStreamService.js b/src/domains/grammar/services/grammarStreamService.js index 0d311da..f9f56c8 100644 --- a/src/domains/grammar/services/grammarStreamService.js +++ b/src/domains/grammar/services/grammarStreamService.js @@ -7,8 +7,8 @@ const WS_URL = import.meta.env.VITE_GRAMMAR_WS_URL || 'wss://placeholder.execute-api.ap-northeast-2.amazonaws.com/dev' -// Mock 모드 (WebSocket 서버가 없을 때 테스트용) -const USE_MOCK = true +// Mock 모드 (환경변수로 제어: VITE_USE_MOCK=true) +const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true' const MOCK_DELAY = 50 // 토큰 간 딜레이 (ms) /** diff --git a/src/domains/vocab/pages/DailyLearning.jsx b/src/domains/vocab/pages/DailyLearning.jsx index dd2b4d9..311a29a 100644 --- a/src/domains/vocab/pages/DailyLearning.jsx +++ b/src/domains/vocab/pages/DailyLearning.jsx @@ -33,8 +33,115 @@ import FlashCard from '../components/FlashCard' import {dailyService, userWordService, voiceService} from '../services/vocabService' import {LEVEL_LABELS, LEVELS} from '../constants/vocabConstants' import {useTranslation} from '../../../contexts/SettingsContext' +import {useAuth} from '../../../contexts/AuthContext' -const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1' +// 카드 셔플 애니메이션 컴포넌트 +function ShuffleAnimation({count, isKorean}) { + return ( + + {/* 카드 셔플 애니메이션 */} + + {[...Array(Math.min(count, 5))].map((_, i) => ( + + + 📚 + + + ))} + + + {/* 텍스트 */} + + {isKorean ? '카드를 섞는 중...' : 'Shuffling cards...'} + + + {isKorean + ? `${count}개의 단어를 다시 학습합니다` + : `Reviewing ${count} words again` + } + + + ) +} // Level Selection Screen function LevelSelect({onSelect, loading, t, isKorean}) { @@ -142,6 +249,8 @@ function LevelSelect({onSelect, loading, t, isKorean}) { export default function DailyLearning() { const navigate = useNavigate() const {t, isKorean} = useTranslation() + const {user} = useAuth() + const userId = user?.userId || user?.username const [phase, setPhase] = useState('loading') const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -154,7 +263,13 @@ export default function DailyLearning() { const [results, setResults] = useState({correct: 0, incorrect: 0}) const [swipeDirection, setSwipeDirection] = useState(null) const [isEntering, setIsEntering] = useState(false) + const [unknownWords, setUnknownWords] = useState([]) // "몰라요" 선택한 단어들 + const [totalWordCount, setTotalWordCount] = useState(0) // 전체 단어 수 (진행률 계산용) + const [isShuffling, setIsShuffling] = useState(false) // 셔플 애니메이션 상태 + const [shuffleCount, setShuffleCount] = useState(0) // 셔플할 단어 수 + // 마운트 시 먼저 level 없이 시도 (기존 학습이 있으면 성공) + // 실패하면 level 선택 화면으로 이동 useEffect(() => { fetchDailyWords() }, []) @@ -164,7 +279,7 @@ export default function DailyLearning() { setLoading(true) setError(null) - const response = await dailyService.getWords(TEMP_USER_ID, level) + const response = await dailyService.getWords(level) const dailyData = response?.data || response const allWords = [ @@ -179,6 +294,7 @@ export default function DailyLearning() { } setWords(allWords) + setTotalWordCount(allWords.length) // 전체 단어 수 저장 const learnedCount = dailyData?.learnedCount || 0 if (learnedCount > 0 && learnedCount < allWords.length) { @@ -212,7 +328,10 @@ export default function DailyLearning() { } const currentWord = words[currentIndex] - const progress = words.length > 0 ? (learnedIds.size / words.length) * 100 : 0 + // 진행률: 학습 완료된 단어 / 전체 단어 수 + const progress = totalWordCount > 0 ? (learnedIds.size / totalWordCount) * 100 : 0 + // 현재 라운드에서 남은 단어 (몰라요 단어 재학습 시 표시용) + const remainingInRound = words.length - currentIndex const playTTS = useCallback(async (word) => { if (!word || isPlayingTTS) return @@ -248,17 +367,19 @@ export default function DailyLearning() { setSwipeDirection(isCorrect ? 'right' : 'left') - try { - await userWordService.update(TEMP_USER_ID, currentWord.wordId, isCorrect) - - setResults(prev => ({ - ...prev, - [isCorrect ? 'correct' : 'incorrect']: prev[isCorrect ? 'correct' : 'incorrect'] + 1 - })) - - setLearnedIds(prev => new Set([...prev, currentWord.wordId])) - } catch (err) { - console.error('Answer update error:', err) + if (isCorrect) { + // "알아요" 선택 - API 호출하고 학습 완료 처리 + try { + await dailyService.markLearned(currentWord.wordId) + setLearnedIds(prev => new Set([...prev, currentWord.wordId])) + setResults(prev => ({...prev, correct: prev.correct + 1})) + } catch (err) { + console.error('Answer update error:', err) + } + } else { + // "몰라요" 선택 - API 호출 X, 나중에 다시 학습하도록 저장 + setUnknownWords(prev => [...prev, currentWord]) + setResults(prev => ({...prev, incorrect: prev.incorrect + 1})) } setTimeout(() => { @@ -269,20 +390,68 @@ export default function DailyLearning() { }, 250) } + // 배열 섞기 함수 + const shuffleArray = (array) => { + const shuffled = [...array] + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]] + } + return shuffled + } + const moveToNext = () => { setIsFlipped(false) if (currentIndex < words.length - 1) { + // 다음 단어로 이동 setCurrentIndex(prev => prev + 1) + } else if (unknownWords.length > 0) { + // 현재 리스트 끝 + "몰라요" 단어 있음 → 셔플 애니메이션 후 다시 학습 + setShuffleCount(unknownWords.length) + setIsShuffling(true) + setTimeout(() => { + const shuffled = shuffleArray(unknownWords) + setWords(shuffled) + setUnknownWords([]) + setCurrentIndex(0) + setIsShuffling(false) + }, 1500) // 1.5초 애니메이션 } else { + // 모든 단어 "알아요" 완료 → 학습 완료 setPhase('complete') } } + // 건너뛰기 - "몰라요"와 동일하게 처리 + const handleSkip = () => { + if (!currentWord || swipeDirection || isShuffling) return + + setIsFlipped(false) + + if (currentIndex < words.length - 1) { + // 다음 단어가 있으면 현재 단어를 unknownWords에 추가하고 다음으로 + setUnknownWords(prev => [...prev, currentWord]) + setCurrentIndex(prev => prev + 1) + } else { + // 마지막 단어 건너뛰기 → 모든 unknown 단어 + 현재 단어로 셔플 + const allUnknown = [...unknownWords, currentWord] + setShuffleCount(allUnknown.length) + setIsShuffling(true) + setTimeout(() => { + const shuffled = shuffleArray(allUnknown) + setWords(shuffled) + setUnknownWords([]) + setCurrentIndex(0) + setIsShuffling(false) + }, 1500) + } + } + const handleToggleBookmark = async () => { if (!currentWord) return try { const newBookmarked = !currentWord.bookmarked - await userWordService.updateTag(TEMP_USER_ID, currentWord.wordId, { + await userWordService.updateTag(userId, currentWord.wordId, { bookmarked: newBookmarked, }) setWords(prev => @@ -296,11 +465,14 @@ export default function DailyLearning() { } const handleRestart = () => { + // 처음부터 다시 시작 - 단어 목록 다시 가져오기 setCurrentIndex(0) setLearnedIds(new Set()) + setUnknownWords([]) setIsFlipped(false) - setPhase('learning') setResults({correct: 0, incorrect: 0}) + setPhase('loading') + fetchDailyWords() // 단어 다시 로드 } // Loading Screen @@ -442,7 +614,16 @@ export default function DailyLearning() { // Learning Screen return ( - + <> + {/* 셔플 애니메이션 오버레이 */} + {isShuffling && ( + + )} + + {/* Header */} { - if (currentIndex < words.length - 1) { - setCurrentIndex(prev => prev + 1) - setIsFlipped(false) - } else { - setPhase('complete') - } - }} + onClick={handleSkip} + disabled={isShuffling} endIcon={} sx={{color: 'text.secondary'}} > - {currentIndex === words.length - 1 ? t('dailyLearning.finish') : t('dailyLearning.skip')} + {t('dailyLearning.skip')} + ) } diff --git a/src/domains/vocab/pages/StatsPage.jsx b/src/domains/vocab/pages/StatsPage.jsx index 0c6ed67..813862f 100644 --- a/src/domains/vocab/pages/StatsPage.jsx +++ b/src/domains/vocab/pages/StatsPage.jsx @@ -13,75 +13,181 @@ import { ListItem, ListItemText, Paper, - Tab, - Tabs, - Tooltip, Typography, } from '@mui/material' import { ArrowBack as BackIcon, - CalendarMonth as CalendarIcon, + CheckCircle as CheckIcon, + EmojiEvents as TrophyIcon, + LocalFireDepartment as FireIcon, + MenuBook as BookIcon, + Quiz as QuizIcon, + School as SchoolIcon, + Timeline as TimelineIcon, TrendingUp as TrendingUpIcon, + VolumeUp as VolumeIcon, Warning as WarningIcon, } from '@mui/icons-material' import {statsService, voiceService} from '../services/vocabService' -import {DIFFICULTY_LABELS, LEVEL_COLORS, LEVEL_LABELS, VOICE_TYPES,} from '../constants/vocabConstants' +import {DIFFICULTY_LABELS, LEVEL_COLORS, LEVEL_LABELS, VOICE_TYPES} from '../constants/vocabConstants' import {useTranslation} from '../../../contexts/SettingsContext' +import {useAuth} from '../../../contexts/AuthContext' import {BadgeSection} from '../../badge' -const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1' - -// 학습 캘린더 히트맵 컴포넌트 +// 학습 캘린더 히트맵 컴포넌트 (GitHub 스타일) function LearningCalendar({data}) { + const [hoveredDay, setHoveredDay] = useState(null) + const [tooltipPos, setTooltipPos] = useState({x: 0, y: 0}) + const today = new Date() + const todayStr = today.toISOString().split('T')[0] + + // 12주(84일) 전부터 오늘까지 const startDate = new Date(today) - startDate.setDate(startDate.getDate() - 83) // 12주 전 + startDate.setDate(startDate.getDate() - 83) + + // 시작일을 해당 주의 일요일로 조정 + const startDayOfWeek = startDate.getDay() + startDate.setDate(startDate.getDate() - startDayOfWeek) const weeks = [] + const monthLabels = [] let currentDate = new Date(startDate) + let lastMonth = -1 - // 12주 데이터 생성 - for (let w = 0; w < 12; w++) { + // 주별로 데이터 생성 + while (currentDate <= today || weeks.length < 13) { const week = [] for (let d = 0; d < 7; d++) { const dateStr = currentDate.toISOString().split('T')[0] - const dayData = data?.find(d => d.date === dateStr) + // 백엔드는 "period" 필드를 사용, 폴백으로 "date"도 지원 + const dayData = data?.find(item => (item.period || item.date) === dateStr) + const isFuture = currentDate > today + + // 월 라벨 추가 (각 주의 첫 날이 새 달이면) + if (d === 0 && currentDate.getMonth() !== lastMonth && !isFuture) { + const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'] + monthLabels.push({ + month: monthNames[currentDate.getMonth()], + weekIndex: weeks.length, + }) + lastMonth = currentDate.getMonth() + } + + // 백엔드는 newWordsLearned 사용, 폴백으로 learnedCount도 지원 + const count = dayData?.newWordsLearned || dayData?.learnedCount || 0 + week.push({ date: dateStr, - count: dayData?.learnedCount || 0, - isToday: dateStr === today.toISOString().split('T')[0], + count, + isToday: dateStr === todayStr, + isFuture, + dayOfWeek: d, }) currentDate.setDate(currentDate.getDate() + 1) } weeks.push(week) + if (weeks.length >= 14) break } - const getColor = (count) => { + // GitHub 스타일 색상 (초록 계열) + const getColor = (count, isFuture) => { + if (isFuture) return 'transparent' if (count === 0) return '#ebedf0' - if (count < 20) return '#9be9a8' - if (count < 40) return '#40c463' - if (count < 55) return '#30a14e' + if (count < 5) return '#9be9a8' + if (count < 15) return '#40c463' + if (count < 30) return '#30a14e' return '#216e39' } + const getLevel = (count) => { + if (count === 0) return 0 + if (count < 5) return 1 + if (count < 15) return 2 + if (count < 30) return 3 + return 4 + } + const dayLabels = ['일', '월', '화', '수', '목', '금', '토'] + const formatDate = (dateStr) => { + const date = new Date(dateStr) + const year = date.getFullYear() + const month = date.getMonth() + 1 + const day = date.getDate() + const weekday = dayLabels[date.getDay()] + return `${year}년 ${month}월 ${day}일 (${weekday})` + } + + const handleMouseEnter = (e, day) => { + if (day.isFuture) return + const rect = e.target.getBoundingClientRect() + setTooltipPos({ + x: rect.left + rect.width / 2, + y: rect.top - 8, + }) + setHoveredDay(day) + } + + // 총 학습량 계산 (백엔드는 newWordsLearned 사용) + const totalLearned = data?.reduce((sum, item) => sum + (item.newWordsLearned || item.learnedCount || 0), 0) || 0 + const activeDays = data?.filter(item => (item.newWordsLearned || item.learnedCount || 0) > 0).length || 0 + return ( - - + + {/* 요약 정보 */} + + + 최근 12주간 {totalLearned}개 단어 학습 + + + + + {/* 월 라벨 */} + + {monthLabels.map((label, idx) => ( + + {label.month} + + ))} + + + {/* 캘린더 그리드 */} + {/* 요일 라벨 */} - + {dayLabels.map((label, idx) => ( {idx % 2 === 1 ? label : ''} @@ -89,85 +195,180 @@ function LearningCalendar({data}) { ))} - {/* 히트맵 그리드 */} + {/* 주별 셀 */} {weeks.map((week, wIdx) => ( - + {week.map((day, dIdx) => ( - - - + onMouseEnter={(e) => handleMouseEnter(e, day)} + onMouseLeave={() => setHoveredDay(null)} + sx={{ + width: 12, + height: 12, + backgroundColor: getColor(day.count, day.isFuture), + borderRadius: '2px', + border: day.isToday + ? '2px solid #10b981' + : day.isFuture + ? 'none' + : '1px solid rgba(27, 31, 35, 0.06)', + cursor: day.isFuture ? 'default' : 'pointer', + transition: 'all 0.15s ease', + boxSizing: 'border-box', + '&:hover': day.isFuture ? {} : { + transform: 'scale(1.2)', + borderColor: 'rgba(27, 31, 35, 0.15)', + boxShadow: '0 1px 3px rgba(0,0,0,0.12)', + }, + }} + /> ))} ))} {/* 범례 */} - - 적음 - {[0, 10, 30, 45, 55].map((count, idx) => ( - - ))} - 많음 + + + 오늘 학습하셨나요? + + + Less + {[0, 1, 2, 3, 4].map((level) => ( + + ))} + More + + + {/* 호버 툴팁 */} + {hoveredDay && ( + + + {hoveredDay.count > 0 + ? `${hoveredDay.count}개 단어 학습` + : '학습 기록 없음'} + + + {formatDate(hoveredDay.date)} + + + )} ) } -// 취약 단어 목록 컴포넌트 +// 복습 필요 단어 목록 컴포넌트 function WeakWordsList({words, onPlayTTS, playingWordId}) { if (!words || words.length === 0) { return ( - - 취약 단어가 없습니다 - + + + + 모든 단어를 잘 학습했어요! + + + 복습이 필요한 단어가 없습니다 + + ) } return ( - {words.map((item, index) => ( + {words.slice(0, 5).map((item, index) => ( + + + {index + 1} + + {item.english} - + {item.incorrectCount > 0 && ( + + {item.incorrectCount}회 오답 + + )} } secondary={item.korean} @@ -176,11 +377,12 @@ function WeakWordsList({words, onPlayTTS, playingWordId}) { size="small" onClick={() => onPlayTTS?.(item)} disabled={playingWordId === item.wordId} + sx={{ + backgroundColor: '#f3f4f6', + '&:hover': {backgroundColor: '#e5e7eb'}, + }} > - + ))} @@ -192,29 +394,57 @@ function WeakWordsList({words, onPlayTTS, playingWordId}) { function LevelProgressChart({data}) { if (!data) return null + const levelConfig = { + BEGINNER: {icon: '🌱', color: '#10b981', bgColor: '#ecfdf5'}, + INTERMEDIATE: {icon: '🌿', color: '#f97316', bgColor: '#fff7ed'}, + ADVANCED: {icon: '🌳', color: '#ef4444', bgColor: '#fef2f2'}, + } + return ( - + {Object.entries(LEVEL_LABELS).map(([level, label]) => { const levelData = data[level] || {total: 0, learned: 0} - const progress = levelData.total > 0 - ? (levelData.learned / levelData.total) * 100 - : 0 + const progress = levelData.total > 0 ? (levelData.learned / levelData.total) * 100 : 0 + const config = levelConfig[level] return ( - - - - {label} - - - {levelData.learned}/{levelData.total} + + + + + {config.icon} + + + {label} + + + + {levelData.learned} / {levelData.total} ) @@ -229,83 +459,129 @@ function DifficultyChart({data}) { const total = Object.values(data).reduce((sum, val) => sum + val, 0) - const colors = { - EASY: '#4caf50', - NORMAL: '#2196f3', - HARD: '#ff9800', + const config = { + EASY: {label: '쉬움', color: '#10b981', bgColor: '#ecfdf5', icon: '😊'}, + NORMAL: {label: '보통', color: '#3b82f6', bgColor: '#eff6ff', icon: '🤔'}, + HARD: {label: '어려움', color: '#ef4444', bgColor: '#fef2f2', icon: '😰'}, } return ( - - {/* 막대 그래프 */} - - {Object.entries(DIFFICULTY_LABELS).map(([key, label]) => { - const count = data[key] || 0 - const height = total > 0 ? (count / total) * 100 : 0 - - return ( - - - {count} - - - - {label} - - - ) - })} - + + {Object.entries(DIFFICULTY_LABELS).map(([key]) => { + const count = data[key] || 0 + const percentage = total > 0 ? ((count / total) * 100).toFixed(0) : 0 + const cfg = config[key] + + return ( + + {cfg.icon} + + {count} + + + {cfg.label} ({percentage}%) + + + ) + })} ) } -// 통계 요약 카드 -function StatCard({title, value, subtitle, icon: Icon, color}) { +// 히어로 통계 카드 +function HeroStatCard({icon: Icon, label, value, subValue, color, bgGradient}) { return ( - + - - {title} + + {label} - + {value} - {subtitle && ( - - {subtitle} + {subValue && ( + + {subValue} )} - {Icon && ( - - - - )} + + + ) } +// 미니 통계 카드 +function MiniStatCard({icon: Icon, label, value, color, bgColor}) { + return ( + + + + + + + {value} + + + {label} + + + + ) +} + export default function StatsPage() { const navigate = useNavigate() - const {t} = useTranslation() - const [tab, setTab] = useState(0) // 0: 일간, 1: 주간, 2: 월간 + const {t, isKorean} = useTranslation() + const {user} = useAuth() const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -323,26 +599,29 @@ export default function StatsPage() { fetchAllStats() }, []) - useEffect(() => { - fetchPeriodStats() - }, [tab]) - const fetchAllStats = async () => { try { setLoading(true) setError(null) const [overviewRes, dailyRes, weakRes] = await Promise.all([ - statsService.getOverall(TEMP_USER_ID), - statsService.getDaily(TEMP_USER_ID, {limit: 84}), - statsService.getWeakness(TEMP_USER_ID), + statsService.getOverall(), + statsService.getDaily(null, {limit: 84}), + statsService.getWeakness(), ]) - setOverviewStats(overviewRes?.data) - setCalendarData(dailyRes?.data?.dailyStats || []) - setWeakWords(weakRes?.data?.weakWords || []) - setLevelProgress(overviewRes?.data?.levelProgress) - setDifficultyDist(overviewRes?.data?.difficultyDistribution) + // API 응답 데이터 접근 (data 필드 또는 직접 접근) + const overview = overviewRes?.data || overviewRes + const daily = dailyRes?.data || dailyRes + const weak = weakRes?.data || weakRes + + setOverviewStats(overview) + // 다양한 응답 형식 지원: history, dailyStats, 또는 배열 자체 + const calendarHistory = daily?.history || daily?.dailyStats || (Array.isArray(daily) ? daily : []) + setCalendarData(calendarHistory) + setWeakWords(weak?.frequentMistakes || weak?.weakWords || weak?.weakestWords || []) + setLevelProgress(overview?.levelProgress) + setDifficultyDist(overview?.difficultyDistribution) } catch (err) { console.error('Fetch stats error:', err) setError('통계를 불러오는데 실패했습니다.') @@ -351,19 +630,6 @@ export default function StatsPage() { } } - const fetchPeriodStats = async () => { - // 기간별 통계는 getDaily로 처리 - try { - const limits = [7, 30, 90] // 일간, 주간, 월간 - const response = await statsService.getDaily(TEMP_USER_ID, { - limit: limits[tab], - }) - // 기간별 통계 처리 - } catch (err) { - console.error('Period stats error:', err) - } - } - const handlePlayTTS = async (word) => { if (playingWordId) return @@ -392,117 +658,189 @@ export default function StatsPage() { return ( - + ) } + // 데이터 추출 + const totalLearned = overviewStats?.totalLearned || overviewStats?.newWordsLearned || 0 + const successRate = overviewStats?.successRate || overviewStats?.averageAccuracy || 0 + const currentStreak = overviewStats?.currentStreak || overviewStats?.streakDays || 0 + const longestStreak = overviewStats?.longestStreak || currentStreak + const testsCompleted = overviewStats?.testsCompleted || 0 + const correctAnswers = overviewStats?.correctAnswers || 0 + const incorrectAnswers = overviewStats?.incorrectAnswers || 0 + const wordsReviewed = overviewStats?.wordsReviewed || 0 + return ( - + {/* 헤더 */} - navigate('/vocab')}> + navigate('/vocab')} + sx={{ + backgroundColor: '#f3f4f6', + '&:hover': {backgroundColor: '#e5e7eb'}, + }} + > - - {t('stats.title')} - + + + 학습 통계 + + + {user?.username || '사용자'}님의 학습 현황 + + {error && ( - setError(null)}> + setError(null)}> {error} )} - {/* 기간 탭 */} - setTab(v)} - sx={{mb: 3}} - variant="fullWidth" - > - - - - - - {/* 요약 카드 */} + {/* 히어로 섹션 - 핵심 통계 */} - - + - - + - - + - - + - {/* 학습 캘린더 */} - - - {t('stats.learningHistory')} + {/* 추가 통계 미니 카드 */} + + + 상세 통계 - + + + + + + + + + + + + + + - {/* 레벨별 진행률 */} - - - {t('stats.levelProgress')} - - + {/* 학습 캘린더 */} + + + + + 학습 기록 + + + {/* 난이도 분포 */} - - - {t('stats.difficultyDist')} - - - + {difficultyDist && ( + + + 체감 난이도 분포 + + + + )} - {/* 취약 단어 */} - - - - {t('stats.weakWordsTop10')} + {/* 레벨별 진행률 */} + {levelProgress && ( + + + 레벨별 학습 진행률 - navigate('/vocab/daily?mode=weak')} - /> + + + )} + + {/* 복습이 필요한 단어 */} + + + + + + 복습이 필요한 단어 + + + {weakWords.length > 0 && ( + navigate('/vocab/daily?mode=weak')} + /> + )} { try { - const response = await testService.getResults(TEMP_USER_ID, {limit: 5}) + const response = await testService.getResults(userId, {limit: 5}) setRecentResults(response?.testResults || []) } catch (err) { console.error('Fetch results error:', err) @@ -508,7 +509,7 @@ export default function TestPage() { try { setLoading(true) setError(null) - const response = await testService.start(TEMP_USER_ID, 'DAILY') + const response = await testService.start(userId, 'DAILY') const testData = response?.data || response if (testData?.testId) { @@ -554,7 +555,7 @@ export default function TestPage() { answer: answers[q.wordId] || '', })) - const response = await testService.submit(TEMP_USER_ID, testId, answersArray) + const response = await testService.submit(userId, testId, answersArray) const resultData = response?.data || response if (resultData) { diff --git a/src/domains/vocab/pages/VocabDashboard.jsx b/src/domains/vocab/pages/VocabDashboard.jsx index 7667110..2df84b0 100644 --- a/src/domains/vocab/pages/VocabDashboard.jsx +++ b/src/domains/vocab/pages/VocabDashboard.jsx @@ -16,26 +16,30 @@ import { Typography, } from '@mui/material' import { + ArrowForward as ArrowIcon, CheckCircle as CheckIcon, - EmojiEvents as TrophyIcon, LocalFireDepartment as FireIcon, MenuBook as VocabIcon, PlayArrow as PlayIcon, Quiz as TestIcon, + School as LearnIcon, Star as StarIcon, StarBorder as StarBorderIcon, + Timeline as StatsIcon, TrendingUp as TrendingIcon, VolumeUp as VolumeIcon, + Warning as WarningIcon, } from '@mui/icons-material' import {dailyService, statsService, userWordService, voiceService} from '../services/vocabService' -import {DAILY_GOAL, LEVEL_LABELS,} from '../constants/vocabConstants' +import {DAILY_GOAL} from '../constants/vocabConstants' import {useTranslation} from '../../../contexts/SettingsContext' - -const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1' +import {useAuth} from '../../../contexts/AuthContext' export default function VocabDashboard() { const navigate = useNavigate() const {t, isKorean} = useTranslation() + const {user} = useAuth() + const userId = user?.userId || user?.username const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [dailyData, setDailyData] = useState(null) @@ -54,16 +58,20 @@ export default function VocabDashboard() { setError(null) const [daily, stats, weekly, weakness] = await Promise.all([ - dailyService.getWords(TEMP_USER_ID).catch(() => null), - statsService.getOverall(TEMP_USER_ID).catch(() => null), - statsService.getDaily(TEMP_USER_ID, {limit: 7}).catch(() => null), - statsService.getWeakness(TEMP_USER_ID).catch(() => null), + dailyService.getWords().catch(() => null), + statsService.getOverall().catch(() => null), + statsService.getDaily(null, {limit: 7}).catch(() => null), + statsService.getWeakness().catch(() => null), ]) setDailyData(daily) setStatsData(stats) - setWeeklyStats(weekly?.dailyStats || []) - setWeakWords(weakness?.weakestWords?.slice(0, 5) || []) + // API: { data: { history: [...] } } 또는 mock: { history: [...] } + const weeklyData = weekly?.data || weekly + setWeeklyStats(weeklyData?.history || weeklyData?.dailyStats || []) + // API: { data: { frequentMistakes: [...] } } 또는 mock: { frequentMistakes: [...] } + const weaknessData = weakness?.data || weakness + setWeakWords(weaknessData?.frequentMistakes?.slice(0, 5) || weaknessData?.weakestWords?.slice(0, 5) || []) } catch (err) { console.error('Dashboard fetch error:', err) setError('Failed to load data.') @@ -81,6 +89,8 @@ export default function VocabDashboard() { audio.onended = () => setPlayingTTS(null) audio.onerror = () => setPlayingTTS(null) await audio.play() + } else { + setPlayingTTS(null) } } catch (err) { console.error('TTS error:', err) @@ -90,7 +100,7 @@ export default function VocabDashboard() { const handleToggleBookmark = async (word) => { try { - await userWordService.updateTag(TEMP_USER_ID, word.wordId, { + await userWordService.updateTag(userId, word.wordId, { bookmarked: !word.bookmarked, }) setWeakWords((prev) => @@ -113,36 +123,45 @@ export default function VocabDashboard() { ) } - const learnedCount = dailyData?.learnedCount || 0 - const totalWords = dailyData?.totalWords || DAILY_GOAL.TOTAL - const progress = totalWords > 0 ? (learnedCount / totalWords) * 100 : 0 - const newWordsCount = dailyData?.newWords?.length || 0 - const reviewWordsCount = dailyData?.reviewWords?.length || 0 + // API 응답 구조: { status, message, data: { dailyStudy, progress, newWords, reviewWords } } + // 또는 mock: { dailyStudy, progress, newWords, reviewWords } + const daily = dailyData?.data || dailyData + const learnedCount = daily?.progress?.learned || daily?.dailyStudy?.learnedCount || 0 + const totalWords = daily?.progress?.total || daily?.dailyStudy?.totalWords || DAILY_GOAL.TOTAL + const progress = daily?.progress?.percentage ?? (totalWords > 0 ? (learnedCount / totalWords) * 100 : 0) + const isCompleted = daily?.progress?.isCompleted || daily?.dailyStudy?.isCompleted || false + + // 통계 데이터 (API: { status, data: {...} } 또는 mock: {...}) + const stats = statsData?.data || statsData + const currentStreak = stats?.currentStreak || stats?.streakDays || 0 + const longestStreak = stats?.longestStreak || 0 + const wordsLearned = stats?.newWordsLearned || stats?.totalLearned || 0 + const successRate = stats?.successRate || stats?.averageAccuracy || 0 + const testsCompleted = stats?.testsCompleted || stats?.testCount || 0 - // Calculate streak from weekly stats - const streak = weeklyStats.filter(s => s?.isCompleted).length + // 주간 통계에서 완료일 수 계산 + const weeklyCompleted = weeklyStats.filter(s => s?.isCompleted).length return ( {/* Header */} - - + + - + - + {t('vocabDash.title')} @@ -153,504 +172,539 @@ export default function VocabDashboard() { {error && ( - + {error} )} - {/* Hero Progress Card */} - - {/* Decorative Elements */} - - - - - - - + {/* 오늘의 학습 카드 */} + + + + + + + + {isKorean ? '오늘의 학습' : "Today's Learning"} + + + + {Math.round(progress)} + + % + + + {isCompleted && ( + } + label={isKorean ? '완료' : 'Done'} + sx={{ + backgroundColor: 'white', + color: '#059669', + fontWeight: 700, + }} + /> + )} + + + + + + {learnedCount} / {totalWords} {isKorean ? '단어' : 'words'} + + + {totalWords - learnedCount} {isKorean ? '남음' : 'left'} + + + + + + + + + + + {/* 연속 학습 카드 */} + + + + + + {currentStreak} - - {Math.round(progress)}% + + {isKorean ? '일 연속 학습' : 'Day Streak'} - + + + {isKorean ? '최장 기록' : 'Best'}: {longestStreak} {isKorean ? '일' : 'days'} + + + + + + - {streak > 0 && ( + {/* 통계 요약 카드 4개 */} + + + navigate('/vocab/stats')} + > + - - - - {streak} - - - {t('vocabDash.days')} - - + - )} - - - - - - {learnedCount} / {totalWords} {t('vocabDash.wordsLearned')} + + {wordsLearned} - - - - - - - - {isKorean ? '새 단어' : 'New Words'} + + {isKorean ? '학습한 단어' : 'Words Learned'} - - {newWordsCount} / {DAILY_GOAL.NEW_WORDS} - - - - - {isKorean ? '복습' : 'Review'} - - - {reviewWordsCount} / {DAILY_GOAL.REVIEW_WORDS} - - - - - - - + + + - {/* Quick Actions */} - - + navigate('/vocab/stats')} > - + - + - - {t('vocabDash.viewStats')} + + {successRate.toFixed?.(0) || 0}% + + + {isKorean ? '정답률' : 'Accuracy'} - - {statsData?.totalWords || 0} + + + + + + navigate('/vocab/test')} + > + + + + + + {testsCompleted} - - {t('vocabDash.wordsLearned')} + + {isKorean ? '테스트 완료' : 'Tests Done'} - + + + + + navigate('/vocab/words')} + > + + + > + + + + {weeklyCompleted}/7 + + + {isKorean ? '이번주 완료' : 'This Week'} + + - + {/* 빠른 액션 */} + + navigate('/vocab/test')} > - + - + - - {t('vocabDash.takeQuiz')} - - - {statsData?.avgSuccessRate?.toFixed(0) || 0}% - - - {isKorean ? '평균 점수' : 'average score'} - - + + + {isKorean ? '퀴즈 풀기' : 'Take Quiz'} + + + {isKorean ? '실력을 테스트해보세요' : 'Test your knowledge'} + + + - + navigate('/vocab/words')} > - + - + - - {t('vocabDash.viewWordList')} - - - {statsData?.wordStatusCounts?.MASTERED || 0} - - - {isKorean ? '마스터' : 'mastered'} - - + + {isKorean ? '단어장' : 'Word List'} + + + {isKorean ? '학습한 단어 보기' : 'View your words'} + + + + + + + + + navigate('/vocab/stats')} + > + + + > + + + + + {isKorean ? '학습 통계' : 'Statistics'} + + + {isKorean ? '상세 통계 보기' : 'View detailed stats'} + + + - {/* Weekly Progress */} - + {/* 주간 학습 현황 */} + - - {t('vocabDash.weeklyProgress')} - - - {(isKorean ? ['월', '화', '수', '목', '금', '토', '일'] : ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']).map((day, index) => { - const stat = weeklyStats[index] - const isCompleted = stat?.isCompleted - const hasProgress = stat?.learnedCount > 0 - const isToday = index === new Date().getDay() - 1 || (new Date().getDay() === 0 && index === 6) + + + {isKorean ? '이번주 학습 현황' : 'This Week'} + + = 7 ? 'success' : 'default'} + /> + - return ( - - - {day} - + + {(() => { + const days = isKorean ? ['월', '화', '수', '목', '금', '토', '일'] : ['M', 'T', 'W', 'T', 'F', 'S', 'S'] + const today = new Date() + const dayOfWeek = today.getDay() + const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek + + return days.map((day, index) => { + const date = new Date(today) + date.setDate(today.getDate() + mondayOffset + index) + const dateStr = date.toISOString().split('T')[0] + + // 해당 날짜의 통계 찾기 + const stat = weeklyStats.find(s => s?.period === dateStr || s?.date === dateStr) + const isCompleted = stat?.isCompleted + const hasProgress = (stat?.newWordsLearned || stat?.learnedCount || 0) > 0 + const isToday = date.toDateString() === today.toDateString() + const isFuture = date > today + + return ( - {isCompleted ? ( - + + {day} + + + {date.getDate()} + + + {isCompleted ? ( - - ) : hasProgress ? ( - + ) : hasProgress ? ( - {stat?.learnedCount} + {stat?.newWordsLearned || stat?.learnedCount || 0} - - ) : ( - - )} + ) : isFuture ? null : ( + + X + + )} + - - ) - })} + ) + }) + })()} - {/* Weak Words */} + {/* 취약 단어 */} {weakWords.length > 0 && ( - + + - {t('vocabDash.focusWords')} + {isKorean ? '복습이 필요한 단어' : 'Words to Review'} - + - - {isKorean ? '추가 연습이 필요한 단어입니다' : 'These words need extra attention'} - - {weakWords.map((word, index) => ( - - - - + + {weakWords.map((word) => ( + + + {word.english} + + {word.korean} + + + + + + handlePlayTTS(word)} + disabled={playingTTS === word.wordId} + > + + + + + handleToggleBookmark(word)}> + {word.bookmarked ? ( + + ) : ( + + )} + + - - {word.korean} - - - - - - handlePlayTTS(word)} - disabled={playingTTS === word.wordId} - sx={{ - backgroundColor: playingTTS === word.wordId ? 'primary.main' : 'transparent', - '&:hover': {backgroundColor: 'rgba(5, 150, 105, 0.1)'}, - }} - > - - - - - handleToggleBookmark(word)}> - {word.bookmarked ? ( - - ) : ( - - )} - - - - - ))} + ))} + )} diff --git a/src/domains/vocab/pages/WordListPage.jsx b/src/domains/vocab/pages/WordListPage.jsx index e9c82c8..530fd6b 100644 --- a/src/domains/vocab/pages/WordListPage.jsx +++ b/src/domains/vocab/pages/WordListPage.jsx @@ -28,8 +28,7 @@ import WordDetailModal from '../components/WordDetailModal' import {myWordService, voiceService} from '../services/vocabService' import {LEVEL_LABELS, WORD_STATUS_LABELS,} from '../constants/vocabConstants' import {useTranslation} from '../../../contexts/SettingsContext' - -const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1' +import {useAuth} from '../../../contexts/AuthContext' const PAGE_SIZE = 20 // 디바운스 훅 @@ -47,6 +46,8 @@ function useDebounce(value, delay) { export default function WordListPage() { const navigate = useNavigate() const {t} = useTranslation() + const {user} = useAuth() + const userId = user?.userId || user?.username const [searchParams] = useSearchParams() const observerRef = useRef(null) const loadMoreRef = useRef(null) @@ -87,7 +88,7 @@ export default function WordListPage() { params.incorrectOnly = true } - const response = await myWordService.getList(TEMP_USER_ID, params) + const response = await myWordService.getList(userId, params) const data = response?.data || response const newWords = data?.userWords || [] @@ -168,7 +169,7 @@ export default function WordListPage() { const newBookmarked = !word.bookmarked try { - await myWordService.toggleBookmark(TEMP_USER_ID, word.wordId, newBookmarked) + await myWordService.toggleBookmark(userId, word.wordId, newBookmarked) setUserWords(prev => prev.map(w => diff --git a/src/domains/vocab/services/vocabService.js b/src/domains/vocab/services/vocabService.js index 613b1d7..32843f5 100644 --- a/src/domains/vocab/services/vocabService.js +++ b/src/domains/vocab/services/vocabService.js @@ -1,7 +1,7 @@ import vocabApi from '../../../api/vocabApi' -// Mock 데이터 사용 여부 (true: 목 데이터 사용, false: 실제 API 호출) -const USE_MOCK = true +// Mock 데이터 사용 여부 (환경변수로 제어: VITE_USE_MOCK=true) +const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true' // ============================================ // Mock 데이터 @@ -235,7 +235,10 @@ const withMock = (apiCall, mockData) => { // interceptor가 response.data를 반환하므로 mockData를 직접 반환 return Promise.resolve(mockData) } - return apiCall().catch(() => mockData) + // 실제 API 호출 시 응답의 data 필드 추출 (백엔드 응답: { isSuccess, message, data }) + return apiCall() + .then(response => response.data || response) + .catch(() => mockData) } /** @@ -245,7 +248,7 @@ export const wordService = { // GET /words - 단어 목록 조회 getList: ({level, category, limit = 20, cursor} = {}) => withMock( - () => vocabApi.get('/words', {params: {level, category, limit, cursor}}), + () => vocabApi.get('/vocab/words', {params: {level, category, limit, cursor}}), { words: mockWords.filter(w => (!level || w.level === level) && (!category || w.category === category)).slice(0, limit), hasMore: false, @@ -256,7 +259,7 @@ export const wordService = { // GET /words - 단어 목록 조회 (별칭) getWords: (params) => withMock( - () => vocabApi.get('/words', {params}), + () => vocabApi.get('/vocab/words', {params}), {words: mockWords, hasMore: false} ), @@ -284,14 +287,14 @@ export const wordService = { // POST /words/batch - 배치 단어 생성 createBatch: (words) => withMock( - () => vocabApi.post('/words/batch', {words}), + () => vocabApi.post('/vocab/words/batch', {words}), {successCount: words.length, failCount: 0, totalRequested: words.length} ), // POST /words/batch/get - 배치 단어 조회 getBatch: (wordIds) => withMock( - () => vocabApi.post('/words/batch/get', {wordIds}), + () => vocabApi.post('/vocab/words/batch/get', {wordIds}), { words: mockWords.filter(w => wordIds.includes(w.wordId)), requestedCount: wordIds.length, @@ -301,31 +304,45 @@ export const wordService = { } /** - * 일일 학습 API - Backend: POST /daily-study/record, GET /user-words/review + * 일일 학습 API - Backend: GET /vocab/daily?level={level}, POST /vocab/daily/words/{wordId}/learned + * userId는 토큰에서 추출됨 */ export const dailyService = { - // 일일 학습용 단어 조회 (새 단어 + 복습 단어) - getWords: (userId, level) => + // GET /vocab/daily?level={level} - 오늘의 학습 단어 조회 + // 첫 호출 시 자동으로 생성됨 + getWords: (level) => withMock( - () => vocabApi.get('/user-words/review', {params: {userId, ...(level ? {level} : {})}}), + () => vocabApi.get('/vocab/daily', {params: {level: level?.toUpperCase()}}), { - newWords: mockWords.filter(w => !level || w.level === level).slice(0, 10), + dailyStudy: { + date: new Date().toISOString().split('T')[0], + totalWords: 55, + learnedCount: 0, + isCompleted: false, + }, + newWords: mockWords.filter(w => !level || w.level === level.toUpperCase()).slice(0, 50), reviewWords: mockUserWords.filter(w => w.status === 'REVIEWING').slice(0, 5), - learnedCount: 0, - isCompleted: false, + progress: { + total: 55, + learned: 0, + remaining: 55, + percentage: 0, + isCompleted: false, + }, } ), - // POST /daily-study/record - 일일 학습 기록 - markLearned: (userId, wordId, isCorrect, studyType = 'REVIEW') => + // POST /vocab/daily/words/{wordId}/learned - 단어 학습 완료 표시 + // body 필요 없음 (userId는 토큰에서 추출) + markLearned: (wordId) => withMock( - () => vocabApi.post('/daily-study/record', {userId, wordId, isCorrect, studyType}), + () => vocabApi.post(`/vocab/daily/words/${wordId}/learned`), { - userId, - date: new Date().toISOString().split('T')[0], - wordsStudied: 1, - correctCount: isCorrect ? 1 : 0, - incorrectCount: isCorrect ? 0 : 1, + total: 55, + learned: 1, + remaining: 54, + percentage: 1.82, + isCompleted: false, } ), } @@ -337,7 +354,7 @@ export const userWordService = { // GET /user-words/review - 복습 예정 단어 조회 getList: (userId, {status, limit = 20, cursor, date} = {}) => withMock( - () => vocabApi.get('/user-words/review', {params: {userId, status, limit, cursor, date}}), + () => vocabApi.get('/vocab/user-words', {params: {userId, status, limit, cursor, date}}), { userWords: mockUserWords.filter(w => !status || w.status === status).slice(0, limit), hasMore: false, @@ -348,14 +365,14 @@ export const userWordService = { // GET /user-words/review - 사용자 단어 조회 (별칭) getUserWords: (userId, params) => withMock( - () => vocabApi.get('/user-words/review', {params: {userId, ...params}}), + () => vocabApi.get('/vocab/user-words', {params: {userId, ...params}}), {words: mockUserWords, hasMore: false} ), - // POST /user-words/{wordId}/review - 사용자 단어 학습 업데이트 + // PUT /user-words/{wordId} - 사용자 단어 학습 업데이트 update: (userId, wordId, isCorrect) => withMock( - () => vocabApi.post(`/user-words/${wordId}/review`, {userId, isCorrect}), + () => vocabApi.put(`/vocab/user-words/${wordId}`, {userId, isCorrect}), { userId, wordId, @@ -371,16 +388,18 @@ export const userWordService = { ), // PATCH /user-words/{wordId}/tag - 사용자 단어 태그 업데이트 + // userId는 토큰에서 추출되므로 body에 포함하지 않음 updateTag: (userId, wordId, {bookmarked, favorite, difficulty}) => withMock( - () => vocabApi.patch(`/user-words/${wordId}/tag`, {userId, bookmarked, favorite, difficulty}), + () => vocabApi.patch(`/vocab/user-words/${wordId}/tag`, {bookmarked, favorite, difficulty}), {success: true, userId, wordId, bookmarked, favorite, difficulty} ), // PATCH /user-words/{wordId}/tag - 사용자 단어 업데이트 (별칭) + // userId는 토큰에서 추출되므로 body에 포함하지 않음 updateUserWord: (userId, wordId, data) => withMock( - () => vocabApi.patch(`/user-words/${wordId}/tag`, {userId, ...data}), + () => vocabApi.patch(`/vocab/user-words/${wordId}/tag`, data), {success: true, ...data} ), } @@ -392,7 +411,7 @@ export const myWordService = { // GET /user-words/review - 나의 단어 목록 (필터링) getList: (userId, {bookmarked, incorrectOnly, limit = 20, cursor} = {}) => withMock( - () => vocabApi.get('/user-words/review', { + () => vocabApi.get('/vocab/user-words', { params: {userId, bookmarked, incorrectOnly, limit, cursor} }), { @@ -406,21 +425,22 @@ export const myWordService = { // 북마크된 단어 조회 getBookmarked: (userId, {limit = 20, cursor} = {}) => withMock( - () => vocabApi.get('/user-words/review', {params: {userId, bookmarked: true, limit, cursor}}), + () => vocabApi.get('/vocab/user-words', {params: {userId, bookmarked: true, limit, cursor}}), {userWords: mockUserWords.filter(w => w.bookmarked).slice(0, limit), hasMore: false} ), // 오답 단어 조회 getIncorrect: (userId, {limit = 20, cursor} = {}) => withMock( - () => vocabApi.get('/user-words/review', {params: {userId, incorrectOnly: true, limit, cursor}}), + () => vocabApi.get('/vocab/user-words', {params: {userId, incorrectOnly: true, limit, cursor}}), {userWords: mockUserWords.filter(w => w.incorrectCount > 0).slice(0, limit), hasMore: false} ), // PATCH /user-words/{wordId}/tag - 북마크 토글 + // userId는 토큰에서 추출되므로 body에 포함하지 않음 toggleBookmark: (userId, wordId, bookmarked) => withMock( - () => vocabApi.patch(`/user-words/${wordId}/tag`, {userId, bookmarked}), + () => vocabApi.patch(`/vocab/user-words/${wordId}/tag`, {bookmarked}), {success: true, wordId, bookmarked} ), } @@ -432,7 +452,7 @@ export const testService = { // POST /tests/start - 시험 시작 start: (userId, testType = 'DAILY', wordCount = 20, level) => withMock( - () => vocabApi.post('/tests/start', {userId, testType, wordCount, level}), + () => vocabApi.post('/vocab/test/start', {userId, testType, wordCount, level}), { testId: `test-${Date.now()}`, testType, @@ -445,10 +465,10 @@ export const testService = { } ), - // POST /tests/{testId}/submit - 시험 제출 + // POST /vocab/test/submit - 시험 제출 submit: (userId, testId, answers) => withMock( - () => vocabApi.post(`/tests/${testId}/submit`, {userId, answers}), + () => vocabApi.post('/vocab/test/submit', {userId, testId, answers}), { testId, totalQuestions: answers.length, @@ -463,63 +483,95 @@ export const testService = { // 시험 결과 조회 (프론트엔드 전용 - 백엔드에서 미구현) getResults: (userId, {limit = 20, cursor} = {}) => withMock( - () => vocabApi.get('/tests/results', {params: {userId, limit, cursor}}), + () => vocabApi.get('/vocab/test/results', {params: {userId, limit, cursor}}), {testResults: mockTestResults.slice(0, limit), hasMore: false} ), } /** - * 통계 API - Backend: GET /statistics + * 통계 API - Backend: GET /stats/total, GET /stats/history, GET /vocab/stats/weakness + * userId는 토큰에서 추출되므로 파라미터로 전달하지 않음 */ export const statsService = { - // GET /statistics - 학습 통계 조회 - getOverall: (userId, period = 'ALL') => + // GET /stats/total - 전체 통계 조회 + getOverall: () => withMock( - () => vocabApi.get('/statistics', {params: {userId, period}}), + () => vocabApi.get('/stats/total'), { - totalWords: mockWords.length, - totalLearned: 15, - masteredWords: 5, - learningWords: 8, - newWords: 7, - averageSuccessRate: 78.5, - averageAccuracy: 78.5, - studyStreak: 7, + periodType: 'TOTAL', + period: 'ALL', + testsCompleted: 4, + questionsAnswered: 100, + correctAnswers: 78, + incorrectAnswers: 22, + successRate: 78.0, + newWordsLearned: 50, + wordsReviewed: 20, + currentStreak: 7, + longestStreak: 15, + lastStudyDate: new Date().toISOString().split('T')[0], + // 프론트엔드 호환용 필드 + totalLearned: 50, + averageSuccessRate: 78.0, + averageAccuracy: 78.0, streakDays: 7, - dailyStats: generateDailyStats().slice(0, 7), - levelProgress: { - BEGINNER: {total: 8, learned: 6}, - INTERMEDIATE: {total: 7, learned: 5}, - ADVANCED: {total: 5, learned: 2}, - }, - difficultyDistribution: { - EASY: 6, - NORMAL: 9, - HARD: 5, - }, } ), - // GET /statistics - 기간별 통계 (프론트엔드 래핑) - getDaily: (userId, {limit = 30, period = 'MONTH'} = {}) => + // GET /stats/history - 히스토리 조회 (히트맵/차트용) + getDaily: (userId, {limit = 7} = {}) => withMock( - () => vocabApi.get('/statistics', {params: {userId, period}}), - {dailyStats: generateDailyStats().slice(0, limit)} + () => vocabApi.get('/stats/history', {params: {limit}}), + { + history: generateDailyStats().slice(0, limit).map(s => ({ + period: s.date, + testsCompleted: Math.floor(Math.random() * 3), + questionsAnswered: s.wordsStudied, + correctAnswers: s.correctCount, + successRate: s.successRate, + newWordsLearned: s.learnedCount, + wordsReviewed: Math.floor(Math.random() * 10), + // 프론트엔드 호환용 + date: s.date, + learnedCount: s.learnedCount, + isCompleted: s.learnedCount >= 55, + })), + dailyStats: generateDailyStats().slice(0, limit), + hasMore: false, + } ), - // 취약 단어 조회 (프론트엔드 전용 - 백엔드에서 미구현) - getWeakness: (userId) => + // GET /vocab/stats/weakness - 취약점 분석 + getWeakness: () => withMock( - () => vocabApi.get('/statistics', {params: {userId, includeWeak: true}}), + () => vocabApi.get('/vocab/stats/weakness'), { + weakCategories: [ + {category: 'BUSINESS', incorrectRate: 35.5, totalAnswered: 100, incorrectCount: 35}, + {category: 'ACADEMIC', incorrectRate: 28.0, totalAnswered: 50, incorrectCount: 14}, + ], + frequentMistakes: mockUserWords + .filter(w => w.incorrectCount > 0) + .sort((a, b) => b.incorrectCount - a.incorrectCount) + .slice(0, 10) + .map(w => ({ + wordId: w.wordId, + english: w.english, + korean: w.korean, + incorrectCount: w.incorrectCount, + accuracy: Math.round((w.correctCount / (w.correctCount + w.incorrectCount)) * 100), + })), weakWords: mockUserWords .filter(w => w.incorrectCount > 0) - .sort((a, b) => (a.correctCount / (a.correctCount + a.incorrectCount)) - (b.correctCount / (b.correctCount + b.incorrectCount))) .slice(0, 10) .map(w => ({ ...w, accuracy: Math.round((w.correctCount / (w.correctCount + w.incorrectCount)) * 100), })), + weakestWords: mockUserWords + .filter(w => w.incorrectCount > 0) + .slice(0, 5), + recommendedReview: 15, } ), } @@ -531,7 +583,7 @@ export const voiceService = { // POST /voice/synthesize - 음성 합성 synthesize: (wordId, text, voice = 'female', type = 'word') => withMock( - () => vocabApi.post('/voice/synthesize', {wordId, text, voice, type}), + () => vocabApi.post('/vocab/voice/synthesize', {wordId, text, voice, type}), { audioUrl: null, // Mock에서는 실제 오디오 없음 cached: false, diff --git a/vite.config.js b/vite.config.js index 4c5fcda..9833a92 100644 --- a/vite.config.js +++ b/vite.config.js @@ -5,7 +5,15 @@ export default defineConfig({ plugins: [react()], server: { port: 3000, - open: true + open: true, + proxy: { + '/api': { + target: 'https://gc8l9ijhzc.execute-api.ap-northeast-2.amazonaws.com/dev', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ''), + secure: true, + } + } }, build: { outDir: 'dist',