From bc58205fabe5fb390c443aa94c8af1db184a40c0 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Sat, 24 Jan 2026 01:31:20 +0900 Subject: [PATCH 1/9] =?UTF-8?q?[Feat]=20:=20AI=20=ED=94=84=EB=A6=AC?= =?UTF-8?q?=ED=86=A0=ED=82=B9(Speaking)=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#195)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : AI 말하기 연습 화면 구현 * refactor : Rest API 변경에 따른 화면 구현 변경 * feature : AI와 대화하기 UI & STT서비스 구현 --- src/App.jsx | 161 +++++----- src/api/axios.js | 21 +- src/api/speakingApi.js | 41 +++ .../components/SpeakingChatMessage.jsx | 163 ++++++++++ .../speaking/components/SpeakingInput.jsx | 294 ++++++++++++++++++ .../speaking/constants/speakingConstants.js | 31 ++ src/domains/speaking/hooks/useSpeaking.js | 119 +++++++ src/domains/speaking/index.js | 15 + src/domains/speaking/pages/SpeakingPage.jsx | 222 +++++++++++++ src/domains/speaking/services/speakingApi.js | 68 ++++ .../speaking/services/speakingService.js | 29 ++ 11 files changed, 1075 insertions(+), 89 deletions(-) create mode 100644 src/api/speakingApi.js create mode 100644 src/domains/speaking/components/SpeakingChatMessage.jsx create mode 100644 src/domains/speaking/components/SpeakingInput.jsx create mode 100644 src/domains/speaking/constants/speakingConstants.js create mode 100644 src/domains/speaking/hooks/useSpeaking.js create mode 100644 src/domains/speaking/index.js create mode 100644 src/domains/speaking/pages/SpeakingPage.jsx create mode 100644 src/domains/speaking/services/speakingApi.js create mode 100644 src/domains/speaking/services/speakingService.js diff --git a/src/App.jsx b/src/App.jsx index dab5d55..d5f3895 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,6 +1,6 @@ -import {useState, useEffect} from 'react' -import {Navigate, Route, Routes, useNavigate} from 'react-router-dom' -import {Box, Button, Card, CardContent, CircularProgress, Collapse, Container, Grid, Typography} from '@mui/material' +import { useState, useEffect } from 'react' +import { Navigate, Route, Routes, useNavigate } from 'react-router-dom' +import { Box, Button, Card, CardContent, CircularProgress, Collapse, Container, Grid, Typography } from '@mui/material' import { ChevronRight as ChevronRightIcon, Create as WritingCategoryIcon, @@ -19,6 +19,7 @@ import { } from '@mui/icons-material' import MainLayout from './layouts/MainLayout' import FreetalkPeoplePage from './domains/freetalk/pages/FreetalkPeoplePage' +import { SpeakingPage } from './domains/speaking' import ChatRoomPage from './domains/freetalk/pages/ChatRoomPage' import ChatRoomModal from './domains/freetalk/components/ChatRoomModal' import VocabDashboard from './domains/vocab/pages/VocabDashboard' @@ -26,8 +27,8 @@ import DailyLearning from './domains/vocab/pages/DailyLearning' import TestPage from './domains/vocab/pages/TestPage' import WordListPage from './domains/vocab/pages/WordListPage' import StatsPage from './domains/vocab/pages/StatsPage' -import {WritingPage} from './domains/grammar' -import {BadgeSection} from './domains/badge' +import { WritingPage } from './domains/grammar' +import { BadgeSection } from './domains/badge' import CatchmindLobbyPage from './domains/games/pages/CatchmindLobbyPage' import CatchmindWaitingPage from './domains/games/pages/CatchmindWaitingPage' import CatchmindPlayPage from './domains/games/pages/CatchmindPlayPage' @@ -41,8 +42,8 @@ import LoginPage from './pages/Login' import SignUpPage from './pages/SignUp' -function ProtectedRoute({children}) { - const {isAuthenticated, isLoading} = useAuth() +function ProtectedRoute({ children }) { + const { isAuthenticated, isLoading } = useAuth() if (isLoading) { return ( @@ -52,21 +53,21 @@ function ProtectedRoute({children}) { alignItems: 'center', justifyContent: 'center' }}> - + ) } if (!isAuthenticated) { - return + return } return children } // 이미 로그인된 경우 대시보드로 -function PublicRoute({children}) { - const {isAuthenticated, isLoading} = useAuth() +function PublicRoute({ children }) { + const { isAuthenticated, isLoading } = useAuth() if (isLoading) { return ( @@ -76,13 +77,13 @@ function PublicRoute({children}) { alignItems: 'center', justifyContent: 'center' }}> - + ) } if (isAuthenticated) { - return + return } return children @@ -92,6 +93,7 @@ function PublicRoute({children}) { function Dashboard() { const navigate = useNavigate() const [expandedCard, setExpandedCard] = useState(null) + const { t } = useSettings() const {t, isKorean} = useSettings() const [activityData, setActivityData] = useState(null) const [loadingActivity, setLoadingActivity] = useState(true) @@ -286,9 +288,9 @@ function Dashboard() { } return ( - + {/* Header */} - + - + - + {t('dashboard.greeting')} @@ -323,7 +325,7 @@ function Dashboard() { const hasChildren = mode.children && mode.children.length > 0 return ( - + handleCardHover(mode.id)} onMouseLeave={handleCardLeave} @@ -344,8 +346,8 @@ function Dashboard() { minHeight: isExpanded ? 'auto' : 140, }} > - - + + {/* Icon */} - + {/* Text */} - + )} - + {mode.description} @@ -441,14 +443,14 @@ function Dashboard() { boxShadow: '0 2px 8px -2px rgba(0,0,0,0.1)', }} > - + + sx={{ mb: 0.5 }}> {child.title} + sx={{ lineHeight: 1.3 }}> {child.description} @@ -655,22 +657,13 @@ function Dashboard() { // Placeholder Pages function OpicPage() { return ( - + OPIC Practice Level-based training ) } -function FreetalkAiPage() { - return ( - - AI Conversation - Free conversation with AI - - ) -} - function ReportsPage() { const {isKorean} = useSettings() @@ -735,9 +728,9 @@ function ReportsPage() { } return ( - + {/* 헤더 */} - + - + @@ -765,9 +758,9 @@ function ReportsPage() { {/* 통계 요약 카드 */} - - - + + + {isKorean ? '총 학습일' : 'Study Days'} @@ -779,8 +772,8 @@ function ReportsPage() { - - + + {isKorean ? '학습한 단어' : 'Words Learned'} @@ -792,8 +785,8 @@ function ReportsPage() { - - + + {isKorean ? '테스트 완료' : 'Tests Taken'} @@ -805,8 +798,8 @@ function ReportsPage() { - - + + {isKorean ? '평균 점수' : 'Average Score'} @@ -852,12 +845,12 @@ function ReportsPage() { )} {/* 연속 학습 */} - + {isKorean ? '연속 학습 기록' : 'Study Streak'} - + - + {/* 배지 섹션 */} - + ) } function SettingsPage() { - const {settings, setTtsVoice, setLanguage, t} = useSettings() + const { settings, setTtsVoice, setLanguage, t } = useSettings() const languageOptions = [ - {value: 'ko', label: '한국어', flag: '🇰🇷'}, - {value: 'en', label: 'English', flag: '🇺🇸'}, + { value: 'ko', label: '한국어', flag: '🇰🇷' }, + { value: 'en', label: 'English', flag: '🇺🇸' }, ] return ( - - + + - + - + {t('settings.title')} @@ -939,24 +932,24 @@ function SettingsPage() { {/* Language Settings */} - + - + {t('settings.language')} - + {t('settings.languageDesc')} - + {languageOptions.map((option) => ( - + setLanguage(option.value)} sx={{ @@ -974,7 +967,7 @@ function SettingsPage() { }, }} > - + {option.flag} {/* TTS Voice Settings */} - + - + {t('settings.ttsVoice')} - + {t('settings.ttsVoiceDesc')} - + - + setTtsVoice('FEMALE')} sx={{ @@ -1039,7 +1032,7 @@ function SettingsPage() { mb: 1.5, }} > - 👩 + 👩 - + setTtsVoice('MALE')} sx={{ @@ -1081,7 +1074,7 @@ function SettingsPage() { mb: 1.5, }} > - 👨 + 👨 @@ -1120,10 +1113,10 @@ function NotFound() { > 404 - + {t('notFound.title')} - + {t('notFound.message')} + + )} + + {/* 알림 목록 */} + {notifications.length === 0 ? ( + + + {isKorean ? '알림이 없습니다' : 'No notifications'} + + + ) : ( + + {notifications.map((notification) => { + const config = NotificationConfig[notification.type] || { + icon: '🔔', + color: '#6b7280', + bgColor: '#f3f4f6', + } + const message = getNotificationMessage(notification, isKorean) + const timeAgo = formatTimeAgo(notification.createdAt, isKorean) + + return ( + handleNotificationClick(notification)} + sx={{ + py: 2, + px: 2.5, + alignItems: 'flex-start', + backgroundColor: notification.isRead + ? 'transparent' + : alpha(config.color, 0.04), + '&:hover': { + backgroundColor: alpha(config.color, 0.08), + }, + }} + > + + {config.icon} + + + + + {message} + + + {timeAgo} + + + + {!notification.isRead && ( + + )} + + ) + })} + + )} + + ) +} + +export default NotificationMenu diff --git a/src/domains/notification/components/NotificationToast.jsx b/src/domains/notification/components/NotificationToast.jsx new file mode 100644 index 0000000..347e8f8 --- /dev/null +++ b/src/domains/notification/components/NotificationToast.jsx @@ -0,0 +1,123 @@ +import { Snackbar, Box, Typography, IconButton, alpha } from '@mui/material' +import { Close as CloseIcon } from '@mui/icons-material' +import { useSettings } from '../../../contexts/SettingsContext' +import { useNotificationContext } from '../contexts/NotificationContext' +import { NotificationConfig, getNotificationMessage, formatTimeAgo } from '../types/notificationTypes' + +const AUTO_HIDE_DURATION = 5000 + +export function NotificationToast() { + const { currentToast, closeToast } = useNotificationContext() + const { isKorean } = useSettings() + + if (!currentToast) return null + + const config = NotificationConfig[currentToast.type] || { + icon: '🔔', + color: '#6b7280', + bgColor: '#f3f4f6', + titleKo: '알림', + titleEn: 'Notification', + } + + const title = isKorean ? config.titleKo : config.titleEn + const message = getNotificationMessage(currentToast, isKorean) + + return ( + + + + {config.icon} + + + + + {title} + + + {message} + + + + + + + + + ) +} + +export default NotificationToast diff --git a/src/domains/notification/contexts/NotificationContext.jsx b/src/domains/notification/contexts/NotificationContext.jsx new file mode 100644 index 0000000..e03ba18 --- /dev/null +++ b/src/domains/notification/contexts/NotificationContext.jsx @@ -0,0 +1,137 @@ +import { createContext, useContext, useState, useCallback, useMemo, useEffect } from 'react' +import { useAuth } from '../../../contexts/AuthContext' +import { useNotifications } from '../hooks/useNotifications' +import { NotificationType, NotificationConfig, getNotificationMessage } from '../types/notificationTypes' + +const NotificationContext = createContext(null) + +const MAX_NOTIFICATIONS = 50 + +export function NotificationProvider({ children }) { + const { user, isAuthenticated } = useAuth() + const [notifications, setNotifications] = useState([]) + const [unreadCount, setUnreadCount] = useState(0) + const [toastQueue, setToastQueue] = useState([]) + const [currentToast, setCurrentToast] = useState(null) + + // 알림 수신 핸들러 + const handleNotification = useCallback((notification) => { + // 클라이언트에서 isRead 속성 추가 + const enrichedNotification = { + ...notification, + isRead: false, + receivedAt: new Date().toISOString(), + } + + // 알림 목록에 추가 (최신순, 최대 개수 제한) + setNotifications((prev) => { + const updated = [enrichedNotification, ...prev] + return updated.slice(0, MAX_NOTIFICATIONS) + }) + + // 읽지 않은 알림 카운트 증가 + setUnreadCount((prev) => prev + 1) + + // 토스트 큐에 추가 + setToastQueue((prev) => [...prev, enrichedNotification]) + }, []) + + // SSE 연결 + const userId = isAuthenticated && user?.username ? user.username : null + console.log('[NotificationContext] Auth state:', { isAuthenticated, user, userId }) + const { isConnected, isEnabled, error, reconnect } = useNotifications(userId, handleNotification) + + // 토스트 표시 로직 - 큐에서 하나씩 처리 + useEffect(() => { + if (toastQueue.length > 0 && !currentToast) { + const [next, ...rest] = toastQueue + setCurrentToast(next) + setToastQueue(rest) + } + }, [toastQueue, currentToast]) + + // 토스트 닫기 + const closeToast = useCallback(() => { + setCurrentToast(null) + }, []) + + // 알림 읽음 처리 + const markAsRead = useCallback((notificationId) => { + setNotifications((prev) => + prev.map((n) => + n.notificationId === notificationId ? { ...n, isRead: true } : n + ) + ) + setUnreadCount((prev) => Math.max(0, prev - 1)) + }, []) + + // 모든 알림 읽음 처리 + const markAllAsRead = useCallback(() => { + setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true }))) + setUnreadCount(0) + }, []) + + // 알림 삭제 + const removeNotification = useCallback((notificationId) => { + setNotifications((prev) => { + const notification = prev.find((n) => n.notificationId === notificationId) + if (notification && !notification.isRead) { + setUnreadCount((count) => Math.max(0, count - 1)) + } + return prev.filter((n) => n.notificationId !== notificationId) + }) + }, []) + + // 모든 알림 삭제 + const clearAllNotifications = useCallback(() => { + setNotifications([]) + setUnreadCount(0) + }, []) + + const value = useMemo( + () => ({ + notifications, + unreadCount, + isConnected, + isEnabled, + connectionError: error, + currentToast, + closeToast, + markAsRead, + markAllAsRead, + removeNotification, + clearAllNotifications, + reconnect, + }), + [ + notifications, + unreadCount, + isConnected, + isEnabled, + error, + currentToast, + closeToast, + markAsRead, + markAllAsRead, + removeNotification, + clearAllNotifications, + reconnect, + ] + ) + + return ( + + {children} + + ) +} + +export function useNotificationContext() { + const context = useContext(NotificationContext) + if (!context) { + throw new Error('useNotificationContext must be used within NotificationProvider') + } + return context +} + +export { NotificationType, NotificationConfig, getNotificationMessage } diff --git a/src/domains/notification/hooks/useNotifications.js b/src/domains/notification/hooks/useNotifications.js new file mode 100644 index 0000000..0948050 --- /dev/null +++ b/src/domains/notification/hooks/useNotifications.js @@ -0,0 +1,173 @@ +import { useEffect, useCallback, useRef, useState } from 'react' + +const NOTIFICATION_URL = import.meta.env.VITE_NOTIFICATION_URL +const NOTIFICATION_ENABLED = import.meta.env.VITE_NOTIFICATION_ENABLED === 'true' + +const MAX_RETRY_COUNT = 5 +const RETRY_DELAY_MS = 3000 + +/** + * SSE를 통한 실시간 알림 연결 훅 + * @param {string|null} userId - 로그인한 사용자 ID + * @param {function} onNotification - 알림 수신 시 콜백 + * @returns {{ isConnected: boolean, error: string|null, disconnect: function, reconnect: function }} + */ +export function useNotifications(userId, onNotification) { + const eventSourceRef = useRef(null) + const retryCountRef = useRef(0) + const retryTimeoutRef = useRef(null) + + const [isConnected, setIsConnected] = useState(false) + const [error, setError] = useState(null) + + const disconnect = useCallback(() => { + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current) + retryTimeoutRef.current = null + } + + if (eventSourceRef.current) { + eventSourceRef.current.close() + eventSourceRef.current = null + } + + setIsConnected(false) + }, []) + + const connect = useCallback(() => { + console.log('[Notifications] connect() called', { userId, NOTIFICATION_URL, NOTIFICATION_ENABLED }) + + if (!NOTIFICATION_ENABLED) { + console.log('[Notifications] Disabled by VITE_NOTIFICATION_ENABLED flag') + return + } + + if (!userId) { + console.warn('[Notifications] Missing userId') + return + } + + if (!NOTIFICATION_URL) { + console.warn('[Notifications] Missing NOTIFICATION_URL env variable') + return + } + + // 기존 연결 정리 + disconnect() + + const url = `${NOTIFICATION_URL}?userId=${encodeURIComponent(userId)}` + console.log('[Notifications] Connecting to:', url) + + try { + const eventSource = new EventSource(url) + eventSourceRef.current = eventSource + + eventSource.onopen = () => { + console.log('[Notifications] 연결됨!') + setIsConnected(true) + setError(null) + retryCountRef.current = 0 + } + + eventSource.onmessage = (event) => { + console.log('[Notifications] 알림:', event.data) + + // Heartbeat 무시 + if (event.data === 'HEARTBEAT') { + return + } + + // Stream end 처리 + if (event.data === 'STREAM_END') { + console.log('[Notifications] Stream ended, reconnecting...') + scheduleReconnect() + return + } + + try { + const notification = JSON.parse(event.data) + console.log('[Notifications] Parsed:', notification) + onNotification?.(notification) + } catch (e) { + console.error('[Notifications] Failed to parse:', e, event.data) + } + } + + eventSource.onerror = (err) => { + console.log('[Notifications] 에러:', err) + console.log('[Notifications] readyState:', eventSource.readyState) + setIsConnected(false) + + // EventSource가 자동으로 재연결을 시도하지만, + // 연속 실패 시 수동 재연결 로직 적용 + if (eventSource.readyState === EventSource.CLOSED) { + scheduleReconnect() + } + } + } catch (err) { + console.error('[Notifications] Failed to create EventSource:', err) + setError('알림 연결에 실패했습니다.') + scheduleReconnect() + } + }, [userId, onNotification, disconnect]) + + const scheduleReconnect = useCallback(() => { + if (retryCountRef.current >= MAX_RETRY_COUNT) { + setError('알림 서버 연결에 실패했습니다. 새로고침해주세요.') + console.error('[Notifications] Max retry count reached') + return + } + + retryCountRef.current += 1 + const delay = RETRY_DELAY_MS * retryCountRef.current + + console.log(`[Notifications] Retry ${retryCountRef.current}/${MAX_RETRY_COUNT} in ${delay}ms`) + + retryTimeoutRef.current = setTimeout(() => { + connect() + }, delay) + }, [connect]) + + // userId 변경 시 연결/해제 + // StrictMode에서 중복 연결 방지를 위한 debounce + useEffect(() => { + let mounted = true + let connectTimeout = null + + if (userId) { + // 100ms 지연으로 StrictMode 중복 호출 방지 + connectTimeout = setTimeout(() => { + if (mounted) { + connect() + } + }, 100) + } else { + disconnect() + } + + return () => { + mounted = false + if (connectTimeout) { + clearTimeout(connectTimeout) + } + disconnect() + } + }, [userId, connect, disconnect]) + + const reconnect = useCallback(() => { + retryCountRef.current = 0 + setError(null) + connect() + }, [connect]) + + return { + isConnected, + isEnabled: NOTIFICATION_ENABLED, + error, + disconnect, + reconnect, + } +} + +export { NOTIFICATION_ENABLED } +export default useNotifications diff --git a/src/domains/notification/index.js b/src/domains/notification/index.js new file mode 100644 index 0000000..c67fbf5 --- /dev/null +++ b/src/domains/notification/index.js @@ -0,0 +1,17 @@ +// Context +export { NotificationProvider, useNotificationContext } from './contexts/NotificationContext' + +// Components +export { NotificationToast } from './components/NotificationToast' +export { NotificationMenu } from './components/NotificationMenu' + +// Hooks +export { useNotifications } from './hooks/useNotifications' + +// Types & Utils +export { + NotificationType, + NotificationConfig, + getNotificationMessage, + formatTimeAgo, +} from './types/notificationTypes' diff --git a/src/domains/notification/types/notificationTypes.js b/src/domains/notification/types/notificationTypes.js new file mode 100644 index 0000000..7b40be5 --- /dev/null +++ b/src/domains/notification/types/notificationTypes.js @@ -0,0 +1,238 @@ +/** + * 알림 타입 상수 + * @enum {string} + */ +export const NotificationType = { + BADGE_EARNED: 'BADGE_EARNED', + DAILY_COMPLETE: 'DAILY_COMPLETE', + STREAK_REMINDER: 'STREAK_REMINDER', + TEST_COMPLETE: 'TEST_COMPLETE', + NEWS_QUIZ_COMPLETE: 'NEWS_QUIZ_COMPLETE', + GAME_END: 'GAME_END', + GAME_STREAK: 'GAME_STREAK', + OPIC_COMPLETE: 'OPIC_COMPLETE', +} + +/** + * @typedef {Object} BadgeEarnedPayload + * @property {string} badgeType - 배지 타입 코드 + * @property {string} badgeName - 배지 이름 + * @property {string} description - 배지 설명 + * @property {string} iconUrl - 배지 아이콘 URL + */ + +/** + * @typedef {Object} DailyCompletePayload + * @property {string} date - 학습 완료 날짜 (YYYY-MM-DD) + * @property {number} wordsLearned - 오늘 학습한 단어 수 + * @property {number} totalWords - 총 학습 단어 수 + * @property {number} currentStreak - 현재 연속 학습 일수 + */ + +/** + * @typedef {Object} StreakReminderPayload + * @property {number} currentStreak - 현재 연속 학습 일수 + * @property {string} message - 리마인더 메시지 + */ + +/** + * @typedef {Object} TestCompletePayload + * @property {string} testId - 테스트 ID + * @property {number} score - 점수 (0-100) + * @property {number} correctCount - 맞힌 문제 수 + * @property {number} totalCount - 전체 문제 수 + * @property {boolean} isPerfect - 만점 여부 + */ + +/** + * @typedef {Object} NewsQuizCompletePayload + * @property {string} articleId - 뉴스 기사 ID + * @property {string} articleTitle - 기사 제목 + * @property {number} score - 점수 (0-100) + * @property {number} correctCount - 맞힌 문제 수 + * @property {number} totalCount - 전체 문제 수 + * @property {boolean} isPerfect - 만점 여부 + */ + +/** + * @typedef {Object} GameEndPayload + * @property {string} roomId - 게임 방 ID + * @property {string} gameSessionId - 게임 세션 ID + * @property {number} rank - 최종 순위 + * @property {number} totalPlayers - 전체 플레이어 수 + * @property {number} score - 획득 점수 + * @property {boolean} isWinner - 1등 여부 + */ + +/** + * @typedef {Object} GameStreakPayload + * @property {string} roomId - 게임 방 ID + * @property {number} streakCount - 연속 정답 횟수 + * @property {number} bonusPoints - 보너스 점수 + */ + +/** + * @typedef {Object} OpicCompletePayload + * @property {string} sessionId - 세션 ID + * @property {string} estimatedLevel - 예상 등급 (IM1, IM2, IH, AL 등) + * @property {number} questionsAnswered - 답변한 문제 수 + * @property {string} feedbackSummary - 피드백 요약 + */ + +/** + * @typedef {Object} Notification + * @property {string} notificationId - 알림 ID ("notif-xxxxxxxx" 형식) + * @property {string} type - 알림 타입 + * @property {string} userId - 대상 사용자 ID + * @property {Object} payload - 타입별 상세 데이터 + * @property {string} createdAt - ISO-8601 형식 생성 시간 + * @property {boolean} [isRead] - 읽음 여부 (클라이언트 관리) + */ + +/** + * 알림 타입별 아이콘 및 스타일 설정 + */ +export const NotificationConfig = { + [NotificationType.BADGE_EARNED]: { + icon: '🏆', + color: '#f59e0b', + bgColor: '#fffbeb', + titleKo: '배지 획득!', + titleEn: 'Badge Earned!', + }, + [NotificationType.DAILY_COMPLETE]: { + icon: '✅', + color: '#10b981', + bgColor: '#ecfdf5', + titleKo: '오늘의 학습 완료!', + titleEn: 'Daily Learning Complete!', + }, + [NotificationType.STREAK_REMINDER]: { + icon: '⏰', + color: '#f97316', + bgColor: '#fff7ed', + titleKo: '학습 리마인더', + titleEn: 'Study Reminder', + }, + [NotificationType.TEST_COMPLETE]: { + icon: '📝', + color: '#8b5cf6', + bgColor: '#f5f3ff', + titleKo: '테스트 완료', + titleEn: 'Test Complete', + }, + [NotificationType.NEWS_QUIZ_COMPLETE]: { + icon: '📰', + color: '#06b6d4', + bgColor: '#ecfeff', + titleKo: '뉴스 퀴즈 완료', + titleEn: 'News Quiz Complete', + }, + [NotificationType.GAME_END]: { + icon: '🎮', + color: '#ec4899', + bgColor: '#fdf2f8', + titleKo: '게임 종료', + titleEn: 'Game Over', + }, + [NotificationType.GAME_STREAK]: { + icon: '🔥', + color: '#ef4444', + bgColor: '#fef2f2', + titleKo: '연속 정답!', + titleEn: 'Answer Streak!', + }, + [NotificationType.OPIC_COMPLETE]: { + icon: '🎤', + color: '#059669', + bgColor: '#ecfdf5', + titleKo: 'OPIc 연습 완료', + titleEn: 'OPIc Practice Complete', + }, +} + +/** + * 알림 payload에서 표시할 메시지 생성 + * @param {Notification} notification + * @param {boolean} isKorean + * @returns {string} + */ +export function getNotificationMessage(notification, isKorean = true) { + const { type, payload } = notification + + switch (type) { + case NotificationType.BADGE_EARNED: + return isKorean + ? `"${payload.badgeName}" 배지를 획득했습니다!` + : `You earned the "${payload.badgeName}" badge!` + + case NotificationType.DAILY_COMPLETE: + return isKorean + ? `${payload.wordsLearned}개 단어 학습 완료! (${payload.currentStreak}일 연속)` + : `${payload.wordsLearned} words learned! (${payload.currentStreak} day streak)` + + case NotificationType.STREAK_REMINDER: + return payload.message + + case NotificationType.TEST_COMPLETE: + return isKorean + ? `테스트 점수: ${payload.score}점 (${payload.correctCount}/${payload.totalCount})` + : `Test score: ${payload.score} (${payload.correctCount}/${payload.totalCount})` + + case NotificationType.NEWS_QUIZ_COMPLETE: + return isKorean + ? `"${payload.articleTitle}" 퀴즈 완료! ${payload.score}점` + : `"${payload.articleTitle}" quiz done! Score: ${payload.score}` + + case NotificationType.GAME_END: + return isKorean + ? `${payload.rank}등으로 게임 종료! (${payload.score}점)` + : `Game over! Rank: #${payload.rank} (${payload.score} pts)` + + case NotificationType.GAME_STREAK: + return isKorean + ? `${payload.streakCount}연속 정답! +${payload.bonusPoints}점 보너스` + : `${payload.streakCount} streak! +${payload.bonusPoints} bonus` + + case NotificationType.OPIC_COMPLETE: + return isKorean + ? `예상 등급: ${payload.estimatedLevel} (${payload.questionsAnswered}문제)` + : `Estimated level: ${payload.estimatedLevel} (${payload.questionsAnswered} questions)` + + default: + return isKorean ? '새 알림이 도착했습니다.' : 'New notification received.' + } +} + +/** + * 시간 경과 표시 포맷 + * @param {string} createdAt - ISO-8601 날짜 문자열 + * @param {boolean} isKorean + * @returns {string} + */ +export function formatTimeAgo(createdAt, isKorean = true) { + const now = new Date() + const created = new Date(createdAt) + const diffMs = now - created + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMs / 3600000) + const diffDays = Math.floor(diffMs / 86400000) + + if (diffMins < 1) { + return isKorean ? '방금 전' : 'Just now' + } + if (diffMins < 60) { + return isKorean ? `${diffMins}분 전` : `${diffMins}m ago` + } + if (diffHours < 24) { + return isKorean ? `${diffHours}시간 전` : `${diffHours}h ago` + } + if (diffDays < 7) { + return isKorean ? `${diffDays}일 전` : `${diffDays}d ago` + } + + return created.toLocaleDateString(isKorean ? 'ko-KR' : 'en-US', { + month: 'short', + day: 'numeric', + }) +} diff --git a/src/layouts/MainLayout/Header/index.jsx b/src/layouts/MainLayout/Header/index.jsx index f93dfa8..f52deba 100644 --- a/src/layouts/MainLayout/Header/index.jsx +++ b/src/layouts/MainLayout/Header/index.jsx @@ -5,7 +5,6 @@ import { Avatar, Badge, Box, - Chip, Divider, IconButton, Menu, @@ -29,6 +28,7 @@ import {useThemeMode} from '../../../contexts/ThemeContext' import {useSettings, useTranslation} from '../../../contexts/SettingsContext' import {LANGUAGE_LABELS, LANGUAGES} from '../../../i18n/translations' import {useAuth} from '../../../contexts/AuthContext' +import {useNotificationContext, NotificationMenu} from '../../../domains/notification' const Header = ({onMenuClick, sidebarOpen}) => { const theme = useTheme() @@ -42,6 +42,7 @@ const Header = ({onMenuClick, sidebarOpen}) => { const [notificationAnchor, setNotificationAnchor] = useState(null) const [langAnchor, setLangAnchor] = useState(null) const {logout} = useAuth() + const {unreadCount} = useNotificationContext() const handleProfileMenuOpen = (event) => { setAnchorEl(event.currentTarget) @@ -206,7 +207,7 @@ const Header = ({onMenuClick, sidebarOpen}) => { }} > { fontSize: 10, minWidth: 18, height: 18, + display: unreadCount > 0 ? 'flex' : 'none', }, }} > @@ -298,75 +300,11 @@ const Header = ({onMenuClick, sidebarOpen}) => { {/* Notification Menu */} - - - - {t('nav.notifications')} - - - - - {[ - { - text: language === 'ko' ? '면접 연습 세션이 완료되었습니다.' : 'Interview session completed.', - time: language === 'ko' ? '10분 전' : '10 min ago' - }, - { - text: language === 'ko' ? 'OPIC 모의고사 결과가 도착했습니다.' : 'OPIC test results arrived.', - time: language === 'ko' ? '1시간 전' : '1 hour ago' - }, - { - text: language === 'ko' ? '새로운 학습 리포트가 생성되었습니다.' : 'New learning report generated.', - time: language === 'ko' ? '어제' : 'Yesterday' - }, - ].map((item, index) => ( - - - - {item.text} - - - {item.time} - - - - ))} - + /> {/* Profile Menu */} - + + + + From 232f3c987f5148d42f512b89da0bf0fa5b4c3ae3 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Sat, 24 Jan 2026 11:40:44 +0900 Subject: [PATCH 3/9] =?UTF-8?q?fix=20:=20=EC=97=B0=EC=86=8D=20=EC=84=A0?= =?UTF-8?q?=EC=96=B8=EB=90=9C=20=EB=B3=80=EC=88=98=20t=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20(#199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : AI 말하기 연습 화면 구현 * refactor : Rest API 변경에 따른 화면 구현 변경 * feature : AI와 대화하기 UI & STT서비스 구현 * [Feat] : AI 프리토킹(Speaking) 기능 구현 (#195) (#196) * feat : AI 말하기 연습 화면 구현 * refactor : Rest API 변경에 따른 화면 구현 변경 * feature : AI와 대화하기 UI & STT서비스 구현 --- src/App.jsx | 109 ++++++++++++++++++++++++++-------------------------- 1 file changed, 55 insertions(+), 54 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index a603d00..3298967 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -32,12 +32,12 @@ import { BadgeSection } from './domains/badge' import CatchmindLobbyPage from './domains/games/pages/CatchmindLobbyPage' import CatchmindWaitingPage from './domains/games/pages/CatchmindWaitingPage' import CatchmindPlayPage from './domains/games/pages/CatchmindPlayPage' -import {NewsListPage, NewsDetailPage, NewsQuizPage, NewsWordsPage, NewsStatsPage} from './domains/news' -import {dailyService, statsService} from './domains/vocab/services/vocabService' -import {getNewsStats, getDashboardStats} from './domains/news/services/newsService' -import {useChat} from './contexts/ChatContext' -import {useSettings} from './contexts/SettingsContext' -import {useAuth} from './contexts/AuthContext' +import { NewsListPage, NewsDetailPage, NewsQuizPage, NewsWordsPage, NewsStatsPage } from './domains/news' +import { dailyService, statsService } from './domains/vocab/services/vocabService' +import { getNewsStats, getDashboardStats } from './domains/news/services/newsService' +import { useChat } from './contexts/ChatContext' +import { useSettings } from './contexts/SettingsContext' +import { useAuth } from './contexts/AuthContext' import LoginPage from './pages/Login' import SignUpPage from './pages/SignUp' @@ -93,7 +93,8 @@ function PublicRoute({ children }) { function Dashboard() { const navigate = useNavigate() const [expandedCard, setExpandedCard] = useState(null) - const {t, isKorean} = useSettings() + const { t } = useSettings() + const { t, isKorean } = useSettings() const [activityData, setActivityData] = useState(null) const [loadingActivity, setLoadingActivity] = useState(true) @@ -466,14 +467,14 @@ function Dashboard() { {/* Today's Activity Stats */} - + {loadingActivity ? ( {[...Array(4)].map((_, i) => ( - - - - + + + + @@ -482,7 +483,7 @@ function Dashboard() { ) : ( {/* 오늘 외운 단어 */} - + navigate('/vocab')} sx={{ @@ -496,7 +497,7 @@ function Dashboard() { }, }} > - + - + {activityData?.todayWords || 0} @@ -527,7 +528,7 @@ function Dashboard() { {/* 읽은 뉴스 */} - + navigate('/news')} sx={{ @@ -541,7 +542,7 @@ function Dashboard() { }, }} > - + - + {activityData?.newsRead || 0} @@ -567,7 +568,7 @@ function Dashboard() { {/* 총 학습 단어 */} - + navigate('/vocab/words')} sx={{ @@ -581,7 +582,7 @@ function Dashboard() { }, }} > - + - + {activityData?.totalWords || 0} @@ -607,7 +608,7 @@ function Dashboard() { {/* 연속 학습 */} - + navigate('/reports')} sx={{ @@ -621,7 +622,7 @@ function Dashboard() { }, }} > - + - + {Math.max(activityData?.vocabStreak || 0, activityData?.newsStreak || 0)} @@ -665,7 +666,7 @@ function OpicPage() { function ReportsPage() { - const {isKorean} = useSettings() + const { isKorean } = useSettings() const [loading, setLoading] = useState(true) const [stats, setStats] = useState({ totalStudyDays: 0, @@ -684,7 +685,7 @@ function ReportsPage() { setLoading(true) const [vocabStatsRes, vocabHistoryRes, newsStatsRes] = await Promise.allSettled([ statsService.getOverall().catch(() => null), - statsService.getDaily(null, {limit: 30}).catch(() => null), + statsService.getDaily(null, { limit: 30 }).catch(() => null), getNewsStats().catch(() => null), ]) @@ -718,9 +719,9 @@ function ReportsPage() { if (loading) { return ( - + - + ) @@ -814,13 +815,13 @@ function ReportsPage() { {/* 뉴스 학습 통계 */} {(stats.newsRead > 0 || stats.newsQuizScore > 0) && ( - + {isKorean ? '뉴스 학습' : 'News Learning'} - - + + {stats.newsRead} @@ -829,8 +830,8 @@ function ReportsPage() { - - + + {stats.newsQuizScore}% @@ -1160,27 +1161,27 @@ function App() { }> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> {/* 404 */} From 05e792887bc0c0185acf0c77623920ce4e2c1a32 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Sat, 24 Jan 2026 11:51:10 +0900 Subject: [PATCH 4/9] =?UTF-8?q?refactor=20:=20AI=20=EB=A7=90=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20routing=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : AI 말하기 연습 화면 구현 * refactor : Rest API 변경에 따른 화면 구현 변경 * feature : AI와 대화하기 UI & STT서비스 구현 * refactor : AI 말하기 라우팅 페이지 수정 --- src/App.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App.jsx b/src/App.jsx index 45f8415..e8a4800 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1164,7 +1164,7 @@ function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> From f8ea2ed84e96cee1f8fde8a3f4726e16c48af8bc Mon Sep 17 00:00:00 2001 From: DDING JOO Date: Sat, 24 Jan 2026 12:01:25 +0900 Subject: [PATCH 5/9] =?UTF-8?q?[FEAT]=20=EC=B1=84=ED=8C=85=20=EC=8A=AC?= =?UTF-8?q?=EB=9E=98=EC=8B=9C=20=EB=AA=85=EB=A0=B9=EC=96=B4=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84=20(#200)=20(#204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 채팅 명령어 타입 정의 (chatCommandTypes.js) - CommandAutocomplete 컴포넌트 (/ 입력 시 명령어 자동완성) - PollCard, PollResultBar 컴포넌트 (투표 UI) - SystemCommandMessage 컴포넌트 (주사위, 동전, 랜덤 등) - WebSocket 서비스에 새 메시지 타입 핸들링 추가 - ChatRoomPage에 명령어 처리 로직 통합 지원 명령어: - /help, /members, /leave, /clear - /dice, /coin, /random - /poll, /vote, /endpoll --- .../components/CommandAutocomplete.jsx | 150 +++++++++++ src/domains/freetalk/components/PollCard.jsx | 175 ++++++++++++ .../freetalk/components/PollResultBar.jsx | 57 ++++ .../components/SystemCommandMessage.jsx | 196 ++++++++++++++ .../freetalk/hooks/useChatWebSocket.js | 90 +++++++ src/domains/freetalk/pages/ChatRoomPage.jsx | 255 ++++++++++++------ .../freetalk/services/chatWebSocketService.js | 29 +- .../freetalk/types/chatCommandTypes.js | 219 +++++++++++++++ 8 files changed, 1084 insertions(+), 87 deletions(-) create mode 100644 src/domains/freetalk/components/CommandAutocomplete.jsx create mode 100644 src/domains/freetalk/components/PollCard.jsx create mode 100644 src/domains/freetalk/components/PollResultBar.jsx create mode 100644 src/domains/freetalk/components/SystemCommandMessage.jsx create mode 100644 src/domains/freetalk/types/chatCommandTypes.js diff --git a/src/domains/freetalk/components/CommandAutocomplete.jsx b/src/domains/freetalk/components/CommandAutocomplete.jsx new file mode 100644 index 0000000..09b83e3 --- /dev/null +++ b/src/domains/freetalk/components/CommandAutocomplete.jsx @@ -0,0 +1,150 @@ +import { useEffect, useState } from 'react' +import { Box, Paper, Typography, List, ListItem, ListItemButton, ListItemText } from '@mui/material' +import { useThemeMode } from '../../../contexts/ThemeContext' +import { searchCommands } from '../types/chatCommandTypes' + +/** + * 채팅 명령어 자동완성 컴포넌트 + * 사용자가 "/"를 입력하면 사용 가능한 명령어 목록을 표시합니다. + * + * @param {Object} props + * @param {string} props.input - 현재 입력 값 + * @param {function} props.onSelect - 명령어 선택 시 호출되는 콜백 + * @param {boolean} props.show - 표시 여부 + * @returns {JSX.Element|null} + */ +const CommandAutocomplete = ({ input, onSelect, show }) => { + const { mode } = useThemeMode() + const isDark = mode === 'dark' + const [selectedIndex, setSelectedIndex] = useState(0) + const [filteredCommands, setFilteredCommands] = useState([]) + + // 입력값에 따라 명령어 필터링 + useEffect(() => { + if (!show || !input.startsWith('/')) { + setFilteredCommands([]) + setSelectedIndex(0) + return + } + + const commands = searchCommands(input) + setFilteredCommands(commands) + setSelectedIndex(0) + }, [input, show]) + + // 키보드 이벤트 처리 + useEffect(() => { + const handleKeyDown = (e) => { + if (!show || filteredCommands.length === 0) return + + if (e.key === 'ArrowDown') { + e.preventDefault() + setSelectedIndex((prev) => (prev + 1) % filteredCommands.length) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setSelectedIndex((prev) => (prev - 1 + filteredCommands.length) % filteredCommands.length) + } else if (e.key === 'Enter' && filteredCommands[selectedIndex]) { + e.preventDefault() + onSelect(filteredCommands[selectedIndex].command) + } else if (e.key === 'Escape') { + onSelect('') + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [show, filteredCommands, selectedIndex, onSelect]) + + // 표시할 항목이 없으면 렌더링하지 않음 + if (!show || filteredCommands.length === 0) { + return null + } + + return ( + + + + 사용 가능한 명령어 ({filteredCommands.length}) + + + + + {filteredCommands.map((cmd, index) => ( + + onSelect(cmd.command)} + sx={{ + py: 1.5, + px: 2, + '&.Mui-selected': { + bgcolor: isDark ? 'rgba(250, 204, 21, 0.2)' : 'rgba(254, 229, 0, 0.3)', + '&:hover': { + bgcolor: isDark ? 'rgba(250, 204, 21, 0.25)' : 'rgba(254, 229, 0, 0.4)', + }, + }, + '&:hover': { + bgcolor: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)', + }, + }} + > + + + {cmd.command} + + + {cmd.usage} + + + } + secondary={ + + {cmd.description} + + } + /> + + + ))} + + + + + 화살표로 선택, Enter로 입력, Esc로 닫기 + + + + ) +} + +export default CommandAutocomplete diff --git a/src/domains/freetalk/components/PollCard.jsx b/src/domains/freetalk/components/PollCard.jsx new file mode 100644 index 0000000..65e5bd6 --- /dev/null +++ b/src/domains/freetalk/components/PollCard.jsx @@ -0,0 +1,175 @@ +import { useState } from 'react' +import { Box, Button, Card, CardContent, Chip, Typography, IconButton } from '@mui/material' +import { + HowToVote as VoteIcon, + CheckCircle as CheckIcon, + Cancel as CancelIcon, + Person as PersonIcon, +} from '@mui/icons-material' +import { useThemeMode } from '../../../contexts/ThemeContext' +import { calculatePollResults } from '../types/chatCommandTypes' +import PollResultBar from './PollResultBar' + +/** + * 투표 카드 컴포넌트 + * 투표 생성, 투표하기, 결과 보기 기능을 제공합니다. + * + * @param {Object} props + * @param {Object} props.poll - 투표 데이터 + * @param {string} props.currentUserId - 현재 사용자 ID + * @param {function} props.onVote - 투표 시 호출되는 콜백 + * @param {function} props.onEndPoll - 투표 종료 시 호출되는 콜백 + * @returns {JSX.Element} + */ +const PollCard = ({ poll, currentUserId, onVote, onEndPoll }) => { + const { mode } = useThemeMode() + const isDark = mode === 'dark' + const [selectedOption, setSelectedOption] = useState(null) + + const { totalVotes, percentages } = calculatePollResults(poll.options) + const hasVoted = poll.options.some((opt) => opt.voters.includes(currentUserId)) + const isCreator = poll.creatorId === currentUserId + const showResults = !poll.isActive || hasVoted + + const handleVote = (optionId) => { + if (!poll.isActive || hasVoted) return + setSelectedOption(optionId) + onVote?.(poll.pollId, optionId) + } + + const handleEndPoll = () => { + onEndPoll?.(poll.pollId) + } + + return ( + + + {/* 헤더 */} + + + + 투표 + + {poll.isActive ? ( + + ) : ( + + )} + + + {/* 질문 */} + + {poll.question} + + + {/* 옵션 목록 */} + + {poll.options.map((option, index) => { + const percentage = percentages[index] + const isSelected = selectedOption === option.optionId + const userVoted = option.voters.includes(currentUserId) + + return ( + + {showResults ? ( + // 결과 보기 모드 + + + + + {option.text} + + {userVoted && } + + + {option.voteCount}표 ({percentage}%) + + + + + ) : ( + // 투표하기 모드 + + )} + + ) + })} + + + {/* 하단 정보 */} + + + + + 총 {totalVotes}명 참여 + + + + {isCreator && poll.isActive && ( + + + + )} + + + {/* 생성자 정보 */} + + 생성자: {poll.creatorId} + + + + ) +} + +export default PollCard diff --git a/src/domains/freetalk/components/PollResultBar.jsx b/src/domains/freetalk/components/PollResultBar.jsx new file mode 100644 index 0000000..bd46201 --- /dev/null +++ b/src/domains/freetalk/components/PollResultBar.jsx @@ -0,0 +1,57 @@ +import { Box, LinearProgress } from '@mui/material' +import { useThemeMode } from '../../../contexts/ThemeContext' + +/** + * 투표 결과 진행바 컴포넌트 + * 투표 옵션의 득표율을 시각적으로 표시합니다. + * + * @param {Object} props + * @param {number} props.percentage - 득표율 (0-100) + * @param {boolean} props.isUserVoted - 사용자가 해당 옵션에 투표했는지 여부 + * @returns {JSX.Element} + */ +const PollResultBar = ({ percentage, isUserVoted = false }) => { + const { mode } = useThemeMode() + const isDark = mode === 'dark' + + return ( + + + {isUserVoted && ( + + )} + + ) +} + +export default PollResultBar diff --git a/src/domains/freetalk/components/SystemCommandMessage.jsx b/src/domains/freetalk/components/SystemCommandMessage.jsx new file mode 100644 index 0000000..12a1423 --- /dev/null +++ b/src/domains/freetalk/components/SystemCommandMessage.jsx @@ -0,0 +1,196 @@ +import { Box, Paper, Typography } from '@mui/material' +import { useThemeMode } from '../../../contexts/ThemeContext' +import { SystemCommandConfig } from '../types/chatCommandTypes' + +/** + * 시스템 명령어 메시지 컴포넌트 + * /dice, /coin, /random, /members, /help 등의 명령어 결과를 표시합니다. + * + * @param {Object} props + * @param {Object} props.data - 시스템 명령어 데이터 + * @param {string} props.data.commandType - 명령어 타입 + * @param {string} props.data.userId - 실행한 사용자 ID + * @param {Object} props.data.result - 명령어 결과 + * @param {string} props.data.displayText - 표시할 텍스트 + * @returns {JSX.Element} + */ +const SystemCommandMessage = ({ data }) => { + const { mode } = useThemeMode() + const isDark = mode === 'dark' + + const config = SystemCommandConfig[data.commandType] || SystemCommandConfig.help + const { icon, color, bgColor } = config + + return ( + + + + {/* 아이콘 */} + + {icon} + + + {/* 내용 */} + + + + {data.userId} + + + {data.displayText} + + + + {/* 추가 결과 정보 */} + {renderCommandResult(data.commandType, data.result, isDark)} + + + + + ) +} + +/** + * 명령어 타입별 결과 렌더링 + */ +function renderCommandResult(commandType, result, isDark) { + if (!result) return null + + switch (commandType) { + case 'dice': + return ( + + {result.value} + + ) + + case 'coin': + return ( + + {result.side === 'heads' ? '앞면' : '뒷면'} + + ) + + case 'random': + return ( + + {result.value} + {result.min !== undefined && result.max !== undefined && ( + + ({result.min}-{result.max}) + + )} + + ) + + case 'members': + return result.memberIds ? ( + + + 참여 중인 멤버 ({result.totalCount}명): + + + {result.memberIds.map((memberId) => ( + + {memberId} + + ))} + + + ) : null + + case 'help': + return result.commands ? ( + + + 사용 가능한 명령어: + + + {result.commands.map((cmd) => ( + + {cmd.command} - {cmd.description} + + ))} + + + ) : null + + default: + return null + } +} + +export default SystemCommandMessage diff --git a/src/domains/freetalk/hooks/useChatWebSocket.js b/src/domains/freetalk/hooks/useChatWebSocket.js index 7afd702..8aad516 100644 --- a/src/domains/freetalk/hooks/useChatWebSocket.js +++ b/src/domains/freetalk/hooks/useChatWebSocket.js @@ -77,6 +77,8 @@ export function useChatWebSocket(roomId, userId) { messageType: data.messageType || 'TEXT', createdAt: data.createdAt || new Date().toISOString(), isOwn: data.userId === userId, + // 추가 데이터 (투표, 시스템 명령어 등) + data: data.data || data.payload, } setMessages((prev) => { // 중복 메시지 방지 (같은 messageId가 이미 있으면 추가하지 않음) @@ -87,6 +89,94 @@ export function useChatWebSocket(roomId, userId) { }) }, + onPollCreate: (data) => { + console.log('[useChatWebSocket] Poll created:', data) + const pollData = data.data || data + const pollMessage = { + id: `poll-${pollData.pollId}`, + messageType: 'POLL_CREATE', + userId: pollData.creatorId, + createdAt: pollData.createdAt || new Date().toISOString(), + isOwn: pollData.creatorId === userId, + data: pollData, + } + setMessages((prev) => [...prev, pollMessage]) + }, + + onPollVote: (data) => { + console.log('[useChatWebSocket] Poll vote:', data) + const voteData = data.data || data + setMessages((prev) => + prev.map((msg) => { + if (msg.id === `poll-${voteData.pollId}` && msg.data) { + return { + ...msg, + data: { + ...msg.data, + options: voteData.updatedOptions || msg.data.options, + }, + } + } + return msg + }) + ) + }, + + onPollEnd: (data) => { + console.log('[useChatWebSocket] Poll ended:', data) + const endData = data.data || data + setMessages((prev) => + prev.map((msg) => { + if (msg.id === `poll-${endData.pollId}` && msg.data) { + return { + ...msg, + data: { + ...msg.data, + isActive: false, + options: endData.finalResults || msg.data.options, + }, + } + } + return msg + }) + ) + }, + + onClearChat: (data) => { + console.log('[useChatWebSocket] Clear chat:', data) + const clearData = data.data || data + // 특정 사용자의 메시지만 삭제 + setMessages((prev) => + prev.filter((msg) => !clearData.messageIds?.includes(msg.id)) + ) + }, + + onLeaveRoom: (data) => { + console.log('[useChatWebSocket] User left room:', data) + const leaveData = data.data || data + const systemMessage = { + id: `leave-${Date.now()}`, + content: `${leaveData.userId}님이 채팅방을 나갔습니다.`, + messageType: 'SYSTEM', + createdAt: leaveData.leftAt || new Date().toISOString(), + isSystem: true, + } + setMessages((prev) => [...prev, systemMessage]) + }, + + onSystemCommand: (data) => { + console.log('[useChatWebSocket] System command:', data) + const commandData = data.data || data + const commandMessage = { + id: `syscmd-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + messageType: 'SYSTEM_COMMAND', + userId: commandData.userId, + createdAt: new Date().toISOString(), + data: commandData, + } + setMessages((prev) => [...prev, commandMessage]) + }, + onGameStart: (data) => { console.log('[useChatWebSocket] Game started - FULL DATA:', JSON.stringify(data, null, 2)) // 실제 게임 데이터 추출 (data.data에 중첩되어 있을 수 있음) diff --git a/src/domains/freetalk/pages/ChatRoomPage.jsx b/src/domains/freetalk/pages/ChatRoomPage.jsx index cc80e6e..2a220d6 100644 --- a/src/domains/freetalk/pages/ChatRoomPage.jsx +++ b/src/domains/freetalk/pages/ChatRoomPage.jsx @@ -25,6 +25,10 @@ import {chatRoomService, messageService, voiceService} from '../../chat/services import {useAuth} from '../../../contexts/AuthContext' import {useChatWebSocket} from '../hooks/useChatWebSocket' import {useThemeMode} from '../../../contexts/ThemeContext' +import CommandAutocomplete from '../components/CommandAutocomplete' +import PollCard from '../components/PollCard' +import SystemCommandMessage from '../components/SystemCommandMessage' +import {parseCommand, MessageType} from '../types/chatCommandTypes' const levelColors = { beginner: {bg: '#e8f5e9', color: '#2e7d32', label: '초급'}, @@ -51,6 +55,7 @@ const ChatRoomPage = () => { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [playingTTS, setPlayingTTS] = useState(null) + const [showCommandAutocomplete, setShowCommandAutocomplete] = useState(false) // WebSocket 훅 사용 (채팅방에서는 게임 관련 기능 제외) const { @@ -153,6 +158,19 @@ const ChatRoomPage = () => { const messageContent = newMessage.trim() setNewMessage('') + setShowCommandAutocomplete(false) + + // 명령어 파싱 + const {isCommand, command, args} = parseCommand(messageContent) + + // /leave 명령어는 클라이언트에서 직접 처리 + if (isCommand && command === '/leave') { + handleLeaveRoom() + return + } + + // /clear 명령어는 서버로 전송 (서버에서 처리) + // 나머지 명령어들도 서버로 전송하여 처리 // WebSocket으로 전송 if (isConnected) { @@ -219,6 +237,41 @@ const ChatRoomPage = () => { } } + // 투표하기 + const handleVote = (pollId, optionId) => { + if (isConnected) { + wsSendMessage(`/vote ${pollId} ${optionId}`, 'TEXT') + } + } + + // 투표 종료 + const handleEndPoll = (pollId) => { + if (isConnected) { + wsSendMessage(`/endpoll ${pollId}`, 'TEXT') + } + } + + // 명령어 자동완성 선택 + const handleCommandSelect = (command) => { + if (command) { + setNewMessage(command + ' ') + } + setShowCommandAutocomplete(false) + } + + // 입력값 변경 처리 + const handleInputChange = (e) => { + const value = e.target.value + setNewMessage(value) + + // "/" 입력 시 자동완성 표시 + if (value.startsWith('/') && value.length > 0) { + setShowCommandAutocomplete(true) + } else { + setShowCommandAutocomplete(false) + } + } + // 새로고침 const handleRefresh = () => { fetchMessages() @@ -323,100 +376,124 @@ const ChatRoomPage = () => { ) : ( - messages.map((message) => ( - - {/* 시스템 메시지 */} - {message.isSystem ? ( - - - {message.content} - + messages.map((message) => { + // 투표 메시지 + if (message.messageType === MessageType.POLL_CREATE) { + return ( + + - ) : ( - <> - {/* 아바타 (상대방만) */} - {!message.isOwn && ( - - {message.userId?.charAt(0)?.toUpperCase() || 'U'} - - )} - - - {/* 사용자 이름 (상대방만) */} + ) + } + + // 시스템 명령어 메시지 + if (message.messageType === MessageType.SYSTEM_COMMAND) { + return ( + + ) + } + + // 일반 메시지 + return ( + + {/* 시스템 메시지 */} + {message.isSystem ? ( + + + {message.content} + + + ) : ( + <> + {/* 아바타 (상대방만) */} {!message.isOwn && ( - - {message.userId} - + + {message.userId?.charAt(0)?.toUpperCase() || 'U'} + )} - {/* 메시지 버블 */} - - {message.isOwn && ( - - {formatTime(message.createdAt)} + + {/* 사용자 이름 (상대방만) */} + {!message.isOwn && ( + + {message.userId} )} - - - {message.content} - - - - {!message.isOwn && ( - - handlePlayTTS(message.id)} - disabled={playingTTS === message.id} - sx={{p: 0.5}} - > - {playingTTS === message.id ? ( - - ) : ( - - )} - + {/* 메시지 버블 */} + + {message.isOwn && ( {formatTime(message.createdAt)} - - )} + )} + + + + {message.content} + + + + {!message.isOwn && ( + + handlePlayTTS(message.id)} + disabled={playingTTS === message.id} + sx={{p: 0.5}} + > + {playingTTS === message.id ? ( + + ) : ( + + )} + + + {formatTime(message.createdAt)} + + + )} + - - - )} - - )) + + )} + + ) + }) )}
@@ -430,13 +507,21 @@ const ChatRoomPage = () => { alignItems: 'center', gap: 1, borderRadius: 0, + position: 'relative', }} > + {/* 명령어 자동완성 */} + + setNewMessage(e.target.value)} + onChange={handleInputChange} onKeyPress={handleKeyPress} size="small" multiline diff --git a/src/domains/freetalk/services/chatWebSocketService.js b/src/domains/freetalk/services/chatWebSocketService.js index f4d042c..c670394 100644 --- a/src/domains/freetalk/services/chatWebSocketService.js +++ b/src/domains/freetalk/services/chatWebSocketService.js @@ -177,8 +177,33 @@ class ChatWebSocketConnection { break case 'system_command': case 'SYSTEM_COMMAND': - // 시스템 명령어 응답 (예: /member, /help 등) - this.callbacks.onMessage?.(data) + // 시스템 명령어 응답 (예: /dice, /coin, /random, /members, /help 등) + this.callbacks.onSystemCommand?.(data) + break + case 'poll_create': + case 'POLL_CREATE': + console.log('[ChatWebSocket] Poll create received:', data) + this.callbacks.onPollCreate?.(data) + break + case 'poll_vote': + case 'POLL_VOTE': + console.log('[ChatWebSocket] Poll vote received:', data) + this.callbacks.onPollVote?.(data) + break + case 'poll_end': + case 'POLL_END': + console.log('[ChatWebSocket] Poll end received:', data) + this.callbacks.onPollEnd?.(data) + break + case 'clear_chat': + case 'CLEAR_CHAT': + console.log('[ChatWebSocket] Clear chat received:', data) + this.callbacks.onClearChat?.(data) + break + case 'leave_room': + case 'LEAVE_ROOM': + console.log('[ChatWebSocket] Leave room received:', data) + this.callbacks.onLeaveRoom?.(data) break case 'error': case 'ERROR': diff --git a/src/domains/freetalk/types/chatCommandTypes.js b/src/domains/freetalk/types/chatCommandTypes.js new file mode 100644 index 0000000..73d87c6 --- /dev/null +++ b/src/domains/freetalk/types/chatCommandTypes.js @@ -0,0 +1,219 @@ +/** + * 채팅 메시지 타입 상수 + * @enum {string} + */ +export const MessageType = { + TEXT: 'TEXT', + IMAGE: 'IMAGE', + VOICE: 'VOICE', + SYSTEM_COMMAND: 'SYSTEM_COMMAND', + POLL_CREATE: 'POLL_CREATE', + POLL_VOTE: 'POLL_VOTE', + POLL_END: 'POLL_END', + CLEAR_CHAT: 'CLEAR_CHAT', + LEAVE_ROOM: 'LEAVE_ROOM', +} + +/** + * 사용 가능한 채팅 명령어 목록 + */ +export const COMMANDS = [ + { + command: '/help', + description: '사용 가능한 명령어 목록 보기', + usage: '/help', + }, + { + command: '/members', + description: '현재 참여 중인 멤버 목록 보기', + usage: '/members', + }, + { + command: '/poll', + description: '투표 생성하기', + usage: '/poll [질문] [옵션1] [옵션2] ...', + }, + { + command: '/vote', + description: '투표하기', + usage: '/vote [투표ID] [옵션번호]', + }, + { + command: '/endpoll', + description: '투표 종료하기', + usage: '/endpoll [투표ID]', + }, + { + command: '/clear', + description: '내 메시지 모두 삭제', + usage: '/clear', + }, + { + command: '/leave', + description: '채팅방 나가기', + usage: '/leave', + }, + { + command: '/dice', + description: '주사위 굴리기 (1-6)', + usage: '/dice', + }, + { + command: '/coin', + description: '동전 던지기 (앞면/뒷면)', + usage: '/coin', + }, + { + command: '/random', + description: '무작위 숫자 생성', + usage: '/random [최소값] [최대값]', + }, +] + +/** + * @typedef {Object} PollOption + * @property {number} optionId - 옵션 ID + * @property {string} text - 옵션 텍스트 + * @property {number} voteCount - 투표 수 + * @property {string[]} voters - 투표한 사용자 ID 목록 + */ + +/** + * @typedef {Object} PollCreateData + * @property {string} pollId - 투표 ID + * @property {string} question - 투표 질문 + * @property {PollOption[]} options - 투표 옵션 목록 + * @property {string} creatorId - 생성자 ID + * @property {string} createdAt - ISO-8601 생성 시간 + * @property {boolean} isActive - 활성 상태 + */ + +/** + * @typedef {Object} PollVoteData + * @property {string} pollId - 투표 ID + * @property {number} optionId - 선택한 옵션 ID + * @property {string} userId - 투표한 사용자 ID + * @property {PollOption[]} updatedOptions - 업데이트된 옵션 목록 + */ + +/** + * @typedef {Object} PollEndData + * @property {string} pollId - 투표 ID + * @property {PollOption[]} finalResults - 최종 결과 + * @property {string} endedBy - 종료한 사용자 ID + * @property {string} endedAt - ISO-8601 종료 시간 + */ + +/** + * @typedef {Object} SystemCommandData + * @property {string} commandType - 명령어 타입 (dice, coin, random, members, help) + * @property {string} userId - 명령어 실행 사용자 ID + * @property {Object} result - 명령어 실행 결과 + * @property {string} displayText - 표시할 텍스트 + */ + +/** + * @typedef {Object} ClearChatData + * @property {string} userId - 삭제 요청 사용자 ID + * @property {string[]} messageIds - 삭제된 메시지 ID 목록 + */ + +/** + * @typedef {Object} LeaveRoomData + * @property {string} userId - 퇴장한 사용자 ID + * @property {string} roomId - 방 ID + * @property {string} leftAt - ISO-8601 퇴장 시간 + */ + +/** + * @typedef {Object} MembersListData + * @property {string[]} memberIds - 멤버 ID 목록 + * @property {number} totalCount - 총 멤버 수 + */ + +/** + * 명령어 파싱 유틸리티 + * @param {string} message - 입력된 메시지 + * @returns {{isCommand: boolean, command: string, args: string[]}} + */ +export function parseCommand(message) { + const trimmed = message.trim() + + if (!trimmed.startsWith('/')) { + return { isCommand: false, command: '', args: [] } + } + + const parts = trimmed.split(/\s+/) + const command = parts[0].toLowerCase() + const args = parts.slice(1) + + return { + isCommand: true, + command, + args, + } +} + +/** + * 명령어가 유효한지 확인 + * @param {string} command - 명령어 (예: '/help') + * @returns {boolean} + */ +export function isValidCommand(command) { + return COMMANDS.some(cmd => cmd.command === command.toLowerCase()) +} + +/** + * 명령어 검색 (자동완성용) + * @param {string} input - 사용자 입력 + * @returns {Array} 일치하는 명령어 목록 + */ +export function searchCommands(input) { + const lowerInput = input.toLowerCase() + return COMMANDS.filter(cmd => cmd.command.startsWith(lowerInput)) +} + +/** + * 투표 결과 계산 + * @param {PollOption[]} options - 투표 옵션 목록 + * @returns {{totalVotes: number, percentages: number[]}} + */ +export function calculatePollResults(options) { + const totalVotes = options.reduce((sum, opt) => sum + opt.voteCount, 0) + const percentages = options.map(opt => + totalVotes > 0 ? Math.round((opt.voteCount / totalVotes) * 100) : 0 + ) + + return { totalVotes, percentages } +} + +/** + * 시스템 명령어 아이콘 및 색상 설정 + */ +export const SystemCommandConfig = { + dice: { + icon: '🎲', + color: '#8b5cf6', + bgColor: '#f5f3ff', + }, + coin: { + icon: '🪙', + color: '#f59e0b', + bgColor: '#fffbeb', + }, + random: { + icon: '🔢', + color: '#06b6d4', + bgColor: '#ecfeff', + }, + members: { + icon: '👥', + color: '#10b981', + bgColor: '#ecfdf5', + }, + help: { + icon: '❓', + color: '#6366f1', + bgColor: '#eef2ff', + }, +} From ba1cc05110172c9ab3ab65fdb29f48b57a25d507 Mon Sep 17 00:00:00 2001 From: DDING JOO Date: Sat, 24 Jan 2026 13:41:03 +0900 Subject: [PATCH 6/9] =?UTF-8?q?[FEAT]=20=EC=98=81=EC=96=B4=20=EB=81=9D?= =?UTF-8?q?=EB=A7=90=EC=9E=87=EA=B8=B0(Word=20Chain)=20=EA=B2=8C=EC=9E=84?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20(#205)=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wordchainService.js: API 서비스 (시작, 단어제출, 타임아웃, 종료) - WordchainLobbyPage: 게임 로비 (방 목록, 생성) - WordchainWaitingPage: 대기실 (참가자 대기, 게임 시작) - WordchainPlayPage: 게임 플레이 페이지 - WordDisplay: 현재 단어 & 다음 글자 표시 - WordchainTimer: 원형 타이머 (시간 감소 애니메이션) - PlayerList: 플레이어 목록 (활성/탈락 표시) - UsedWordsList: 사용된 단어 목록 - WordInput: 단어 입력 필드 - GameEndModal: 게임 종료 모달 (순위, 단어 학습) - WebSocket 핸들러 추가 (wordchain_*) - 라우트 및 사이드바 네비게이션 추가 --- src/App.jsx | 13 + .../freetalk/services/chatWebSocketService.js | 25 ++ .../components/wordchain/GameEndModal.jsx | 158 +++++++ .../games/components/wordchain/PlayerList.jsx | 89 ++++ .../components/wordchain/UsedWordsList.jsx | 49 +++ .../components/wordchain/WordDisplay.jsx | 73 ++++ .../games/components/wordchain/WordInput.jsx | 96 +++++ .../components/wordchain/WordchainTimer.jsx | 59 +++ .../games/pages/WordchainLobbyPage.jsx | 312 ++++++++++++++ src/domains/games/pages/WordchainPlayPage.jsx | 400 ++++++++++++++++++ .../games/pages/WordchainWaitingPage.jsx | 393 +++++++++++++++++ .../games/services/wordchainService.js | 58 +++ src/layouts/MainLayout/Sidebar/index.jsx | 7 + 13 files changed, 1732 insertions(+) create mode 100644 src/domains/games/components/wordchain/GameEndModal.jsx create mode 100644 src/domains/games/components/wordchain/PlayerList.jsx create mode 100644 src/domains/games/components/wordchain/UsedWordsList.jsx create mode 100644 src/domains/games/components/wordchain/WordDisplay.jsx create mode 100644 src/domains/games/components/wordchain/WordInput.jsx create mode 100644 src/domains/games/components/wordchain/WordchainTimer.jsx create mode 100644 src/domains/games/pages/WordchainLobbyPage.jsx create mode 100644 src/domains/games/pages/WordchainPlayPage.jsx create mode 100644 src/domains/games/pages/WordchainWaitingPage.jsx create mode 100644 src/domains/games/services/wordchainService.js diff --git a/src/App.jsx b/src/App.jsx index e8a4800..ab94f58 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -32,6 +32,9 @@ import { BadgeSection } from './domains/badge' import CatchmindLobbyPage from './domains/games/pages/CatchmindLobbyPage' import CatchmindWaitingPage from './domains/games/pages/CatchmindWaitingPage' import CatchmindPlayPage from './domains/games/pages/CatchmindPlayPage' +import WordchainLobbyPage from './domains/games/pages/WordchainLobbyPage' +import WordchainWaitingPage from './domains/games/pages/WordchainWaitingPage' +import WordchainPlayPage from './domains/games/pages/WordchainPlayPage' import { NewsListPage, NewsDetailPage, NewsQuizPage, NewsWordsPage, NewsStatsPage } from './domains/news' import { dailyService, statsService } from './domains/vocab/services/vocabService' import { getNewsStats, getDashboardStats } from './domains/news/services/newsService' @@ -269,6 +272,13 @@ function Dashboard() { path: '/games/catchmind', description: t('games.catchmindDesc') }, + { + id: 'wordchain', + title: '끝말잇기', + icon: GameIcon, + path: '/games/wordchain', + description: '영어 끝말잇기' + }, ], }, ] @@ -1176,6 +1186,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/src/domains/freetalk/services/chatWebSocketService.js b/src/domains/freetalk/services/chatWebSocketService.js index c670394..f8fbe75 100644 --- a/src/domains/freetalk/services/chatWebSocketService.js +++ b/src/domains/freetalk/services/chatWebSocketService.js @@ -213,6 +213,31 @@ class ChatWebSocketConnection { // 추측 메시지 - 일반 메시지로 처리 this.callbacks.onMessage?.(data) break + case 'wordchain_start': + case 'WORDCHAIN_START': + console.log('[ChatWebSocket] Wordchain start received:', data) + this.callbacks.onWordchainStart?.(data) + break + case 'wordchain_correct': + case 'WORDCHAIN_CORRECT': + console.log('[ChatWebSocket] Wordchain correct received:', data) + this.callbacks.onWordchainCorrect?.(data) + break + case 'wordchain_wrong': + case 'WORDCHAIN_WRONG': + console.log('[ChatWebSocket] Wordchain wrong received:', data) + this.callbacks.onWordchainWrong?.(data) + break + case 'wordchain_timeout': + case 'WORDCHAIN_TIMEOUT': + console.log('[ChatWebSocket] Wordchain timeout received:', data) + this.callbacks.onWordchainTimeout?.(data) + break + case 'wordchain_end': + case 'WORDCHAIN_END': + console.log('[ChatWebSocket] Wordchain end received:', data) + this.callbacks.onWordchainEnd?.(data) + break default: console.log('[ChatWebSocket] Unknown message type:', type || messageType, data) this.callbacks.onMessage?.(data) diff --git a/src/domains/games/components/wordchain/GameEndModal.jsx b/src/domains/games/components/wordchain/GameEndModal.jsx new file mode 100644 index 0000000..abcb3eb --- /dev/null +++ b/src/domains/games/components/wordchain/GameEndModal.jsx @@ -0,0 +1,158 @@ +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Box, + Typography, +} from '@mui/material' +import { + EmojiEvents as TrophyIcon, + ExitToApp as ExitIcon, + Replay as ReplayIcon, +} from '@mui/icons-material' +import { GAME_COLORS, GAME_TYPOGRAPHY } from '../../theme/gameTheme' + +/** + * GameEndModal - 게임 종료 모달 + */ +const GameEndModal = ({ open, winner, finalPlayers, onRestart, onExit, currentUserId }) => { + const sortedPlayers = [...(finalPlayers || [])] + .sort((a, b) => { + // 생존자가 우선 + if (a.isAlive && !b.isAlive) return -1 + if (!a.isAlive && b.isAlive) return 1 + // 제출한 단어 수로 정렬 + return (b.wordsSubmitted || 0) - (a.wordsSubmitted || 0) + }) + + return ( + + + + + 게임 종료! + + + + + {winner && ( + + + 우승자 + + + {winner.nickname || winner.userId} + {winner.userId === currentUserId && ' (나)'} + + + )} + + + 최종 순위 + + + + {sortedPlayers.map((player, index) => ( + + + + {player.isAlive ? ( + index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : `${index + 1}위` + ) : '❌'} + + + {player.nickname || player.userId} + {player.userId === currentUserId && ' (나)'} + + + + {player.wordsSubmitted || 0}단어 + + + ))} + + + + + + + + + ) +} + +export default GameEndModal diff --git a/src/domains/games/components/wordchain/PlayerList.jsx b/src/domains/games/components/wordchain/PlayerList.jsx new file mode 100644 index 0000000..2e31812 --- /dev/null +++ b/src/domains/games/components/wordchain/PlayerList.jsx @@ -0,0 +1,89 @@ +import { Box, Typography, Avatar } from '@mui/material' +import { CheckCircle as CheckIcon } from '@mui/icons-material' +import { GAME_COLORS } from '../../theme/gameTheme' +import { useThemeMode } from '../../../../contexts/ThemeContext' + +/** + * PlayerList - 플레이어 목록 (턴 & 생존 상태) + */ +const PlayerList = ({ players, currentTurnUserId, currentUserId }) => { + const { mode } = useThemeMode() + const isDark = mode === 'dark' + + return ( + + + 플레이어 + + + {players.map((player) => { + const isCurrentTurn = player.userId === currentTurnUserId + const isMe = player.userId === currentUserId + const isAlive = player.isAlive !== false + + return ( + + + {player.nickname?.[0] || player.userId?.[0] || '?'} + + + + {player.nickname || player.userId} + {isMe && ' (나)'} + + {isCurrentTurn && ( + + 턴 진행 중 + + )} + + {!isAlive && ( + + 탈락 + + )} + {isAlive && !isCurrentTurn && player.hasAnswered && ( + + )} + + ) + })} + + + ) +} + +export default PlayerList diff --git a/src/domains/games/components/wordchain/UsedWordsList.jsx b/src/domains/games/components/wordchain/UsedWordsList.jsx new file mode 100644 index 0000000..c366ec4 --- /dev/null +++ b/src/domains/games/components/wordchain/UsedWordsList.jsx @@ -0,0 +1,49 @@ +import { Box, Typography, Chip } from '@mui/material' +import { GAME_COLORS } from '../../theme/gameTheme' + +/** + * UsedWordsList - 사용된 단어 목록 + */ +const UsedWordsList = ({ words }) => { + return ( + + + 사용된 단어 ({words.length}) + + + {words.length === 0 ? ( + + 아직 사용된 단어가 없습니다 + + ) : ( + words.map((wordData, index) => ( + + )) + )} + + + ) +} + +export default UsedWordsList diff --git a/src/domains/games/components/wordchain/WordDisplay.jsx b/src/domains/games/components/wordchain/WordDisplay.jsx new file mode 100644 index 0000000..38a310e --- /dev/null +++ b/src/domains/games/components/wordchain/WordDisplay.jsx @@ -0,0 +1,73 @@ +import { Box, Typography } from '@mui/material' +import { GAME_COLORS, GAME_TYPOGRAPHY } from '../../theme/gameTheme' +import { useThemeMode } from '../../../../contexts/ThemeContext' + +/** + * WordDisplay - 현재 단어 & 다음 글자 표시 + */ +const WordDisplay = ({ currentWord, nextLetter, isMyTurn }) => { + const { mode } = useThemeMode() + const isDark = mode === 'dark' + + return ( + + {currentWord ? ( + <> + + 현재 단어 + + + {currentWord} + + + + 다음 시작 글자: + + + {nextLetter} + + + + ) : ( + + 첫 단어를 기다리는 중... + + )} + + ) +} + +export default WordDisplay diff --git a/src/domains/games/components/wordchain/WordInput.jsx b/src/domains/games/components/wordchain/WordInput.jsx new file mode 100644 index 0000000..c6d9587 --- /dev/null +++ b/src/domains/games/components/wordchain/WordInput.jsx @@ -0,0 +1,96 @@ +import { useState } from 'react' +import { Box, TextField, Button, Typography } from '@mui/material' +import { Send as SendIcon } from '@mui/icons-material' +import { GAME_COLORS } from '../../theme/gameTheme' + +/** + * WordInput - 단어 입력 필드 + */ +const WordInput = ({ onSubmit, disabled, nextLetter, isMyTurn }) => { + const [word, setWord] = useState('') + const [error, setError] = useState('') + + const handleSubmit = (e) => { + e.preventDefault() + + const trimmedWord = word.trim().toLowerCase() + + // 유효성 검사 + if (!trimmedWord) { + setError('단어를 입력하세요') + return + } + + if (nextLetter && trimmedWord[0] !== nextLetter.toLowerCase()) { + setError(`단어는 '${nextLetter}'로 시작해야 합니다`) + return + } + + // 알파벳만 허용 + if (!/^[a-z]+$/.test(trimmedWord)) { + setError('영어 알파벳만 입력하세요') + return + } + + setError('') + onSubmit(trimmedWord) + setWord('') + } + + const handleChange = (e) => { + setWord(e.target.value) + setError('') + } + + return ( + + {!isMyTurn && ( + + 다른 플레이어의 턴입니다 + + )} + + + + + + ) +} + +export default WordInput diff --git a/src/domains/games/components/wordchain/WordchainTimer.jsx b/src/domains/games/components/wordchain/WordchainTimer.jsx new file mode 100644 index 0000000..aac71cf --- /dev/null +++ b/src/domains/games/components/wordchain/WordchainTimer.jsx @@ -0,0 +1,59 @@ +import { Box, CircularProgress, Typography } from '@mui/material' +import { GAME_COLORS, GAME_TYPOGRAPHY } from '../../theme/gameTheme' + +/** + * WordchainTimer - 원형 타이머 + */ +const WordchainTimer = ({ timeLeft, timeLimit }) => { + const percentage = (timeLeft / timeLimit) * 100 + const isDanger = timeLeft <= 5 + + return ( + + + + + {timeLeft} + + + + ) +} + +export default WordchainTimer diff --git a/src/domains/games/pages/WordchainLobbyPage.jsx b/src/domains/games/pages/WordchainLobbyPage.jsx new file mode 100644 index 0000000..a4b8542 --- /dev/null +++ b/src/domains/games/pages/WordchainLobbyPage.jsx @@ -0,0 +1,312 @@ +import { useCallback, useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Container, + FormControlLabel, + Grid, + IconButton, + MenuItem, + Select, + Switch, + Typography, +} from '@mui/material' +import { + Add as AddIcon, + Link as LinkIcon, + Refresh as RefreshIcon, +} from '@mui/icons-material' +import GameRoomCard from '../components/GameRoomCard' +import CreateGameRoomModal from '../components/CreateGameRoomModal' +import { gameService } from '../services/gameService' +import { GAME_COLORS } from '../theme/gameTheme' +import { useAuth } from '../../../contexts/AuthContext' + +const WordchainLobbyPage = () => { + const navigate = useNavigate() + const { user } = useAuth() + + const [rooms, setRooms] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [createModalOpen, setCreateModalOpen] = useState(false) + const [creating, setCreating] = useState(false) + + // 필터 + const [filters, setFilters] = useState({ + level: '', + waitingOnly: true, + }) + + // 방 목록 조회 - gameType을 WORDCHAIN으로 필터링 + const fetchRooms = useCallback(async () => { + try { + setLoading(true) + setError(null) + + const params = {} + if (filters.level) params.level = filters.level + if (filters.waitingOnly) params.status = 'WAITING' + + const response = await gameService.getRooms(params) + // WORDCHAIN 타입 필터링 + const wordchainRooms = (response.data.rooms || []).filter( + room => room.gameType === 'WORDCHAIN' + ) + setRooms(wordchainRooms) + } catch (err) { + console.error('Failed to fetch rooms:', err) + setError('방 목록을 불러오는데 실패했습니다') + } finally { + setLoading(false) + } + }, [filters]) + + useEffect(() => { + fetchRooms() + }, [fetchRooms]) + + // 방 생성 + const handleCreateRoom = async (data) => { + try { + setCreating(true) + // gameType을 WORDCHAIN으로 설정 + const response = await gameService.createRoom({ + ...data, + gameType: 'WORDCHAIN', + }) + setCreateModalOpen(false) + // 생성된 방의 대기실로 이동 + navigate(`/games/wordchain/${response.data.roomId}/waiting`) + } catch (err) { + console.error('Failed to create room:', err) + setError('방 생성에 실패했습니다') + } finally { + setCreating(false) + } + } + + // 방 참가 + const handleJoinRoom = async (room) => { + try { + const response = await gameService.joinRoom(room.roomId) + // roomToken을 sessionStorage에 저장 (WebSocket 연결 시 사용) + if (response.data?.roomToken) { + sessionStorage.setItem(`roomToken_${room.roomId}`, response.data.roomToken) + } + navigate(`/games/wordchain/${room.roomId}/waiting`) + } catch (err) { + console.error('Failed to join room:', err) + setError(err.message || '방 참가에 실패했습니다') + } + } + + // 관전 + const handleSpectateRoom = (room) => { + navigate(`/games/wordchain/${room.roomId}/play?spectate=true`) + } + + return ( + + {/* 헤더 */} + + + + + + + + 끝말잇기 + + + 영어 단어로 끝말잇기를 즐겨보세요 + + + + + + {/* 필터 + 액션 */} + + {/* 필터 */} + + + + setFilters(prev => ({ ...prev, waitingOnly: e.target.checked }))} + sx={{ + '& .MuiSwitch-switchBase.Mui-checked': { + color: GAME_COLORS.primary, + }, + '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { + bgcolor: GAME_COLORS.primary, + }, + }} + /> + } + label={대기중만} + /> + + + + + + + + + {/* 방 만들기 버튼 */} + + + + {/* 에러 */} + {error && ( + setError(null)} sx={{ mb: 3, borderRadius: '12px' }}> + {error} + + )} + + {/* 방 목록 */} + {loading ? ( + + + + ) : rooms.length === 0 ? ( + + + + + + 대기 중인 방이 없습니다 + + + 새로운 게임방을 만들어보세요! + + + + ) : ( + + {rooms.map((room) => ( + + + + ))} + + )} + + {/* 방 생성 모달 */} + setCreateModalOpen(false)} + onCreate={handleCreateRoom} + loading={creating} + gameType="WORDCHAIN" + /> + + ) +} + +export default WordchainLobbyPage diff --git a/src/domains/games/pages/WordchainPlayPage.jsx b/src/domains/games/pages/WordchainPlayPage.jsx new file mode 100644 index 0000000..383d656 --- /dev/null +++ b/src/domains/games/pages/WordchainPlayPage.jsx @@ -0,0 +1,400 @@ +import { useCallback, useEffect, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Container, + Grid, + IconButton, + Typography, +} from '@mui/material' +import { + ExitToApp as ExitIcon, + Stop as StopIcon, +} from '@mui/icons-material' +import WordDisplay from '../components/wordchain/WordDisplay' +import WordchainTimer from '../components/wordchain/WordchainTimer' +import PlayerList from '../components/wordchain/PlayerList' +import UsedWordsList from '../components/wordchain/UsedWordsList' +import WordInput from '../components/wordchain/WordInput' +import GameEndModal from '../components/wordchain/GameEndModal' +import { gameService } from '../services/gameService' +import wordchainService from '../services/wordchainService' +import { GAME_COLORS } from '../theme/gameTheme' +import { useAuth } from '../../../contexts/AuthContext' +import { useThemeMode } from '../../../contexts/ThemeContext' +import { useChatWebSocket } from '../../freetalk/hooks/useChatWebSocket' + +const WordchainPlayPage = () => { + const { roomId } = useParams() + const navigate = useNavigate() + const { user } = useAuth() + const { mode } = useThemeMode() + const isDark = mode === 'dark' + const currentUserId = user?.userId || user?.username || user?.sub + + // WebSocket 연결 + const { + isConnected, + gameState: wsGameState, + error: wsError, + connect, + disconnect, + } = useChatWebSocket(roomId, currentUserId) + + // 로컬 게임 상태 + const [gameState, setGameState] = useState({ + status: 'PLAYING', + currentWord: null, + nextLetter: null, + currentTurnUserId: null, + turnStartTime: Date.now(), + turnTimeLimit: 15, + players: [], + usedWords: [], + winner: null, + }) + + const [room, setRoom] = useState(null) + const [loading, setLoading] = useState(true) + const [timeLeft, setTimeLeft] = useState(15) + const [showEndModal, setShowEndModal] = useState(false) + const [submitting, setSubmitting] = useState(false) + + const isMyTurn = gameState.currentTurnUserId === currentUserId + + // WebSocket gameState 업데이트 반영 + useEffect(() => { + if (wsGameState) { + console.log('[WordchainPlayPage] Received wsGameState:', wsGameState) + + setGameState(prev => ({ + ...prev, + status: wsGameState.status ?? prev.status, + currentWord: wsGameState.currentWord ?? prev.currentWord, + nextLetter: wsGameState.nextLetter ?? prev.nextLetter, + currentTurnUserId: wsGameState.currentTurnUserId ?? prev.currentTurnUserId, + turnStartTime: wsGameState.turnStartTime ?? prev.turnStartTime, + turnTimeLimit: wsGameState.turnTimeLimit ?? prev.turnTimeLimit ?? 15, + players: wsGameState.players ?? prev.players, + usedWords: wsGameState.usedWords ?? prev.usedWords, + winner: wsGameState.winner ?? prev.winner, + })) + + // 턴이 변경되면 타이머 리셋 + if (wsGameState.currentTurnUserId && wsGameState.currentTurnUserId !== prev.currentTurnUserId) { + setTimeLeft(wsGameState.turnTimeLimit ?? 15) + } + + // 게임 종료 처리 + if (wsGameState.status === 'FINISHED' && !showEndModal) { + setShowEndModal(true) + } + } + }, [wsGameState, showEndModal]) + + // 초기 로드 및 WebSocket 연결 + useEffect(() => { + const initGame = async () => { + try { + setLoading(true) + + // 방 정보 조회 + const roomResponse = await gameService.getRoom(roomId) + setRoom(roomResponse.data) + + // 게임 상태 조회 + let gameData + try { + const statusResponse = await wordchainService.getStatus(roomId) + gameData = statusResponse.data || statusResponse + } catch { + // 게임 상태가 없으면 시작 + const gameResponse = await wordchainService.start(roomId) + gameData = gameResponse.data || gameResponse + } + + setGameState({ + status: 'PLAYING', + currentWord: gameData.currentWord || null, + nextLetter: gameData.nextLetter || null, + currentTurnUserId: gameData.currentTurnUserId, + turnStartTime: gameData.turnStartTime || Date.now(), + turnTimeLimit: gameData.turnTimeLimit || 15, + players: gameData.players || roomResponse.data.participants || [], + usedWords: gameData.usedWords || [], + winner: null, + }) + + setTimeLeft(gameData.turnTimeLimit || 15) + + // WebSocket 연결 + console.log('[WordchainPlayPage] Connecting WebSocket...') + await connect() + console.log('[WordchainPlayPage] WebSocket connected') + } catch (err) { + console.error('Failed to init game:', err) + } finally { + setLoading(false) + } + } + + initGame() + + return () => { + console.log('[WordchainPlayPage] Disconnecting WebSocket...') + disconnect() + } + }, [roomId, currentUserId, connect, disconnect]) + + // 타이머 + useEffect(() => { + if (gameState.status !== 'PLAYING') return + + const interval = setInterval(() => { + setTimeLeft(prev => { + if (prev <= 1) { + // 시간 초과 + if (isMyTurn && isConnected) { + console.log('[WordchainPlayPage] Timer expired, sending timeout') + wordchainService.timeout(roomId).catch(err => { + console.error('Failed to send timeout:', err) + }) + } + return 0 + } + return prev - 1 + }) + }, 1000) + + return () => clearInterval(interval) + }, [gameState.status, isMyTurn, isConnected, roomId]) + + // 단어 제출 + const handleSubmitWord = async (word) => { + if (!isMyTurn || submitting) return + + try { + setSubmitting(true) + await wordchainService.submit(roomId, word) + // 서버에서 WebSocket으로 결과 브로드캐스트 + } catch (err) { + console.error('Failed to submit word:', err) + alert(err.response?.data?.message || err.message || '단어 제출에 실패했습니다') + } finally { + setSubmitting(false) + } + } + + // 게임 종료 + const handleStopGame = async () => { + try { + disconnect() + await wordchainService.stop(roomId) + sessionStorage.removeItem(`roomToken_${roomId}`) + navigate('/games/wordchain') + } catch (err) { + console.error('Failed to stop game:', err) + } + } + + // 재시작 + const handleRestart = async () => { + try { + disconnect() + await gameService.restartGame(roomId) + setShowEndModal(false) + navigate(`/games/wordchain/${roomId}/waiting`) + } catch (err) { + console.error('Failed to restart:', err) + setShowEndModal(false) + navigate(`/games/wordchain/${roomId}/waiting`) + } + } + + // 나가기 + const handleLeave = async () => { + try { + disconnect() + await gameService.leaveRoom(roomId) + } catch (err) { + console.error('Failed to leave room:', err) + } + sessionStorage.removeItem(`roomToken_${roomId}`) + navigate('/games/wordchain') + } + + if (loading) { + return ( + + + + ) + } + + return ( + + {/* 헤더 */} + + + + + + {room?.name || '끝말잇기'} + + + + + + + + + + + + {/* WebSocket 에러 */} + {wsError && ( + + + {wsError} + + + )} + + {/* 메인 컨텐츠 */} + + + {/* 좌측: 게임 영역 */} + + {/* 타이머 & 현재 턴 */} + + + + 현재 턴 + + + {gameState.players.find(p => p.userId === gameState.currentTurnUserId)?.nickname || + gameState.currentTurnUserId || + '대기 중'} + + {isMyTurn && ( + + )} + + + + + {/* 현재 단어 & 다음 글자 */} + + + + + {/* 단어 입력 */} + + + + + + {/* 우측: 플레이어 & 사용된 단어 */} + + {/* 플레이어 목록 */} + + + + + {/* 사용된 단어 */} + + + + + + + + {/* 게임 종료 모달 */} + + + ) +} + +export default WordchainPlayPage diff --git a/src/domains/games/pages/WordchainWaitingPage.jsx b/src/domains/games/pages/WordchainWaitingPage.jsx new file mode 100644 index 0000000..e33642c --- /dev/null +++ b/src/domains/games/pages/WordchainWaitingPage.jsx @@ -0,0 +1,393 @@ +import { useCallback, useEffect, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { + Alert, + Box, + Button, + Card, + Chip, + CircularProgress, + Container, + IconButton, + Typography, +} from '@mui/material' +import { + ArrowBack as ArrowBackIcon, + PlayArrow as PlayIcon, + Settings as SettingsIcon, +} from '@mui/icons-material' +import ParticipantList from '../components/ParticipantList' +import WaitingChat from '../components/WaitingChat' +import { gameService } from '../services/gameService' +import { GAME_COLORS } from '../theme/gameTheme' +import { useAuth } from '../../../contexts/AuthContext' +import { useThemeMode } from '../../../contexts/ThemeContext' +import { useChatWebSocket } from '../../freetalk/hooks/useChatWebSocket' + +const WordchainWaitingPage = () => { + const { roomId } = useParams() + const navigate = useNavigate() + const { user } = useAuth() + const { mode } = useThemeMode() + const isDark = mode === 'dark' + const currentUserId = user?.userId || user?.username || user?.sub + + // WebSocket 연결 + const { + isConnected, + messages: wsMessages, + gameState: wsGameState, + error: wsError, + connect, + disconnect, + sendMessage: wsSendMessage, + } = useChatWebSocket(roomId, currentUserId) + + const [room, setRoom] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [starting, setStarting] = useState(false) + + // 채팅 메시지 (WebSocket 연동) + const [messages, setMessages] = useState([ + { + id: 'system-1', + content: '게임 대기실에 입장했습니다.', + isSystem: true, + createdAt: new Date().toISOString(), + }, + ]) + + // WebSocket 메시지 동기화 + useEffect(() => { + if (wsMessages && wsMessages.length > 0) { + setMessages(prev => { + const systemMessages = prev.filter(m => m.isSystem && m.id === 'system-1') + const existingIds = new Set(systemMessages.map(m => m.id)) + const uniqueWsMessages = wsMessages.filter(m => !existingIds.has(m.id)) + const allMessages = [...systemMessages, ...uniqueWsMessages] + const seen = new Set() + return allMessages.filter(m => { + if (seen.has(m.id)) return false + seen.add(m.id) + return true + }) + }) + } + }, [wsMessages]) + + // 게임 시작 감지 (WebSocket GAME_START 이벤트) + useEffect(() => { + if (wsGameState?.status === 'PLAYING') { + console.log('[WordchainWaitingPage] Game started via WebSocket, navigating to play page') + navigate(`/games/wordchain/${roomId}/play`) + } + }, [wsGameState?.status, roomId, navigate]) + + // 방 정보 조회 + const fetchRoom = useCallback(async (showLoading = false) => { + try { + if (showLoading) { + setLoading(true) + } + const response = await gameService.getRoom(roomId) + setRoom(response.data) + + // 게임이 시작되면 플레이 페이지로 이동 + if (response.data.status === 'PLAYING') { + navigate(`/games/wordchain/${roomId}/play`) + } + } catch (err) { + console.error('Failed to fetch room:', err) + setError('방 정보를 불러오는데 실패했습니다') + } finally { + if (showLoading) { + setLoading(false) + } + } + }, [roomId, navigate]) + + // 초기 로딩 및 WebSocket 연결 + useEffect(() => { + const init = async () => { + await fetchRoom(true) + console.log('[WordchainWaitingPage] Connecting WebSocket...') + try { + await connect() + console.log('[WordchainWaitingPage] WebSocket connected') + } catch (err) { + console.error('[WordchainWaitingPage] WebSocket connection failed:', err) + } + } + init() + + return () => { + console.log('[WordchainWaitingPage] Disconnecting WebSocket...') + disconnect() + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + // 주기적 새로고침 + useEffect(() => { + const interval = setInterval(() => fetchRoom(false), 3000) + return () => clearInterval(interval) + }, [fetchRoom]) + + // 게임 시작 + const handleStartGame = async () => { + try { + setStarting(true) + await gameService.startGame(roomId) + navigate(`/games/wordchain/${roomId}/play`) + } catch (err) { + console.error('Failed to start game:', err) + const errorMessage = err.response?.data?.message || err.message || '게임 시작에 실패했습니다' + setError(errorMessage) + } finally { + setStarting(false) + } + } + + // 방 나가기 + const handleLeaveRoom = async () => { + try { + disconnect() + await gameService.leaveRoom(roomId) + sessionStorage.removeItem(`roomToken_${roomId}`) + navigate('/games/wordchain') + } catch (err) { + console.error('Failed to leave room:', err) + } + } + + // 채팅 메시지 전송 + const handleSendMessage = (content) => { + if (isConnected) { + console.log('[WordchainWaitingPage] Sending message via WebSocket:', content) + wsSendMessage(content, 'TEXT') + } else { + const newMessage = { + id: `msg-${Date.now()}`, + userId: currentUserId, + nickname: user?.nickname || user?.username || '플레이어', + content, + createdAt: new Date().toISOString(), + } + setMessages(prev => [...prev, newMessage]) + } + } + + const isHost = room?.hostId === currentUserId + const canStart = isHost && room?.currentParticipants >= 2 + + if (loading) { + return ( + + + + ) + } + + if (!room) { + return ( + + + 방을 찾을 수 없습니다 + + + + ) + } + + return ( + + {/* 헤더 */} + + + + + + + + + + {room.name} + + + + + + + + + + {isHost && ( + + + + )} + + + + + {/* 에러 */} + {(error || wsError) && ( + + setError(null)} sx={{ borderRadius: '12px' }}> + {error || wsError} + + + )} + + {/* 메인 컨텐츠 */} + + + {/* 좌측: 참가자 목록 */} + + + + + {/* 우측: 대기 채팅 */} + + + + 대기 채팅 + + + + + + + + + {/* 하단: 게임 설정 + 시작 버튼 */} + + + + + 턴 시간 + + + {room.gameSettings?.turnTimeLimit || 15}초 + + + + + 난이도 + + + {room.level || 'INTERMEDIATE'} + + + + + {isHost ? ( + + ) : ( + + + 방장이 게임을 시작할 때까지 기다려주세요 + + + + )} + + + + ) +} + +export default WordchainWaitingPage diff --git a/src/domains/games/services/wordchainService.js b/src/domains/games/services/wordchainService.js new file mode 100644 index 0000000..b63a441 --- /dev/null +++ b/src/domains/games/services/wordchainService.js @@ -0,0 +1,58 @@ +/** + * Word Chain Service - 백엔드 API 연동 + * 영어 끝말잇기 게임 관련 REST API 호출 + */ +import chatApi from '../../../api/chatApi' + +/** + * 끝말잇기 게임 진행 관련 API + */ +export const wordchainService = { + /** + * 게임 시작 + * @param {string} roomId + */ + start: async (roomId) => { + const response = await chatApi.post(`/chat/rooms/${roomId}/wordchain/start`, {}) + return response.data + }, + + /** + * 단어 제출 + * @param {string} roomId + * @param {string} word + */ + submit: async (roomId, word) => { + const response = await chatApi.post(`/chat/rooms/${roomId}/wordchain/submit`, { word }) + return response.data + }, + + /** + * 타임아웃 처리 + * @param {string} roomId + */ + timeout: async (roomId) => { + const response = await chatApi.post(`/chat/rooms/${roomId}/wordchain/timeout`, {}) + return response.data + }, + + /** + * 게임 중단 + * @param {string} roomId + */ + stop: async (roomId) => { + const response = await chatApi.post(`/chat/rooms/${roomId}/wordchain/stop`, {}) + return response.data + }, + + /** + * 게임 상태 조회 + * @param {string} roomId + */ + getStatus: async (roomId) => { + const response = await chatApi.get(`/chat/rooms/${roomId}/wordchain/status`) + return response.data + }, +} + +export default wordchainService diff --git a/src/layouts/MainLayout/Sidebar/index.jsx b/src/layouts/MainLayout/Sidebar/index.jsx index fa9dbc5..abc6754 100644 --- a/src/layouts/MainLayout/Sidebar/index.jsx +++ b/src/layouts/MainLayout/Sidebar/index.jsx @@ -169,6 +169,13 @@ const Sidebar = ({open, collapsed, onToggleCollapse, onClose}) => { path: '/games/catchmind', description: t('games.catchmindDesc'), }, + { + id: 'wordchain', + label: '끝말잇기', + icon: GameIcon, + path: '/games/wordchain', + description: '영어 끝말잇기', + }, ], }, ], From d979845983ccda75df13d954cc578791ff55030b Mon Sep 17 00:00:00 2001 From: DDING JOO Date: Sat, 24 Jan 2026 13:43:43 +0900 Subject: [PATCH 7/9] =?UTF-8?q?[FIX]=20=EC=B1=84=ED=8C=85=20=EC=8A=AC?= =?UTF-8?q?=EB=9E=98=EC=8B=9C=20=EB=AA=85=EB=A0=B9=EC=96=B4=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95=20(#207)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [FEAT] 채팅 슬래시 명령어 시스템 구현 (#200) - 채팅 명령어 타입 정의 (chatCommandTypes.js) - CommandAutocomplete 컴포넌트 (/ 입력 시 명령어 자동완성) - PollCard, PollResultBar 컴포넌트 (투표 UI) - SystemCommandMessage 컴포넌트 (주사위, 동전, 랜덤 등) - WebSocket 서비스에 새 메시지 타입 핸들링 추가 - ChatRoomPage에 명령어 처리 로직 통합 지원 명령어: - /help, /members, /leave, /clear - /dice, /coin, /random - /poll, /vote, /endpoll * [FIX] 시스템 명령어 메시지 백엔드 응답 구조 대응 - useChatWebSocket: 백엔드 응답을 프론트엔드 형식으로 변환 - SystemCommandMessage: 다양한 응답 구조 처리 - displayText(message) 필드 우선 표시 * [FIX] 시스템 명령어 content 필드 매핑 수정 * [DEBUG] 시스템 명령어 메시지 디버깅 로그 추가 * [DEBUG] 시스템 명령어 메시지 추가 로그 * [DEBUG] ChatRoomPage 메시지 렌더링 로그 추가 * [DEBUG] SYSTEM_COMMAND 조건 체크 로그 * [FIX] ChatRoomModal에 SYSTEM_COMMAND 메시지 렌더링 추가 * [CLEANUP] 디버깅 로그 제거 --- .../freetalk/components/ChatRoomModal.jsx | 14 +++++- .../components/SystemCommandMessage.jsx | 49 ++++++++++++------- .../freetalk/hooks/useChatWebSocket.js | 17 +++++-- 3 files changed, 56 insertions(+), 24 deletions(-) diff --git a/src/domains/freetalk/components/ChatRoomModal.jsx b/src/domains/freetalk/components/ChatRoomModal.jsx index c62ca79..1856abc 100644 --- a/src/domains/freetalk/components/ChatRoomModal.jsx +++ b/src/domains/freetalk/components/ChatRoomModal.jsx @@ -35,6 +35,8 @@ import {useAuth} from '../../../contexts/AuthContext' import {useThemeMode} from '../../../contexts/ThemeContext' import {DESIGN_TOKENS, getChatStyles} from '../../../theme/theme' import {useChatWebSocket} from '../hooks/useChatWebSocket' +import SystemCommandMessage from './SystemCommandMessage' +import {MessageType} from '../types/chatCommandTypes' const ChatRoomModal = ({open, onClose, room, onLeave}) => { const theme = useTheme() @@ -456,7 +458,15 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { ) : ( - messages.map((message) => ( + messages.map((message) => { + // 시스템 명령어 메시지 (SYSTEM_COMMAND) + if (message.messageType === MessageType.SYSTEM_COMMAND || message.messageType === 'SYSTEM_COMMAND') { + return ( + + ) + } + + return ( { )} - )) + )}) )}
diff --git a/src/domains/freetalk/components/SystemCommandMessage.jsx b/src/domains/freetalk/components/SystemCommandMessage.jsx index 12a1423..253382f 100644 --- a/src/domains/freetalk/components/SystemCommandMessage.jsx +++ b/src/domains/freetalk/components/SystemCommandMessage.jsx @@ -18,9 +18,18 @@ const SystemCommandMessage = ({ data }) => { const { mode } = useThemeMode() const isDark = mode === 'dark' - const config = SystemCommandConfig[data.commandType] || SystemCommandConfig.help + // 명령어 타입 추출 + const commandType = data?.commandType || data?.type || data?.raw?.type || 'help' + const config = SystemCommandConfig[commandType] || SystemCommandConfig.help const { icon, color, bgColor } = config + // 표시 텍스트 추출 + const displayText = data?.displayText || data?.message || data?.content || '' + const userId = data?.userId || data?.nickname || '' + + // 결과 값 추출 + const result = data?.result || data?.raw || {} + return ( { {/* 내용 */} - - - {data.userId} - + {/* displayText가 있으면 그대로 표시 (백엔드에서 이미 포맷팅됨) */} + {displayText ? ( - {data.displayText} + {displayText} - - - {/* 추가 결과 정보 */} - {renderCommandResult(data.commandType, data.result, isDark)} + ) : ( + <> + + + {userId} + + + {/* 추가 결과 정보 */} + {renderCommandResult(commandType, result, isDark)} + + )} diff --git a/src/domains/freetalk/hooks/useChatWebSocket.js b/src/domains/freetalk/hooks/useChatWebSocket.js index 8aad516..fd6eaaa 100644 --- a/src/domains/freetalk/hooks/useChatWebSocket.js +++ b/src/domains/freetalk/hooks/useChatWebSocket.js @@ -165,14 +165,21 @@ export function useChatWebSocket(roomId, userId) { }, onSystemCommand: (data) => { - console.log('[useChatWebSocket] System command:', data) - const commandData = data.data || data + const commandData = data.data || {} const commandMessage = { id: `syscmd-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, messageType: 'SYSTEM_COMMAND', - userId: commandData.userId, - createdAt: new Date().toISOString(), - data: commandData, + userId: commandData.userId || commandData.nickname || data.userId, + createdAt: data.createdAt || new Date().toISOString(), + data: { + commandType: commandData.type || 'help', + userId: commandData.userId || commandData.nickname, + displayText: data.content || data.message || '', + result: typeof commandData.result === 'object' + ? commandData.result + : { value: commandData.result }, + raw: commandData, + }, } setMessages((prev) => [...prev, commandMessage]) }, From 44fa7d9ab155c75a52ed00bf73651f3fa774c108 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Sun, 25 Jan 2026 18:58:06 +0900 Subject: [PATCH 8/9] =?UTF-8?q?refactor=20:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8/?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=A0=95=EC=83=81?= =?UTF-8?q?=ED=99=94=20=EB=B0=8F=20=EC=B1=84=ED=8C=85=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20=EC=97=B0=EA=B2=B0=20=EB=B0=8F=20=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=ED=97=A4=EB=8D=94=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=83=81=ED=83=9C=20=ED=91=9C=EC=8B=9C=20(#208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor : 프리토킹 사용자 닉네임 수정 * refactor : 메인 헤더 프로필 드롭다운 메뉴 사용자 정보 동기화 리팩토링 --- src/App.jsx | 11 ++ src/aws-config.js | 4 +- .../freetalk/hooks/useChatWebSocket.js | 11 +- src/domains/freetalk/pages/ChatRoomPage.jsx | 101 ++++++++-------- src/domains/profile/hooks/useProfile.js | 35 ++++++ .../profile/services/profileService.js | 39 ++++++ src/domains/profile/store/profileSlice.js | 114 ++++++++++++++++++ src/layouts/MainLayout/Header/index.jsx | 82 +++++++------ src/store/index.js | 5 +- 9 files changed, 304 insertions(+), 98 deletions(-) create mode 100644 src/domains/profile/hooks/useProfile.js create mode 100644 src/domains/profile/services/profileService.js create mode 100644 src/domains/profile/store/profileSlice.js diff --git a/src/App.jsx b/src/App.jsx index ab94f58..6896eeb 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react' import { Navigate, Route, Routes, useNavigate } from 'react-router-dom' +import { useDispatch } from 'react-redux' import { Box, Button, Card, CardContent, CircularProgress, Collapse, Container, Grid, Typography } from '@mui/material' import { ChevronRight as ChevronRightIcon, @@ -43,6 +44,7 @@ import { useSettings } from './contexts/SettingsContext' import { useAuth } from './contexts/AuthContext' import LoginPage from './pages/Login' import SignUpPage from './pages/SignUp' +import { fetchMyProfile } from "./domains/profile/store/profileSlice"; function ProtectedRoute({ children }) { @@ -1141,8 +1143,17 @@ function NotFound() { } function App() { + const dispatch = useDispatch() + const { isAuthenticated } = useAuth() const { activeRoom, closeChatRoom } = useChat() + useEffect(() => { + if (isAuthenticated) { + // redux로 프로필 정보 API(/users/profile/me) 호출 + dispatch(fetchMyProfile()) + } + }, [isAuthenticated, dispatch]) + const handleRefreshRooms = () => { // Refresh rooms list after leaving a room } diff --git a/src/aws-config.js b/src/aws-config.js index d4d0a9d..6d2b672 100644 --- a/src/aws-config.js +++ b/src/aws-config.js @@ -1,8 +1,8 @@ const awsConfig = { Auth: { Cognito: { - userPoolId: 'ap-northeast-2_ezDwzFCzR', - userPoolClientId: '4ns077jcr1pkue2vvisr6qdpu5', + userPoolId: import.meta.env.VITE_COGNITO_POOL_ID, + userPoolClientId: import.meta.env.VITE_COGNITO_CLIENT_ID, loginWith: { email: true, diff --git a/src/domains/freetalk/hooks/useChatWebSocket.js b/src/domains/freetalk/hooks/useChatWebSocket.js index fd6eaaa..633d755 100644 --- a/src/domains/freetalk/hooks/useChatWebSocket.js +++ b/src/domains/freetalk/hooks/useChatWebSocket.js @@ -1,6 +1,6 @@ -import {useCallback, useEffect, useRef, useState} from 'react' -import {chatWebSocketService} from '../services/chatWebSocketService' -import {chatRoomService} from '../../chat/services/chatService' +import { useCallback, useEffect, useRef, useState } from 'react' +import { chatWebSocketService } from '../services/chatWebSocketService' +import { chatRoomService } from '../../chat/services/chatService' /** * Chat WebSocket 훅 @@ -26,10 +26,10 @@ export function useChatWebSocket(roomId, userId) { * WebSocket 연결 */ const connect = useCallback(async (forceNewToken = false) => { - console.log('[useChatWebSocket] Attempting to connect...', {roomId, userId, forceNewToken}) + console.log('[useChatWebSocket] Attempting to connect...', { roomId, userId, forceNewToken }) if (!roomId || !userId) { - console.error('[useChatWebSocket] roomId and userId are required', {roomId, userId}) + console.error('[useChatWebSocket] roomId and userId are required', { roomId, userId }) return } @@ -74,6 +74,7 @@ export function useChatWebSocket(roomId, userId) { id: messageId, content: data.content, userId: data.userId, + nickname: data.nickname, messageType: data.messageType || 'TEXT', createdAt: data.createdAt || new Date().toISOString(), isOwn: data.userId === userId, diff --git a/src/domains/freetalk/pages/ChatRoomPage.jsx b/src/domains/freetalk/pages/ChatRoomPage.jsx index 2a220d6..781a891 100644 --- a/src/domains/freetalk/pages/ChatRoomPage.jsx +++ b/src/domains/freetalk/pages/ChatRoomPage.jsx @@ -1,5 +1,5 @@ -import {useCallback, useEffect, useRef, useState} from 'react' -import {useNavigate, useParams} from 'react-router-dom' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' import { Alert, AppBar, @@ -21,31 +21,31 @@ import { Send as SendIcon, VolumeUp as VolumeUpIcon, } from '@mui/icons-material' -import {chatRoomService, messageService, voiceService} from '../../chat/services/chatService' -import {useAuth} from '../../../contexts/AuthContext' -import {useChatWebSocket} from '../hooks/useChatWebSocket' -import {useThemeMode} from '../../../contexts/ThemeContext' +import { chatRoomService, messageService, voiceService } from '../../chat/services/chatService' +import { useAuth } from '../../../contexts/AuthContext' +import { useChatWebSocket } from '../hooks/useChatWebSocket' +import { useThemeMode } from '../../../contexts/ThemeContext' import CommandAutocomplete from '../components/CommandAutocomplete' import PollCard from '../components/PollCard' import SystemCommandMessage from '../components/SystemCommandMessage' -import {parseCommand, MessageType} from '../types/chatCommandTypes' +import { parseCommand, MessageType } from '../types/chatCommandTypes' const levelColors = { - beginner: {bg: '#e8f5e9', color: '#2e7d32', label: '초급'}, - intermediate: {bg: '#fff3e0', color: '#ef6c00', label: '중급'}, - advanced: {bg: '#fce4ec', color: '#c2185b', label: '고급'}, + beginner: { bg: '#e8f5e9', color: '#2e7d32', label: '초급' }, + intermediate: { bg: '#fff3e0', color: '#ef6c00', label: '중급' }, + advanced: { bg: '#fce4ec', color: '#c2185b', label: '고급' }, } const ChatRoomPage = () => { - const {roomId} = useParams() + const { roomId } = useParams() const navigate = useNavigate() - const {user} = useAuth() - const {mode} = useThemeMode() + const { user } = useAuth() + const { mode } = useThemeMode() const isDark = mode === 'dark' const currentUserId = user?.userId || user?.username || user?.sub // 디버깅: 사용자 정보 확인 - console.log('[ChatRoomPage] User info:', {user, currentUserId, roomId}) + console.log('[ChatRoomPage] User info:', { user, currentUserId, roomId }) const messagesEndRef = useRef(null) const messagesContainerRef = useRef(null) @@ -90,11 +90,12 @@ const ChatRoomPage = () => { // 기존 메시지 목록 조회 (초기 로드용) const fetchMessages = useCallback(async () => { try { - const response = await messageService.getList(roomId, {limit: 50}) + const response = await messageService.getList(roomId, { limit: 50 }) const transformedMessages = (response.messages || []).map((msg, index) => ({ id: msg.messageId || `msg-${index}-${Date.now()}`, content: msg.content, userId: msg.userId, + nickname: msg.nickname, messageType: msg.messageType, createdAt: new Date(msg.createdAt), isOwn: msg.userId === currentUserId, @@ -122,12 +123,12 @@ const ChatRoomPage = () => { // WebSocket 연결 (별도 effect) useEffect(() => { - console.log('[ChatRoomPage] WebSocket effect triggered:', {roomId, currentUserId, isConnected}) + console.log('[ChatRoomPage] WebSocket effect triggered:', { roomId, currentUserId, isConnected }) if (currentUserId && roomId) { - console.log('[ChatRoomPage] Connecting WebSocket...', {roomId, currentUserId}) + console.log('[ChatRoomPage] Connecting WebSocket...', { roomId, currentUserId }) wsConnect() } else { - console.log('[ChatRoomPage] Missing required values:', {roomId, currentUserId}) + console.log('[ChatRoomPage] Missing required values:', { roomId, currentUserId }) } return () => { @@ -138,7 +139,7 @@ const ChatRoomPage = () => { // 스크롤 맨 아래로 const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({behavior: 'smooth'}) + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) } useEffect(() => { @@ -161,7 +162,7 @@ const ChatRoomPage = () => { setShowCommandAutocomplete(false) // 명령어 파싱 - const {isCommand, command, args} = parseCommand(messageContent) + const { isCommand, command, args } = parseCommand(messageContent) // /leave 명령어는 클라이언트에서 직접 처리 if (isCommand && command === '/leave') { @@ -293,23 +294,23 @@ const ChatRoomPage = () => { if (loading) { return ( - - + + ) } return ( - + {/* 헤더 */} - navigate('/freetalk/people')} sx={{mr: 1}}> - + navigate('/freetalk/people')} sx={{ mr: 1 }}> + - - - + + + {room?.name || '채팅방'} {/* 연결 상태 표시 */} @@ -320,7 +321,7 @@ const ChatRoomPage = () => { }} /> - + {room?.level && ( { - + - + {/* 에러 메시지 */} {error && ( - + {error} )} @@ -370,7 +371,7 @@ const ChatRoomPage = () => { }} > {messages.length === 0 ? ( - + 아직 메시지가 없습니다. 첫 메시지를 보내보세요! @@ -380,7 +381,7 @@ const ChatRoomPage = () => { // 투표 메시지 if (message.messageType === MessageType.POLL_CREATE) { return ( - + { // 시스템 명령어 메시지 if (message.messageType === MessageType.SYSTEM_COMMAND) { return ( - + ) } @@ -411,7 +412,7 @@ const ChatRoomPage = () => { > {/* 시스템 메시지 */} {message.isSystem ? ( - + {message.content} @@ -420,8 +421,8 @@ const ChatRoomPage = () => { <> {/* 아바타 (상대방만) */} {!message.isOwn && ( - - {message.userId?.charAt(0)?.toUpperCase() || 'U'} + + {(message.nickname || message.userId)?.charAt(0)?.toUpperCase() || 'U'} )} @@ -435,13 +436,13 @@ const ChatRoomPage = () => { > {/* 사용자 이름 (상대방만) */} {!message.isOwn && ( - - {message.userId} + + {message.nickname || message.userId} )} {/* 메시지 버블 */} - + {message.isOwn && ( {formatTime(message.createdAt)} @@ -463,23 +464,23 @@ const ChatRoomPage = () => { opacity: message.isPending ? 0.7 : 1, }} > - + {message.content} {!message.isOwn && ( - + handlePlayTTS(message.id)} disabled={playingTTS === message.id} - sx={{p: 0.5}} + sx={{ p: 0.5 }} > {playingTTS === message.id ? ( - + ) : ( - + )} @@ -495,7 +496,7 @@ const ChatRoomPage = () => { ) }) )} -
+
{/* 입력 영역 */} @@ -539,11 +540,11 @@ const ChatRoomPage = () => { sx={{ bgcolor: 'primary.main', color: 'white', - '&:hover': {bgcolor: 'primary.dark'}, - '&:disabled': {bgcolor: 'grey.300'}, + '&:hover': { bgcolor: 'primary.dark' }, + '&:disabled': { bgcolor: 'grey.300' }, }} > - + diff --git a/src/domains/profile/hooks/useProfile.js b/src/domains/profile/hooks/useProfile.js new file mode 100644 index 0000000..3e965c4 --- /dev/null +++ b/src/domains/profile/hooks/useProfile.js @@ -0,0 +1,35 @@ +import { useEffect } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { + fetchMyProfile, + updateProfile, + uploadProfileImage, + clearError +} from '../store/profileSlice' + +export const useProfile = () => { + const dispatch = useDispatch() + const { profile, loading, error, updateLoading, imageUploading } = useSelector( + (state) => state.profile + ) + + useEffect(() => { + if (!profile && !loading && !error) { + dispatch(fetchMyProfile()) + } + }, [dispatch, profile, loading, error]) + + return { + profile, + loading, + error, + updateLoading, + imageUploading, + updateProfile: (data) => dispatch(updateProfile(data)).unwrap(), + uploadImage: (file) => dispatch(uploadProfileImage(file)).unwrap(), + clearError: () => dispatch(clearError()), + refetch: () => dispatch(fetchMyProfile()) + } +} + +export default useProfile \ No newline at end of file diff --git a/src/domains/profile/services/profileService.js b/src/domains/profile/services/profileService.js new file mode 100644 index 0000000..c41fcdc --- /dev/null +++ b/src/domains/profile/services/profileService.js @@ -0,0 +1,39 @@ +import api from '../../../api/axios' + +const profileService = { + // 내 프로필 조회 + getMyProfile: async () => { + const response = await api.get('/users/profile/me') + return response.data + }, + + // 프로필 수정 (닉네임, 레벨) + updateProfile: async ({ nickname, level, profileUrl }) => { + const response = await api.put('/users/profile/me', { + nickname, + level, + profileUrl + }) + return response.data + }, + + // 이미지 업로드 URL 발급 + getImageUploadUrl: async (fileName, contentType) => { + const response = await api.post('/users/profile/me/image', { + fileName, + contentType + }) + return response.data + }, + + // S3에 이미지 직접 업로드 + uploadImageToS3: async (uploadUrl, file) => { + await fetch(uploadUrl, { + method: 'PUT', + headers: { 'Content-Type': file.type }, + body: file + }) + } +} + +export default profileService \ No newline at end of file diff --git a/src/domains/profile/store/profileSlice.js b/src/domains/profile/store/profileSlice.js new file mode 100644 index 0000000..76995ec --- /dev/null +++ b/src/domains/profile/store/profileSlice.js @@ -0,0 +1,114 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' +import profileService from '../services/profileService' + +// 프로필 조회 +export const fetchMyProfile = createAsyncThunk( + 'profile/fetchMyProfile', + async (_, { rejectWithValue }) => { + try { + const response = await profileService.getMyProfile() + return response.data || response + } catch (error) { + const status = error.response?.status + const message = error.response?.data?.error || error.message || '프로필 조회 실패' + return rejectWithValue({ message, status }) + } + } +) + +// 프로필 수정 +export const updateProfile = createAsyncThunk( + 'profile/updateProfile', + async (data, { rejectWithValue }) => { + try { + const response = await profileService.updateProfile(data) + return response.data + } catch (error) { + return rejectWithValue(error.response?.data?.error || '프로필 수정 실패') + } + } +) + +// 이미지 업로드 +export const uploadProfileImage = createAsyncThunk( + 'profile/uploadImage', + async (file, { dispatch, rejectWithValue }) => { + try { + const urlResponse = await profileService.getImageUploadUrl(file.name, file.type) + const { uploadUrl, imageUrl } = urlResponse.data + + await profileService.uploadImageToS3(uploadUrl, file) + await dispatch(updateProfile({ profileUrl: imageUrl })) + + return imageUrl + } catch (error) { + return rejectWithValue('이미지 업로드 실패') + } + } +) + +const profileSlice = createSlice({ + name: 'profile', + initialState: { + profile: null, + loading: false, + error: null, + updateLoading: false, + imageUploading: false, + authError: false + }, + reducers: { + clearError: (state) => { state.error = null }, + clearProfile: (state) => { state.profile = null } + }, + extraReducers: (builder) => { + builder + // fetchMyProfile + .addCase(fetchMyProfile.pending, (state) => { + state.loading = true + state.error = null + }) + .addCase(fetchMyProfile.fulfilled, (state, action) => { + state.loading = false + state.profile = action.payload + }) + .addCase(fetchMyProfile.rejected, (state, action) => { + state.loading = false + state.error = action.payload?.message || action.payload + + const status = action.payload?.status + const message = String(action.payload?.message || action.payload || '') + + if (status === 401 || message.includes('401') || message.includes('인증')) { + state.profile = null + state.authError = true + } + }) + // updateProfile + .addCase(updateProfile.pending, (state) => { + state.updateLoading = true + }) + .addCase(updateProfile.fulfilled, (state, action) => { + state.updateLoading = false + state.profile = action.payload + }) + .addCase(updateProfile.rejected, (state, action) => { + state.updateLoading = false + state.error = action.payload + }) + // uploadProfileImage + .addCase(uploadProfileImage.pending, (state) => { + state.imageUploading = true + }) + .addCase(uploadProfileImage.fulfilled, (state) => { + state.imageUploading = false + }) + .addCase(uploadProfileImage.rejected, (state, action) => { + state.imageUploading = false + state.error = action.payload + }) + } +}) + +export const { clearError, clearProfile } = profileSlice.actions +export default profileSlice.reducer \ No newline at end of file diff --git a/src/layouts/MainLayout/Header/index.jsx b/src/layouts/MainLayout/Header/index.jsx index f52deba..d4c6583 100644 --- a/src/layouts/MainLayout/Header/index.jsx +++ b/src/layouts/MainLayout/Header/index.jsx @@ -1,5 +1,6 @@ -import {useState} from 'react' -import {useNavigate} from 'react-router-dom' +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useSelector } from 'react-redux' import { AppBar, Avatar, @@ -24,25 +25,26 @@ import { Settings as SettingsIcon, Translate as TranslateIcon, } from '@mui/icons-material' -import {useThemeMode} from '../../../contexts/ThemeContext' -import {useSettings, useTranslation} from '../../../contexts/SettingsContext' -import {LANGUAGE_LABELS, LANGUAGES} from '../../../i18n/translations' -import {useAuth} from '../../../contexts/AuthContext' -import {useNotificationContext, NotificationMenu} from '../../../domains/notification' +import { useThemeMode } from '../../../contexts/ThemeContext' +import { useSettings, useTranslation } from '../../../contexts/SettingsContext' +import { LANGUAGE_LABELS, LANGUAGES } from '../../../i18n/translations' +import { useAuth } from '../../../contexts/AuthContext' +import { useNotificationContext, NotificationMenu } from '../../../domains/notification' -const Header = ({onMenuClick, sidebarOpen}) => { +const Header = ({ onMenuClick, sidebarOpen }) => { const theme = useTheme() const navigate = useNavigate() const isMobile = useMediaQuery(theme.breakpoints.down('md')) - const {mode, toggleTheme} = useThemeMode() - const {setLanguage, language} = useSettings() - const {t} = useTranslation() + const { mode, toggleTheme } = useThemeMode() + const { setLanguage, language } = useSettings() + const { t } = useTranslation() + const { profile } = useSelector((state) => state.profile) const [anchorEl, setAnchorEl] = useState(null) const [notificationAnchor, setNotificationAnchor] = useState(null) const [langAnchor, setLangAnchor] = useState(null) - const {logout} = useAuth() - const {unreadCount} = useNotificationContext() + const { logout } = useAuth() + const { unreadCount } = useNotificationContext() const handleProfileMenuOpen = (event) => { setAnchorEl(event.currentTarget) @@ -93,7 +95,7 @@ const Header = ({onMenuClick, sidebarOpen}) => { borderColor: mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)', }} > - + {/* Hamburger menu (mobile) */} {isMobile && ( { }, }} > - + )} @@ -145,7 +147,7 @@ const Header = ({onMenuClick, sidebarOpen}) => { L - + { - + {/* Right side icons */} - + {/* Language selector */} { }, }} > - + {/* Dark mode toggle */} @@ -192,7 +194,7 @@ const Header = ({onMenuClick, sidebarOpen}) => { }, }} > - {mode === 'dark' ? : } + {mode === 'dark' ? : } {/* Notifications */} @@ -219,14 +221,14 @@ const Header = ({onMenuClick, sidebarOpen}) => { }, }} > - + {/* Profile */} { fontSize: 14, }} > - U + {profile?.nickname ? profile.nickname.substring(0, 1).toUpperCase() : 'U'} @@ -255,15 +257,15 @@ const Header = ({onMenuClick, sidebarOpen}) => { minWidth: 160, }, }} - transformOrigin={{horizontal: 'right', vertical: 'top'}} - anchorOrigin={{horizontal: 'right', vertical: 'bottom'}} + transformOrigin={{ horizontal: 'right', vertical: 'top' }} + anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} > - + {t('settings.language')} - + {Object.entries(LANGUAGES).map(([key, value]) => ( { boxShadow: '0 10px 40px -10px rgba(0,0,0,0.2)', }, }} - transformOrigin={{horizontal: 'right', vertical: 'top'}} - anchorOrigin={{horizontal: 'right', vertical: 'bottom'}} + transformOrigin={{ horizontal: 'right', vertical: 'top' }} + anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} > - + - {t('header.user')} + {profile?.nickname || t('header.user')} - user@example.com + {profile?.email || 'user@example.com'} - + { handleProfileMenuClose(); navigate('/profile'); }} - sx={{py: 1.5, px: 2.5}} + sx={{ py: 1.5, px: 2.5 }} > - + {t('nav.profile')} { handleProfileMenuClose(); navigate('/settings'); }} - sx={{py: 1.5, px: 2.5}} + sx={{ py: 1.5, px: 2.5 }} > - + {t('nav.settings')} - + - + {t('nav.logout')}
diff --git a/src/store/index.js b/src/store/index.js index 543e164..17e676c 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -1,4 +1,6 @@ -import {configureStore, createSlice} from '@reduxjs/toolkit' +import { configureStore, createSlice } from '@reduxjs/toolkit' + +import profileReducer from '../domains/profile/store/profileSlice' // 임시 슬라이스 (빈 store 에러 방지) const appSlice = createSlice({ @@ -12,6 +14,7 @@ const appSlice = createSlice({ export const store = configureStore({ reducer: { app: appSlice.reducer, + profile: profileReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ From 100c283d68b9da69a6897d9f95b00ed941a9eff5 Mon Sep 17 00:00:00 2001 From: DDING JOO Date: Sun, 25 Jan 2026 21:26:12 +0900 Subject: [PATCH 9/9] =?UTF-8?q?[FIX]=20=EB=81=9D=EB=A7=90=EC=9E=87?= =?UTF-8?q?=EA=B8=B0=20=EA=B2=8C=EC=9E=84=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20UI=20=EA=B0=9C=EC=84=A0=20(#210)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: API 토큰 갱신 로직 통합 및 Cognito 설정 환경변수화 - 모든 API 호출을 공통 axios 인스턴스로 통합 - 401 오류 시 토큰 자동 갱신 로직 일원화 - aws-config.js의 Cognito 설정을 환경변수로 변경 - newsService.js를 fetch에서 axios로 변경 * [FIX] 끝말잇기 게임 버그 수정 및 UI 개선 - 타이머 타임아웃 중복 호출 버그 수정 (timeoutSent 플래그 추가) - 플레이어 닉네임 표시 수정 (userId 문자열 배열 → 객체 배열 매핑) - 번역 파일에 끝말잇기 관련 번역 추가 (games.wordchainTitle, games.wordchainDesc) - 대시보드 끝말잇기 카드 번역 함수 적용 --- src/App.jsx | 4 +- src/api/axios.js | 15 +- src/api/badgeApi.js | 41 ++--- src/api/chatApi.js | 49 ++---- src/api/grammarApi.js | 51 ++---- src/api/speakingApi.js | 52 ++---- src/api/vocabApi.js | 49 ++---- .../freetalk/hooks/useChatWebSocket.js | 91 +++++++++++ .../games/components/CreateGameRoomModal.jsx | 147 +++++++++++------ .../components/wordchain/GameEndModal.jsx | 37 ++++- .../games/pages/WordchainLobbyPage.jsx | 10 +- src/domains/games/pages/WordchainPlayPage.jsx | 153 +++++++++++++----- .../games/pages/WordchainWaitingPage.jsx | 3 +- src/domains/games/services/gameService.js | 27 +++- src/domains/news/services/newsService.js | 35 ++-- src/i18n/translations.js | 4 + .../MainLayout/HorizontalNav/index.jsx | 7 + 17 files changed, 459 insertions(+), 316 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 6896eeb..1f9a6fe 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -276,10 +276,10 @@ function Dashboard() { }, { id: 'wordchain', - title: '끝말잇기', + title: t('games.wordchainTitle'), icon: GameIcon, path: '/games/wordchain', - description: '영어 끝말잇기' + description: t('games.wordchainDesc') }, ], }, diff --git a/src/api/axios.js b/src/api/axios.js index 944cc96..ae97915 100644 --- a/src/api/axios.js +++ b/src/api/axios.js @@ -42,18 +42,29 @@ api.interceptors.response.use( // 401 에러 && 재시도하지 않을 경우 if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true + console.log('[Axios] 401 detected, attempting token refresh...') try { - // 토큰 갱신 시도 + // 토큰 갱신 시도 (API Gateway는 idToken을 기대) const session = await fetchAuthSession({ forceRefresh: true }) - const newToken = session.tokens?.accessToken?.toString() + console.log('[Axios] Session fetched:', !!session, 'tokens:', !!session?.tokens) + const newToken = session.tokens?.idToken?.toString() + console.log('[Axios] New token obtained:', !!newToken, 'length:', newToken?.length) if (newToken) { localStorage.setItem('accessToken', newToken) originalRequest.headers['Authorization'] = `Bearer ${newToken}` + console.log('[Axios] Retrying request with new token') return api(originalRequest) + } else { + console.log('[Axios] No token received, redirecting to login') + localStorage.removeItem('accessToken') + if (window.location.pathname !== '/login') { + window.location.href = '/login' + } } } catch (refreshError) { + console.error('[Axios] Token refresh failed:', refreshError) try { await signOut() } catch (e) { diff --git a/src/api/badgeApi.js b/src/api/badgeApi.js index 52654bb..1df2de3 100644 --- a/src/api/badgeApi.js +++ b/src/api/badgeApi.js @@ -1,32 +1,13 @@ -import axios from 'axios' - -const badgeApi = axios.create({ - baseURL: import.meta.env.VITE_BADGE_API_URL || import.meta.env.VITE_API_URL, - timeout: 10000, - headers: { - 'Content-Type': 'application/json', - }, -}) - -// Request interceptor for JWT token -badgeApi.interceptors.request.use( - (config) => { - const token = localStorage.getItem('accessToken') - if (token) { - config.headers.Authorization = `Bearer ${token}` - } - return config - }, - (error) => Promise.reject(error) -) - -// Response interceptor for error handling -badgeApi.interceptors.response.use( - (response) => response.data, - (error) => { - console.error('Badge API Error:', error.response?.data || error.message) - return Promise.reject(error) - } -) +import api from './axios' + +// 공통 axios 인스턴스 사용 (토큰 갱신 로직 포함) +// response.data 자동 추출을 위한 래퍼 +const badgeApi = { + get: (url, config) => api.get(url, config).then(res => res.data), + post: (url, data, config) => api.post(url, data, config).then(res => res.data), + put: (url, data, config) => api.put(url, data, config).then(res => res.data), + patch: (url, data, config) => api.patch(url, data, config).then(res => res.data), + delete: (url, config) => api.delete(url, config).then(res => res.data), +} export default badgeApi diff --git a/src/api/chatApi.js b/src/api/chatApi.js index a41d0fe..101ceb0 100644 --- a/src/api/chatApi.js +++ b/src/api/chatApi.js @@ -1,40 +1,13 @@ -import axios from 'axios' - -const chatApi = axios.create({ - baseURL: import.meta.env.VITE_CHAT_API_URL || import.meta.env.VITE_API_URL, - timeout: 10000, - headers: { - 'Content-Type': 'application/json', - }, -}) - -// Request interceptor - JWT 토큰 자동 추가 -chatApi.interceptors.request.use( - (config) => { - const token = localStorage.getItem('accessToken') - if (token) { - config.headers.Authorization = `Bearer ${token}` - } - return config - }, - (error) => { - return Promise.reject(error) - } -) - -// Response interceptor - 401 에러 시 로그인 페이지로 이동 -chatApi.interceptors.response.use( - (response) => response.data, - (error) => { - console.error('Chat API Error:', error.response?.data || error.message) - - if (error.response?.status === 401) { - localStorage.removeItem('accessToken') - window.location.href = '/login' - } - - return Promise.reject(error) - } -) +import api from './axios' + +// 공통 axios 인스턴스 사용 (토큰 갱신 로직 포함) +// response.data 자동 추출을 위한 래퍼 +const chatApi = { + get: (url, config) => api.get(url, config).then(res => res.data), + post: (url, data, config) => api.post(url, data, config).then(res => res.data), + put: (url, data, config) => api.put(url, data, config).then(res => res.data), + patch: (url, data, config) => api.patch(url, data, config).then(res => res.data), + delete: (url, config) => api.delete(url, config).then(res => res.data), +} export default chatApi diff --git a/src/api/grammarApi.js b/src/api/grammarApi.js index 54c4dee..72b97a9 100644 --- a/src/api/grammarApi.js +++ b/src/api/grammarApi.js @@ -1,40 +1,15 @@ -import axios from 'axios' - -const grammarApi = axios.create({ - baseURL: import.meta.env.VITE_GRAMMAR_API_URL || import.meta.env.VITE_API_URL, - timeout: 30000, - headers: { - 'Content-Type': 'application/json', - }, -}) - -// Request interceptor -grammarApi.interceptors.request.use( - (config) => { - const token = localStorage.getItem('accessToken') - if (token) { - config.headers.Authorization = `Bearer ${token}` - } - return config - }, - (error) => { - return Promise.reject(error) - } -) - -// Response interceptor -grammarApi.interceptors.response.use( - (response) => response.data, - (error) => { - console.error('Grammar API Error:', error.response?.data || error.message) - - if (error.response?.status === 401) { - localStorage.removeItem('accessToken') - window.location.href = '/login' - } - - return Promise.reject(error) - } -) +import api from './axios' + +// 공통 axios 인스턴스 사용 (토큰 갱신 로직 포함) +// Grammar API는 AI 처리로 인해 timeout 30초 필요 +const GRAMMAR_TIMEOUT = 30000 + +const grammarApi = { + get: (url, config) => api.get(url, { timeout: GRAMMAR_TIMEOUT, ...config }).then(res => res.data), + post: (url, data, config) => api.post(url, data, { timeout: GRAMMAR_TIMEOUT, ...config }).then(res => res.data), + put: (url, data, config) => api.put(url, data, { timeout: GRAMMAR_TIMEOUT, ...config }).then(res => res.data), + patch: (url, data, config) => api.patch(url, data, { timeout: GRAMMAR_TIMEOUT, ...config }).then(res => res.data), + delete: (url, config) => api.delete(url, { timeout: GRAMMAR_TIMEOUT, ...config }).then(res => res.data), +} export default grammarApi diff --git a/src/api/speakingApi.js b/src/api/speakingApi.js index 64a5b17..ef1c0e4 100644 --- a/src/api/speakingApi.js +++ b/src/api/speakingApi.js @@ -1,41 +1,15 @@ -import axios from 'axios' - -// Bedrock/Polly 사용으로 응답 시간(timeout) 제한 늘림 -const speakingApi = axios.create({ - baseURL: import.meta.env.VITE_API_URL, - timeout: 30000, // 30초 - headers: { - 'Content-Type': 'application/json', - }, -}) - -// Request interceptor - JWT 토큰 자동 추가 -speakingApi.interceptors.request.use( - (config) => { - const token = localStorage.getItem('accessToken') - if (token) { - config.headers.Authorization = `Bearer ${token}` - } - return config - }, - (error) => { - return Promise.reject(error) - } -) - -// Response interceptor - 401 에러 처리 및 데이터 추출 -speakingApi.interceptors.response.use( - (response) => response.data, // response.data를 바로 반환하도록 설정 - (error) => { - console.error('Speaking API Error:', error.response?.data || error.message) - - if (error.response?.status === 401) { - localStorage.removeItem('accessToken') - window.location.href = '/login' - } - - return Promise.reject(error) - } -) +import api from './axios' + +// 공통 axios 인스턴스 사용 (토큰 갱신 로직 포함) +// Bedrock/Polly 사용으로 timeout 30초 필요 +const SPEAKING_TIMEOUT = 30000 + +const speakingApi = { + get: (url, config) => api.get(url, { timeout: SPEAKING_TIMEOUT, ...config }).then(res => res.data), + post: (url, data, config) => api.post(url, data, { timeout: SPEAKING_TIMEOUT, ...config }).then(res => res.data), + put: (url, data, config) => api.put(url, data, { timeout: SPEAKING_TIMEOUT, ...config }).then(res => res.data), + patch: (url, data, config) => api.patch(url, data, { timeout: SPEAKING_TIMEOUT, ...config }).then(res => res.data), + delete: (url, config) => api.delete(url, { timeout: SPEAKING_TIMEOUT, ...config }).then(res => res.data), +} export default speakingApi \ No newline at end of file diff --git a/src/api/vocabApi.js b/src/api/vocabApi.js index 0c78af4..1e25f4d 100644 --- a/src/api/vocabApi.js +++ b/src/api/vocabApi.js @@ -1,40 +1,13 @@ -import axios from 'axios' - -const vocabApi = axios.create({ - baseURL: import.meta.env.VITE_VOCAB_API_URL || import.meta.env.VITE_API_URL, - timeout: 10000, - headers: { - 'Content-Type': 'application/json', - }, -}) - -// Request interceptor - JWT 토큰 자동 추가 -vocabApi.interceptors.request.use( - (config) => { - const token = localStorage.getItem('accessToken') - if (token) { - config.headers.Authorization = `Bearer ${token}` - } - return config - }, - (error) => { - return Promise.reject(error) - } -) - -// Response interceptor - 401 에러 시 로그인 페이지로 이동 -vocabApi.interceptors.response.use( - (response) => response.data, - (error) => { - console.error('Vocab API Error:', error.response?.data || error.message) - - if (error.response?.status === 401) { - localStorage.removeItem('accessToken') - window.location.href = '/login' - } - - return Promise.reject(error) - } -) +import api from './axios' + +// 공통 axios 인스턴스 사용 (토큰 갱신 로직 포함) +// response.data 자동 추출을 위한 래퍼 +const vocabApi = { + get: (url, config) => api.get(url, config).then(res => res.data), + post: (url, data, config) => api.post(url, data, config).then(res => res.data), + put: (url, data, config) => api.put(url, data, config).then(res => res.data), + patch: (url, data, config) => api.patch(url, data, config).then(res => res.data), + delete: (url, config) => api.delete(url, config).then(res => res.data), +} export default vocabApi diff --git a/src/domains/freetalk/hooks/useChatWebSocket.js b/src/domains/freetalk/hooks/useChatWebSocket.js index 633d755..adc8037 100644 --- a/src/domains/freetalk/hooks/useChatWebSocket.js +++ b/src/domains/freetalk/hooks/useChatWebSocket.js @@ -215,6 +215,97 @@ export function useChatWebSocket(roomId, userId) { })) }, + // 끝말잇기 게임 시작 + onWordchainStart: (data) => { + console.log('[useChatWebSocket] Wordchain started - FULL DATA:', JSON.stringify(data, null, 2)) + const gameData = data.data || data + // 서버 필드명 매핑: starterWord->currentWord, currentPlayerId->currentTurnUserId, timeLimit->turnTimeLimit + const wordchainState = { + status: 'PLAYING', + gameType: 'WORDCHAIN', + currentTurnUserId: gameData.currentPlayerId || gameData.currentTurnUserId, + currentWord: gameData.starterWord || gameData.currentWord, + nextLetter: gameData.nextLetter, + turnTimeLimit: gameData.timeLimit || gameData.turnTimeLimit || 15, + turnStartTime: gameData.turnStartTime || Date.now(), + scores: gameData.scores || {}, + players: gameData.players || gameData.activePlayers || [], + } + setGameState(wordchainState) + // PlayPage에서 사용할 수 있도록 sessionStorage에 저장 + sessionStorage.setItem(`wordchainState_${roomId}`, JSON.stringify(wordchainState)) + }, + + // 끝말잇기 정답 + onWordchainCorrect: (data) => { + console.log('[useChatWebSocket] Wordchain correct:', data) + const correctData = data.data || data + // 서버 필드명 매핑: word->currentWord, nextPlayerId->currentTurnUserId, nextTimeLimit->turnTimeLimit + setGameState((prev) => { + const newState = { + ...prev, + currentWord: correctData.word || correctData.currentWord, + nextLetter: correctData.nextLetter || prev?.nextLetter, + currentTurnUserId: correctData.nextPlayerId || correctData.nextTurnUserId, + turnTimeLimit: correctData.nextTimeLimit || prev?.turnTimeLimit, + turnStartTime: correctData.turnStartTime || Date.now(), + scores: correctData.scores || prev?.scores, + usedWords: prev?.usedWords ? [...prev.usedWords, correctData.word] : [correctData.word], + } + // sessionStorage 업데이트 + sessionStorage.setItem(`wordchainState_${roomId}`, JSON.stringify(newState)) + return newState + }) + }, + + // 끝말잇기 오답 + onWordchainWrong: (data) => { + console.log('[useChatWebSocket] Wordchain wrong:', data) + }, + + // 끝말잇기 타임아웃 + onWordchainTimeout: (data) => { + console.log('[useChatWebSocket] Wordchain timeout:', data) + const timeoutData = data.data || data + // 서버 필드명 매핑: nextPlayerId->currentTurnUserId, nextTimeLimit->turnTimeLimit + setGameState((prev) => { + const newState = { + ...prev, + currentTurnUserId: timeoutData.nextPlayerId || timeoutData.nextTurnUserId, + nextLetter: timeoutData.nextLetter || prev?.nextLetter, + turnTimeLimit: timeoutData.nextTimeLimit || prev?.turnTimeLimit, + turnStartTime: timeoutData.turnStartTime || Date.now(), + players: timeoutData.activePlayers || prev?.players, + } + // sessionStorage 업데이트 + sessionStorage.setItem(`wordchainState_${roomId}`, JSON.stringify(newState)) + return newState + }) + }, + + // 끝말잇기 게임 종료 + onWordchainEnd: (data) => { + console.log('[useChatWebSocket] Wordchain ended:', data) + const endData = data.data || data + setGameState((prev) => { + const newState = { + ...prev, + status: 'FINISHED', + winner: endData.winnerId ? { + id: endData.winnerId, + nickname: endData.winnerNickname, + } : null, + ranking: endData.ranking, + finalScores: endData.scores || prev?.scores, + usedWords: endData.usedWords || prev?.usedWords, + wordDefinitions: endData.wordDefinitions || {}, + } + // sessionStorage 업데이트 + sessionStorage.setItem(`wordchainState_${roomId}`, JSON.stringify(newState)) + return newState + }) + }, + onRoundStart: (data) => { console.log('[useChatWebSocket] Round started - FULL DATA:', JSON.stringify(data, null, 2)) // 실제 라운드 데이터 추출 (data.data에 중첩되어 있을 수 있음) diff --git a/src/domains/games/components/CreateGameRoomModal.jsx b/src/domains/games/components/CreateGameRoomModal.jsx index 4545f24..fc03238 100644 --- a/src/domains/games/components/CreateGameRoomModal.jsx +++ b/src/domains/games/components/CreateGameRoomModal.jsx @@ -22,14 +22,17 @@ const levelOptions = [ { value: 'ADVANCED', label: '고급', color: '#EF4444' }, ] -const CreateGameRoomModal = ({ open, onClose, onCreate, loading }) => { +const CreateGameRoomModal = ({ open, onClose, onCreate, loading, gameType = 'CATCHMIND' }) => { + const isWordchain = gameType === 'WORDCHAIN' + const [formData, setFormData] = useState({ name: '', description: '', level: 'BEGINNER', - maxParticipants: 4, + maxParticipants: isWordchain ? 6 : 4, maxRounds: 5, roundTimeLimit: 60, + turnTimeLimit: 15, // 끝말잇기용 }) const handleChange = (field, value) => { @@ -46,9 +49,10 @@ const CreateGameRoomModal = ({ open, onClose, onCreate, loading }) => { name: '', description: '', level: 'BEGINNER', - maxParticipants: 4, + maxParticipants: isWordchain ? 6 : 4, maxRounds: 5, roundTimeLimit: 60, + turnTimeLimit: 15, }) onClose?.() } @@ -190,59 +194,96 @@ const CreateGameRoomModal = ({ open, onClose, onCreate, loading }) => { />
- {/* 라운드 수 */} - - - - 라운드 수 - - - {formData.maxRounds}라운드 - + {/* 캐치마인드: 라운드 수 */} + {!isWordchain && ( + + + + 라운드 수 + + + {formData.maxRounds}라운드 + + + handleChange('maxRounds', value)} + min={3} + max={10} + step={1} + marks + sx={{ + color: GAME_COLORS.primary, + }} + /> - handleChange('maxRounds', value)} - min={3} - max={10} - step={1} - marks - sx={{ - color: GAME_COLORS.primary, - }} - /> - + )} - {/* 라운드 시간 */} - - - - 라운드 시간 - - - {formData.roundTimeLimit}초 - + {/* 캐치마인드: 라운드 시간 */} + {!isWordchain && ( + + + + 라운드 시간 + + + {formData.roundTimeLimit}초 + + + handleChange('roundTimeLimit', value)} + min={30} + max={120} + step={15} + marks={[ + { value: 30, label: '30초' }, + { value: 60, label: '60초' }, + { value: 90, label: '90초' }, + { value: 120, label: '120초' }, + ]} + sx={{ + color: GAME_COLORS.primary, + '& .MuiSlider-markLabel': { + fontSize: '0.65rem', + }, + }} + /> - handleChange('roundTimeLimit', value)} - min={30} - max={120} - step={15} - marks={[ - { value: 30, label: '30초' }, - { value: 60, label: '60초' }, - { value: 90, label: '90초' }, - { value: 120, label: '120초' }, - ]} - sx={{ - color: GAME_COLORS.primary, - '& .MuiSlider-markLabel': { - fontSize: '0.65rem', - }, - }} - /> - + )} + + {/* 끝말잇기: 턴 시간 */} + {isWordchain && ( + + + + 턴 시간 + + + {formData.turnTimeLimit}초 + + + handleChange('turnTimeLimit', value)} + min={10} + max={30} + step={5} + marks={[ + { value: 10, label: '10초' }, + { value: 15, label: '15초' }, + { value: 20, label: '20초' }, + { value: 30, label: '30초' }, + ]} + sx={{ + color: GAME_COLORS.primary, + '& .MuiSlider-markLabel': { + fontSize: '0.65rem', + }, + }} + /> + + )} diff --git a/src/domains/games/components/wordchain/GameEndModal.jsx b/src/domains/games/components/wordchain/GameEndModal.jsx index abcb3eb..5b47a89 100644 --- a/src/domains/games/components/wordchain/GameEndModal.jsx +++ b/src/domains/games/components/wordchain/GameEndModal.jsx @@ -16,17 +16,38 @@ import { GAME_COLORS, GAME_TYPOGRAPHY } from '../../theme/gameTheme' /** * GameEndModal - 게임 종료 모달 + * @param {Object} winner - { id, nickname } or { userId, nickname } + * @param {Array} finalPlayers - 백엔드 ranking 배열 또는 기존 players 배열 + * - 백엔드: [{ playerId, nickname, score, eliminated }] + * - 기존: [{ userId, nickname, isAlive, wordsSubmitted }] */ -const GameEndModal = ({ open, winner, finalPlayers, onRestart, onExit, currentUserId }) => { - const sortedPlayers = [...(finalPlayers || [])] +const GameEndModal = ({ open, winner, finalPlayers, ranking, onRestart, onExit, currentUserId }) => { + // 백엔드 ranking 또는 기존 finalPlayers 사용 + const players = ranking || finalPlayers || [] + + // 데이터 정규화 - 백엔드 형식과 기존 형식 모두 지원 + const sortedPlayers = [...players] + .map(player => ({ + userId: player.playerId || player.userId, + nickname: player.nickname || player.userId || player.playerId, + score: player.score || 0, + isAlive: player.eliminated !== undefined ? !player.eliminated : player.isAlive, + wordsSubmitted: player.wordsSubmitted || player.score || 0, + })) .sort((a, b) => { // 생존자가 우선 if (a.isAlive && !b.isAlive) return -1 if (!a.isAlive && b.isAlive) return 1 - // 제출한 단어 수로 정렬 - return (b.wordsSubmitted || 0) - (a.wordsSubmitted || 0) + // 점수로 정렬 + return (b.score || 0) - (a.score || 0) }) + // winner 정규화 + const normalizedWinner = winner ? { + userId: winner.id || winner.userId || winner.playerId, + nickname: winner.nickname || winner.id || winner.userId, + } : null + return ( - {winner && ( + {normalizedWinner && ( - {winner.nickname || winner.userId} - {winner.userId === currentUserId && ' (나)'} + {normalizedWinner.nickname || normalizedWinner.userId} + {normalizedWinner.userId === currentUserId && ' (나)'} )} @@ -121,7 +142,7 @@ const GameEndModal = ({ open, winner, finalPlayers, onRestart, onExit, currentUs color: player.isAlive ? GAME_COLORS.primary : 'text.secondary', }} > - {player.wordsSubmitted || 0}단어 + {player.score || 0}점 ))} diff --git a/src/domains/games/pages/WordchainLobbyPage.jsx b/src/domains/games/pages/WordchainLobbyPage.jsx index a4b8542..1298b07 100644 --- a/src/domains/games/pages/WordchainLobbyPage.jsx +++ b/src/domains/games/pages/WordchainLobbyPage.jsx @@ -48,16 +48,14 @@ const WordchainLobbyPage = () => { setLoading(true) setError(null) - const params = {} + const params = { + gameType: 'WORDCHAIN', + } if (filters.level) params.level = filters.level if (filters.waitingOnly) params.status = 'WAITING' const response = await gameService.getRooms(params) - // WORDCHAIN 타입 필터링 - const wordchainRooms = (response.data.rooms || []).filter( - room => room.gameType === 'WORDCHAIN' - ) - setRooms(wordchainRooms) + setRooms(response.data.rooms || []) } catch (err) { console.error('Failed to fetch rooms:', err) setError('방 목록을 불러오는데 실패했습니다') diff --git a/src/domains/games/pages/WordchainPlayPage.jsx b/src/domains/games/pages/WordchainPlayPage.jsx index 383d656..f248609 100644 --- a/src/domains/games/pages/WordchainPlayPage.jsx +++ b/src/domains/games/pages/WordchainPlayPage.jsx @@ -56,6 +56,8 @@ const WordchainPlayPage = () => { players: [], usedWords: [], winner: null, + ranking: null, + finalScores: null, }) const [room, setRoom] = useState(null) @@ -63,38 +65,83 @@ const WordchainPlayPage = () => { const [timeLeft, setTimeLeft] = useState(15) const [showEndModal, setShowEndModal] = useState(false) const [submitting, setSubmitting] = useState(false) + const [timeoutSent, setTimeoutSent] = useState(false) // 타임아웃 중복 방지 const isMyTurn = gameState.currentTurnUserId === currentUserId + console.log('[WordchainPlayPage] Turn check:', { currentUserId, currentTurnUserId: gameState.currentTurnUserId, isMyTurn }) + + // WebSocket에서 온 players 배열을 객체 배열로 변환 (nickname 매핑) + const mapPlayersWithNickname = (playerIds, existingPlayers, participants) => { + if (!playerIds || !Array.isArray(playerIds)) return existingPlayers + + // playerIds가 이미 객체 배열이면 그대로 반환 + if (playerIds.length > 0 && typeof playerIds[0] === 'object') { + return playerIds + } + + // 문자열 배열이면 nickname 매핑 + return playerIds.map(userId => { + // 기존 players에서 찾기 + const existing = existingPlayers?.find(p => p.userId === userId) + if (existing) return existing + + // room.participants에서 찾기 + const participant = participants?.find(p => p.id === userId || p.participantId === userId || p.userId === userId) + if (participant) { + return { + userId, + nickname: participant.nickname || participant.name || userId.substring(0, 8), + isAlive: true, + } + } + + // 못 찾으면 userId로 표시 + return { + userId, + nickname: userId.substring(0, 8), + isAlive: true, + } + }) + } // WebSocket gameState 업데이트 반영 useEffect(() => { if (wsGameState) { console.log('[WordchainPlayPage] Received wsGameState:', wsGameState) - setGameState(prev => ({ - ...prev, - status: wsGameState.status ?? prev.status, - currentWord: wsGameState.currentWord ?? prev.currentWord, - nextLetter: wsGameState.nextLetter ?? prev.nextLetter, - currentTurnUserId: wsGameState.currentTurnUserId ?? prev.currentTurnUserId, - turnStartTime: wsGameState.turnStartTime ?? prev.turnStartTime, - turnTimeLimit: wsGameState.turnTimeLimit ?? prev.turnTimeLimit ?? 15, - players: wsGameState.players ?? prev.players, - usedWords: wsGameState.usedWords ?? prev.usedWords, - winner: wsGameState.winner ?? prev.winner, - })) - - // 턴이 변경되면 타이머 리셋 - if (wsGameState.currentTurnUserId && wsGameState.currentTurnUserId !== prev.currentTurnUserId) { - setTimeLeft(wsGameState.turnTimeLimit ?? 15) - } + setGameState(prev => { + // 턴이 변경되면 타이머 리셋 + if (wsGameState.currentTurnUserId && wsGameState.currentTurnUserId !== prev.currentTurnUserId) { + setTimeLeft(wsGameState.turnTimeLimit ?? prev.turnTimeLimit ?? 15) + } + + // players 매핑 + const mappedPlayers = wsGameState.players + ? mapPlayersWithNickname(wsGameState.players, prev.players, room?.participants) + : prev.players + + return { + ...prev, + status: wsGameState.status ?? prev.status, + currentWord: wsGameState.currentWord ?? prev.currentWord, + nextLetter: wsGameState.nextLetter ?? prev.nextLetter, + currentTurnUserId: wsGameState.currentTurnUserId ?? prev.currentTurnUserId, + turnStartTime: wsGameState.turnStartTime ?? prev.turnStartTime, + turnTimeLimit: wsGameState.turnTimeLimit ?? prev.turnTimeLimit ?? 15, + players: mappedPlayers, + usedWords: wsGameState.usedWords ?? prev.usedWords, + winner: wsGameState.winner ?? prev.winner, + ranking: wsGameState.ranking ?? prev.ranking, + finalScores: wsGameState.finalScores ?? prev.finalScores, + } + }) // 게임 종료 처리 if (wsGameState.status === 'FINISHED' && !showEndModal) { setShowEndModal(true) } } - }, [wsGameState, showEndModal]) + }, [wsGameState, showEndModal, room]) // 초기 로드 및 WebSocket 연결 useEffect(() => { @@ -106,15 +153,33 @@ const WordchainPlayPage = () => { const roomResponse = await gameService.getRoom(roomId) setRoom(roomResponse.data) - // 게임 상태 조회 - let gameData - try { - const statusResponse = await wordchainService.getStatus(roomId) - gameData = statusResponse.data || statusResponse - } catch { - // 게임 상태가 없으면 시작 - const gameResponse = await wordchainService.start(roomId) - gameData = gameResponse.data || gameResponse + // sessionStorage에서 WORDCHAIN_START 데이터 확인 + const savedState = sessionStorage.getItem(`wordchainState_${roomId}`) + let gameData = null + + if (savedState) { + gameData = JSON.parse(savedState) + console.log('[WordchainPlayPage] Got saved wordchain state:', gameData) + // 페이지 이탈 시 삭제하도록 변경 (StrictMode 두 번 마운트 대응) + } else { + // sessionStorage에 없으면 API 조회 시도 + try { + const statusResponse = await wordchainService.getStatus(roomId) + gameData = statusResponse.data || statusResponse + console.log('[WordchainPlayPage] Got game status:', gameData) + } catch (err) { + // 게임 상태 조회 실패 시 WebSocket 이벤트 대기 + console.log('[WordchainPlayPage] Failed to get status, waiting for WebSocket:', err.message) + // 기본 상태로 시작하고 WebSocket에서 업데이트 받음 + gameData = { + currentWord: null, + nextLetter: null, + currentTurnUserId: null, + turnTimeLimit: 15, + players: roomResponse.data.participants || [], + usedWords: [], + } + } } setGameState({ @@ -157,13 +222,6 @@ const WordchainPlayPage = () => { const interval = setInterval(() => { setTimeLeft(prev => { if (prev <= 1) { - // 시간 초과 - if (isMyTurn && isConnected) { - console.log('[WordchainPlayPage] Timer expired, sending timeout') - wordchainService.timeout(roomId).catch(err => { - console.error('Failed to send timeout:', err) - }) - } return 0 } return prev - 1 @@ -171,7 +229,23 @@ const WordchainPlayPage = () => { }, 1000) return () => clearInterval(interval) - }, [gameState.status, isMyTurn, isConnected, roomId]) + }, [gameState.status]) + + // 타임아웃 처리 (한 번만 전송) + useEffect(() => { + if (timeLeft === 0 && isMyTurn && isConnected && !timeoutSent) { + console.log('[WordchainPlayPage] Timer expired, sending timeout') + setTimeoutSent(true) + wordchainService.timeout(roomId).catch(err => { + console.error('Failed to send timeout:', err) + }) + } + }, [timeLeft, isMyTurn, isConnected, timeoutSent, roomId]) + + // 턴 변경 시 타임아웃 플래그 리셋 + useEffect(() => { + setTimeoutSent(false) + }, [gameState.currentTurnUserId]) // 단어 제출 const handleSubmitWord = async (word) => { @@ -191,14 +265,16 @@ const WordchainPlayPage = () => { // 게임 종료 const handleStopGame = async () => { + disconnect() try { - disconnect() await wordchainService.stop(roomId) - sessionStorage.removeItem(`roomToken_${roomId}`) - navigate('/games/wordchain') } catch (err) { console.error('Failed to stop game:', err) + // 에러가 나도 무시하고 진행 } + sessionStorage.removeItem(`roomToken_${roomId}`) + sessionStorage.removeItem(`wordchainState_${roomId}`) + navigate('/games/wordchain') } // 재시작 @@ -389,6 +465,7 @@ const WordchainPlayPage = () => { open={showEndModal} winner={gameState.winner} finalPlayers={gameState.players} + ranking={gameState.ranking} onRestart={handleRestart} onExit={handleLeave} currentUserId={currentUserId} diff --git a/src/domains/games/pages/WordchainWaitingPage.jsx b/src/domains/games/pages/WordchainWaitingPage.jsx index e33642c..d8c8102 100644 --- a/src/domains/games/pages/WordchainWaitingPage.jsx +++ b/src/domains/games/pages/WordchainWaitingPage.jsx @@ -19,6 +19,7 @@ import { import ParticipantList from '../components/ParticipantList' import WaitingChat from '../components/WaitingChat' import { gameService } from '../services/gameService' +import { wordchainService } from '../services/wordchainService' import { GAME_COLORS } from '../theme/gameTheme' import { useAuth } from '../../../contexts/AuthContext' import { useThemeMode } from '../../../contexts/ThemeContext' @@ -137,7 +138,7 @@ const WordchainWaitingPage = () => { const handleStartGame = async () => { try { setStarting(true) - await gameService.startGame(roomId) + await wordchainService.start(roomId) navigate(`/games/wordchain/${roomId}/play`) } catch (err) { console.error('Failed to start game:', err) diff --git a/src/domains/games/services/gameService.js b/src/domains/games/services/gameService.js index 1c3958f..03844d4 100644 --- a/src/domains/games/services/gameService.js +++ b/src/domains/games/services/gameService.js @@ -20,7 +20,8 @@ export const gameRoomService = { const params = new URLSearchParams() // 게임방 필터 params.append('type', 'GAME') - params.append('gameType', 'CATCHMIND') + const gameType = filters.gameType || 'CATCHMIND' + params.append('gameType', gameType) // 백엔드는 소문자 level 값 사용 if (filters.status) params.append('status', filters.status) @@ -35,11 +36,11 @@ export const gameRoomService = { let data = response.data if (data?.rooms) { data.rooms = data.rooms.filter(room => - room.type === 'GAME' || room.gameType === 'CATCHMIND' + room.type === 'GAME' || room.gameType === gameType ) } else if (Array.isArray(data)) { data = data.filter(room => - room.type === 'GAME' || room.gameType === 'CATCHMIND' + room.type === 'GAME' || room.gameType === gameType ) } @@ -67,6 +68,19 @@ export const gameRoomService = { * @param {Object} data.gameSettings - 게임 설정 */ create: async (data) => { + const gameType = data.gameType || 'CATCHMIND' + const isWordchain = gameType === 'WORDCHAIN' + + // 게임 타입별 gameSettings 설정 + const gameSettings = isWordchain + ? { + turnTimeLimit: data.turnTimeLimit || 15, + } + : { + maxRounds: data.maxRounds || 5, + roundTimeLimit: data.roundTimeLimit || 60, + } + const payload = { name: data.name, description: data.description || '', @@ -75,11 +89,8 @@ export const gameRoomService = { isPrivate: data.isPrivate || false, password: data.password, type: 'GAME', - gameType: 'CATCHMIND', - gameSettings: { - maxRounds: data.maxRounds || 5, - roundTimeLimit: data.roundTimeLimit || 60, - }, + gameType: gameType, + gameSettings: gameSettings, } console.log('[gameService] create payload:', payload) diff --git a/src/domains/news/services/newsService.js b/src/domains/news/services/newsService.js index a417fd8..4a30eee 100644 --- a/src/domains/news/services/newsService.js +++ b/src/domains/news/services/newsService.js @@ -3,28 +3,33 @@ * 뉴스 영어 학습 관련 API 호출 */ -const API_URL = import.meta.env.VITE_API_URL +import api from '../../../api/axios' /** - * API 요청 헬퍼 + * API 요청 헬퍼 - 공통 axios 인스턴스 사용 (토큰 갱신 로직 포함) */ const fetchWithAuth = async (endpoint, options = {}) => { - const token = localStorage.getItem('accessToken') - const response = await fetch(`${API_URL}${endpoint}`, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...(token && { Authorization: `Bearer ${token}` }), - ...options.headers, - }, - }) + const { method = 'GET', body, ...restOptions } = options + + const config = { + ...restOptions, + } - if (!response.ok) { - const error = await response.json().catch(() => ({})) - throw new Error(error.message || 'API request failed') + if (method === 'GET') { + const response = await api.get(endpoint, config) + return response.data + } else if (method === 'POST') { + const response = await api.post(endpoint, body ? JSON.parse(body) : undefined, config) + return response.data + } else if (method === 'PUT') { + const response = await api.put(endpoint, body ? JSON.parse(body) : undefined, config) + return response.data + } else if (method === 'DELETE') { + const response = await api.delete(endpoint, config) + return response.data } - return response.json() + throw new Error(`Unsupported method: ${method}`) } /** diff --git a/src/i18n/translations.js b/src/i18n/translations.js index 5a8b7af..d5a1384 100644 --- a/src/i18n/translations.js +++ b/src/i18n/translations.js @@ -388,6 +388,8 @@ export const translations = { description: '재미있는 게임으로 영어 실력을 향상하세요', catchmindTitle: '캐치마인드', catchmindDesc: '그림 맞추기 게임', + wordchainTitle: '끝말잇기', + wordchainDesc: '영어 끝말잇기', }, // News @@ -837,6 +839,8 @@ export const translations = { description: 'Improve your English with fun games', catchmindTitle: 'Catchmind', catchmindDesc: 'Drawing guessing game', + wordchainTitle: 'Word Chain', + wordchainDesc: 'English word chain game', }, // News diff --git a/src/layouts/MainLayout/HorizontalNav/index.jsx b/src/layouts/MainLayout/HorizontalNav/index.jsx index 0c6e3ff..fb62d41 100644 --- a/src/layouts/MainLayout/HorizontalNav/index.jsx +++ b/src/layouts/MainLayout/HorizontalNav/index.jsx @@ -141,6 +141,13 @@ const HorizontalNav = () => { path: '/games/catchmind', desc: t('games.catchmindDesc') }, + { + id: 'wordchain', + label: t('games.wordchainTitle') || '끝말잇기', + icon: GameIcon, + path: '/games/wordchain', + desc: t('games.wordchainDesc') || '영어 끝말잇기' + }, ], }, {