- )
- )}
+ )}
{/* 입력 영역 */}
{
borderTop: 1,
borderColor: 'divider',
bgcolor: 'background.paper',
+ pointerEvents: 'auto',
}}
>
{
+const GameModePanel = ({roomId, onGameMessage, initialGameStatus, wsGameState, isConnected, onStartGame, onStopGame, onSendMessage, onSendDrawing, onClearDrawing, receivedDrawing, onDrawingProcessed, shouldClearCanvas, onCanvasCleared, messages, correctAnswerBubble, onBubbleProcessed}) => {
const theme = useTheme()
- const isDark = theme.palette.mode === 'dark'
+ const {mode} = useThemeMode()
+ const isDark = mode === 'dark'
+ const {user} = useAuth()
+ const currentUserId = user?.userId || user?.username
const canvasRef = useRef(null)
const [gameState, setGameState] = useState({
- gameStatus: GAME_STATUS.NONE,
+ gameStatus: initialGameStatus || GAME_STATUS.NONE,
currentRound: 0,
totalRounds: 5,
currentDrawerId: null,
@@ -34,32 +39,105 @@ const GameModePanel = ({roomId, onGameMessage}) => {
const [brushColor, setBrushColor] = useState('#000000')
const [brushSize, setBrushSize] = useState(3)
const [loading, setLoading] = useState(false)
+ const [currentStroke, setCurrentStroke] = useState([]) // 현재 그리는 스트로크
+ const [bubbles, setBubbles] = useState([]) // 비눗방울 애니메이션
- const isDrawer = gameState.currentDrawerId === TEMP_USER_ID
+ const isDrawer = gameState.currentDrawerId === currentUserId
const isGameActive = gameState.gameStatus === GAME_STATUS.PLAYING
+ // 최신 값을 interval에서 사용하기 위한 ref
+ const isDrawerRef = useRef(isDrawer)
+ const isConnectedRef = useRef(isConnected)
+ const onSendMessageRef = useRef(onSendMessage)
+ useEffect(() => {
+ isDrawerRef.current = isDrawer
+ isConnectedRef.current = isConnected
+ onSendMessageRef.current = onSendMessage
+ }, [isDrawer, isConnected, onSendMessage])
+
+ // 디버깅 로그
+ console.log('[GameModePanel] State:', {
+ isDrawer,
+ isGameActive,
+ currentDrawerId: gameState.currentDrawerId,
+ currentUserId,
+ gameStatus: gameState.gameStatus
+ })
+
// 게임 상태 조회
const fetchGameStatus = useCallback(async () => {
try {
const response = await gameService.getStatus(roomId)
const data = response.data || response
- setGameState(data)
+ setGameState(prev => ({
+ ...prev,
+ ...data,
+ gameStatus: data.gameStatus || GAME_STATUS.NONE,
+ }))
} catch (err) {
console.error('Failed to fetch game status:', err)
}
}, [roomId])
- // 타이머
+ // 마운트 시 게임 상태 조회
+ useEffect(() => {
+ if (roomId) {
+ fetchGameStatus()
+ }
+ }, [roomId, fetchGameStatus])
+
+ // 부모 컴포넌트의 게임 상태 변경 반영
+ useEffect(() => {
+ if (initialGameStatus) {
+ setGameState(prev => ({
+ ...prev,
+ gameStatus: initialGameStatus,
+ }))
+ }
+ }, [initialGameStatus])
+
+ // WebSocket 게임 상태 동기화 (제시어, 출제자, 라운드 등)
+ useEffect(() => {
+ if (wsGameState) {
+ console.log('[GameModePanel] Syncing wsGameState:', wsGameState)
+ setGameState(prev => ({
+ ...prev,
+ gameStatus: wsGameState.status === 'PLAYING' ? GAME_STATUS.PLAYING :
+ wsGameState.status === 'FINISHED' ? GAME_STATUS.NONE : prev.gameStatus,
+ currentRound: wsGameState.currentRound ?? prev.currentRound,
+ totalRounds: wsGameState.totalRounds ?? prev.totalRounds,
+ currentDrawerId: wsGameState.currentDrawerId ?? prev.currentDrawerId,
+ currentWord: wsGameState.currentWord ?? prev.currentWord,
+ roundStartTime: wsGameState.roundStartTime ?? prev.roundStartTime,
+ scores: wsGameState.scores ?? prev.scores,
+ hintUsed: wsGameState.hintUsed ?? prev.hintUsed,
+ hint: wsGameState.hint ?? prev.hint,
+ }))
+ }
+ }, [wsGameState])
+
+ // 타이머 + 자동 스킵 (출제자만)
+ const autoSkipSentRef = useRef(false)
useEffect(() => {
if (!isGameActive || !gameState.roundStartTime) return
+ // 새 라운드 시작 시 autoSkip 플래그 초기화
+ autoSkipSentRef.current = false
+
const interval = setInterval(() => {
const elapsed = Math.floor((Date.now() - gameState.roundStartTime) / 1000)
const remaining = Math.max(0, gameState.roundTimeLimit - elapsed)
setTimeLeft(remaining)
- if (remaining === 0) {
- // 시간 초과 처리
+ // 시간 초과 시 자동 스킵 (출제자만, 연결된 경우만, 한 번만 전송)
+ // ref를 사용해서 최신 값 확인
+ if (remaining === 0 && isDrawerRef.current && isConnectedRef.current && !autoSkipSentRef.current && onSendMessageRef.current) {
+ console.log('[GameModePanel] Time expired, auto-skipping... isDrawer:', isDrawerRef.current, 'isConnected:', isConnectedRef.current)
+ autoSkipSentRef.current = true
+ onSendMessageRef.current('/skip', 'TEXT')
+ clearInterval(interval)
+ } else if (remaining === 0) {
+ console.log('[GameModePanel] Time expired, but cannot skip. isDrawer:', isDrawerRef.current, 'isConnected:', isConnectedRef.current)
clearInterval(interval)
}
}, 1000)
@@ -67,39 +145,49 @@ const GameModePanel = ({roomId, onGameMessage}) => {
return () => clearInterval(interval)
}, [isGameActive, gameState.roundStartTime, gameState.roundTimeLimit])
- // 게임 시작
- const handleStartGame = async () => {
- setLoading(true)
- try {
- const response = await gameService.start(roomId)
- const data = response.data || response
- setGameState(data)
- onGameMessage?.({
- type: 'game_start',
- content: `🎮 게임 시작! 총 ${data.totalRounds}라운드\n출제자: ${data.currentDrawerId}`,
- })
- } catch (err) {
- console.error('Failed to start game:', err)
- } finally {
- setLoading(false)
+ // 게임 시작 - WebSocket을 통해 /start 명령어 전송 (서버에서 인원 검증)
+ const handleStartGame = () => {
+ console.log('[GameModePanel] handleStartGame called, onStartGame:', !!onStartGame)
+ if (onStartGame) {
+ // WebSocket으로 /start 명령어 전송 (채팅창에서 /start 입력과 동일)
+ const result = onStartGame()
+ console.log('[GameModePanel] onStartGame result:', result)
+ } else {
+ // fallback: REST API 사용 (WebSocket 미연결 시)
+ setLoading(true)
+ gameService.start(roomId)
+ .then(response => {
+ const data = response.data || response
+ setGameState(data)
+ onGameMessage?.({
+ type: 'game_start',
+ content: `🎮 게임 시작! 총 ${data.totalRounds}라운드\n출제자: ${data.currentDrawerId}`,
+ })
+ })
+ .catch(err => console.error('Failed to start game:', err))
+ .finally(() => setLoading(false))
}
}
- // 게임 종료
- const handleStopGame = async () => {
- setLoading(true)
- try {
- const response = await gameService.stop(roomId)
- const data = response.data || response
- setGameState(prev => ({...prev, gameStatus: GAME_STATUS.NONE}))
- onGameMessage?.({
- type: 'game_end',
- content: `🎮 게임 종료!\n${data.message}`,
- })
- } catch (err) {
- console.error('Failed to stop game:', err)
- } finally {
- setLoading(false)
+ // 게임 종료 - WebSocket을 통해 /stop 명령어 전송
+ const handleStopGame = () => {
+ if (onStopGame) {
+ // WebSocket으로 /stop 명령어 전송 (채팅창에서 /stop 입력과 동일)
+ onStopGame()
+ } else {
+ // fallback: REST API 사용 (WebSocket 미연결 시)
+ setLoading(true)
+ gameService.stop(roomId)
+ .then(response => {
+ const data = response.data || response
+ setGameState(prev => ({...prev, gameStatus: GAME_STATUS.NONE}))
+ onGameMessage?.({
+ type: 'game_end',
+ content: `🎮 게임 종료!\n${data.message}`,
+ })
+ })
+ .catch(err => console.error('Failed to stop game:', err))
+ .finally(() => setLoading(false))
}
}
@@ -147,28 +235,57 @@ const GameModePanel = ({roomId, onGameMessage}) => {
}
// 캔버스 초기화
- const clearCanvas = () => {
+ const clearCanvas = (sendToOthers = false) => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, canvas.width, canvas.height)
+
+ // 다른 사용자에게도 초기화 메시지 전송
+ if (sendToOthers && onClearDrawing) {
+ onClearDrawing()
+ }
}
- // 캔버스 드로잉
+ // 캔버스 드로잉 - 스트로크 배열 방식
const startDrawing = (e) => {
if (!isDrawer) return
+ const canvas = canvasRef.current
+ if (!canvas) return
+
+ const rect = canvas.getBoundingClientRect()
+ const x = e.clientX - rect.left
+ const y = e.clientY - rect.top
+
setIsDrawing(true)
- draw(e)
+ // 스트로크 시작
+ setCurrentStroke([{x, y, type: 'start', color: brushColor, width: brushSize}])
+
+ const ctx = canvas.getContext('2d')
+ ctx.beginPath()
+ ctx.moveTo(x, y)
}
const stopDrawing = () => {
+ if (!isDrawing) return
setIsDrawing(false)
+
const canvas = canvasRef.current
if (canvas) {
const ctx = canvas.getContext('2d')
ctx.beginPath()
}
+
+ // 스트로크 완료 시 WebSocket으로 전송
+ if (currentStroke.length > 0 && onSendDrawing) {
+ const strokeData = [...currentStroke, {type: 'end'}]
+ console.log('[GameModePanel] Sending stroke:', strokeData)
+ onSendDrawing(JSON.stringify(strokeData))
+ } else {
+ console.log('[GameModePanel] Not sending:', {strokeLength: currentStroke.length, hasOnSendDrawing: !!onSendDrawing})
+ }
+ setCurrentStroke([])
}
const draw = (e) => {
@@ -188,15 +305,115 @@ const GameModePanel = ({roomId, onGameMessage}) => {
ctx.stroke()
ctx.beginPath()
ctx.moveTo(x, y)
+
+ // 스트로크에 좌표 추가
+ setCurrentStroke(prev => [...prev, {x, y, type: 'move'}])
}
- // 캔버스 초기화
+ // 캔버스 초기화 (라운드 변경 시)
useEffect(() => {
if (canvasRef.current) {
clearCanvas()
}
}, [gameState.currentRound])
+ // 원격 캔버스 초기화 (다른 사용자가 초기화 시)
+ useEffect(() => {
+ if (shouldClearCanvas && canvasRef.current) {
+ console.log('[GameModePanel] Clearing canvas from remote')
+ clearCanvas(false) // 다시 전송하지 않음
+ onCanvasCleared?.()
+ }
+ }, [shouldClearCanvas, onCanvasCleared])
+
+ // 수신된 그리기 데이터를 캔버스에 그리기
+ useEffect(() => {
+ if (!receivedDrawing || !canvasRef.current) return
+
+ const canvas = canvasRef.current
+ const ctx = canvas.getContext('2d')
+
+ try {
+ // content에서 스트로크 데이터 파싱
+ const content = receivedDrawing.content
+ const strokeData = typeof content === 'string' ? JSON.parse(content) : content
+
+ console.log('[GameModePanel] Drawing received stroke:', strokeData)
+
+ if (!Array.isArray(strokeData)) return
+
+ // 스트로크 그리기
+ strokeData.forEach((point, index) => {
+ if (point.type === 'start') {
+ ctx.beginPath()
+ ctx.moveTo(point.x, point.y)
+ ctx.lineWidth = point.width || 3
+ ctx.lineCap = 'round'
+ ctx.strokeStyle = point.color || '#000000'
+ } else if (point.type === 'move') {
+ ctx.lineTo(point.x, point.y)
+ ctx.stroke()
+ ctx.beginPath()
+ ctx.moveTo(point.x, point.y)
+ } else if (point.type === 'end') {
+ ctx.beginPath()
+ }
+ })
+
+ // 처리 완료 알림
+ onDrawingProcessed?.()
+ } catch (err) {
+ console.error('[GameModePanel] Error drawing received data:', err)
+ }
+ }, [receivedDrawing, onDrawingProcessed])
+
+ // 비눗방울 애니메이션 생성
+ const createBubble = useCallback((userId, content, isCorrect = false) => {
+ const id = `bubble-${Date.now()}-${Math.random()}`
+ const bubble = {
+ id,
+ userId,
+ content,
+ isCorrect,
+ x: Math.random() * (CANVAS_WIDTH - 100) + 50, // 랜덤 x 위치
+ startTime: Date.now(),
+ }
+ setBubbles(prev => [...prev, bubble])
+
+ // 3초 후 자동 삭제
+ setTimeout(() => {
+ setBubbles(prev => prev.filter(b => b.id !== id))
+ }, 3000)
+ }, [])
+
+ // correctAnswerBubble prop으로 정답 버블 생성 (정답일 때 특별 효과)
+ // 백엔드가 정답 시 자동으로 ROUND_END를 보내므로 프론트엔드에서 /skip 불필요
+ useEffect(() => {
+ if (correctAnswerBubble) {
+ createBubble(correctAnswerBubble.userId, correctAnswerBubble.content, true) // isCorrect = true
+ onBubbleProcessed?.()
+ // 백엔드가 정답 처리 후 자동으로 라운드 전환하므로 /skip 제거
+ }
+ }, [correctAnswerBubble, createBubble, onBubbleProcessed])
+
+ // 게임 중 모든 채팅 메시지를 비눗방울로 표시
+ const lastMessageIdRef = useRef(null)
+ useEffect(() => {
+ if (!isGameActive || !messages || messages.length === 0) return
+
+ // 마지막 메시지만 처리 (중복 방지)
+ const lastMessage = messages[messages.length - 1]
+ if (!lastMessage || lastMessage.id === lastMessageIdRef.current) return
+ if (lastMessage.isSystem || lastMessage.messageType === 'SYSTEM') return
+ if (lastMessage.messageType === 'DRAWING' || lastMessage.messageType === 'DRAWING_CLEAR') return
+
+ // 출제자의 메시지는 제외 (출제자가 답을 알려줘선 안 됨)
+ if (lastMessage.userId === gameState.currentDrawerId) return
+
+ lastMessageIdRef.current = lastMessage.id
+ createBubble(lastMessage.userId, lastMessage.content, false) // isCorrect = false
+ }, [messages, isGameActive, gameState.currentDrawerId, createBubble])
+
// 점수 정렬
const sortedScores = Object.entries(gameState.scores || {})
.sort(([, a], [, b]) => b - a)
@@ -211,7 +428,10 @@ const GameModePanel = ({roomId, onGameMessage}) => {
+ ) : isPlaying ? (
+ }
+ onClick={() => onSpectate?.(room)}
+ sx={{
+ borderColor: GAME_COLORS.status.playing,
+ color: GAME_COLORS.status.playing,
+ '&:hover': {
+ borderColor: GAME_COLORS.status.playing,
+ bgcolor: GAME_COLORS.status.playingBg,
+ },
+ borderRadius: '10px',
+ textTransform: 'none',
+ fontWeight: 600,
+ }}
+ >
+ 관전하기
+
+ ) : (
+
+ )}
+
+
+
+ )
+}
+
+export default GameRoomCard
diff --git a/src/domains/games/components/ParticipantList.jsx b/src/domains/games/components/ParticipantList.jsx
new file mode 100644
index 0000000..92c6dc3
--- /dev/null
+++ b/src/domains/games/components/ParticipantList.jsx
@@ -0,0 +1,134 @@
+import {Avatar, Box, Chip, List, ListItem, ListItemAvatar, ListItemText, Typography} from '@mui/material'
+import {GAME_COLORS} from '../theme/gameTheme'
+
+const ParticipantList = ({ participants, maxParticipants, currentUserId }) => {
+ const emptySlots = maxParticipants - participants.length
+
+ return (
+
+
+
+ 참가자
+
+
+
+
+
+ {participants.map((participant) => (
+
+
+
+ {participant.nickname?.charAt(0) || 'U'}
+
+
+
+
+ {participant.nickname}
+
+ {participant.isHost && (
+
+ )}
+ {participant.userId === currentUserId && (
+
+ )}
+
+ }
+ />
+
+ ))}
+
+ {/* 빈 슬롯 */}
+ {Array.from({ length: emptySlots }).map((_, index) => (
+
+
+
+ ?
+
+
+
+ 대기중...
+
+ }
+ />
+
+ ))}
+
+
+ )
+}
+
+export default ParticipantList
diff --git a/src/domains/games/components/WaitingChat.jsx b/src/domains/games/components/WaitingChat.jsx
new file mode 100644
index 0000000..7eeaf27
--- /dev/null
+++ b/src/domains/games/components/WaitingChat.jsx
@@ -0,0 +1,202 @@
+import {useEffect, useRef, useState} from 'react'
+import {Avatar, Box, IconButton, Paper, TextField, Typography} from '@mui/material'
+import {Send as SendIcon} from '@mui/icons-material'
+import {GAME_COLORS} from '../theme/gameTheme'
+import {useThemeMode} from '../../../contexts/ThemeContext'
+
+const WaitingChat = ({ messages, onSendMessage, currentUserId, disabled }) => {
+ const {mode} = useThemeMode()
+ const isDark = mode === 'dark'
+ const [newMessage, setNewMessage] = useState('')
+ const messagesEndRef = useRef(null)
+
+ const scrollToBottom = () => {
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
+ }
+
+ useEffect(() => {
+ scrollToBottom()
+ }, [messages])
+
+ const handleSend = () => {
+ if (!newMessage.trim() || disabled) return
+ onSendMessage?.(newMessage.trim())
+ setNewMessage('')
+ }
+
+ const handleKeyPress = (e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault()
+ handleSend()
+ }
+ }
+
+ const formatTime = (date) => {
+ const d = new Date(date)
+ return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })
+ }
+
+ return (
+
+ {/* 채팅 메시지 영역 */}
+
+ {messages.length === 0 ? (
+
+
+ 대기 중 채팅을 시작해보세요!
+
+
+ ) : (
+ messages.map((message) => {
+ const isOwn = message.userId === currentUserId
+ const isSystem = message.isSystem
+
+ if (isSystem) {
+ return (
+
+
+ {message.content}
+
+
+ )
+ }
+
+ return (
+
+ {!isOwn && (
+
+ {message.nickname?.charAt(0) || message.userId?.charAt(0) || 'U'}
+
+ )}
+
+
+ {!isOwn && (
+
+ {message.nickname || message.userId}
+
+ )}
+
+
+ {isOwn && (
+
+ {formatTime(message.createdAt)}
+
+ )}
+
+
+
+ {message.content}
+
+
+
+ {!isOwn && (
+
+ {formatTime(message.createdAt)}
+
+ )}
+
+
+
+ )
+ })
+ )}
+
+
+
+ {/* 입력 영역 */}
+
+ setNewMessage(e.target.value)}
+ onKeyPress={handleKeyPress}
+ disabled={disabled}
+ size="small"
+ sx={{
+ '& .MuiOutlinedInput-root': {
+ borderRadius: '10px',
+ },
+ }}
+ />
+
+
+
+
+
+ )
+}
+
+export default WaitingChat
diff --git a/src/domains/games/pages/CatchmindLobbyPage.jsx b/src/domains/games/pages/CatchmindLobbyPage.jsx
new file mode 100644
index 0000000..d44b063
--- /dev/null
+++ b/src/domains/games/pages/CatchmindLobbyPage.jsx
@@ -0,0 +1,303 @@
+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,
+ Brush as BrushIcon,
+ 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 CatchmindLobbyPage = () => {
+ 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,
+ })
+
+ // 방 목록 조회
+ 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)
+ setRooms(response.data.rooms)
+ } catch (err) {
+ console.error('Failed to fetch rooms:', err)
+ setError('방 목록을 불러오는데 실패했습니다')
+ } finally {
+ setLoading(false)
+ }
+ }, [filters])
+
+ useEffect(() => {
+ fetchRooms()
+ }, [fetchRooms])
+
+ // 방 생성
+ const handleCreateRoom = async (data) => {
+ try {
+ setCreating(true)
+ const response = await gameService.createRoom(data)
+ setCreateModalOpen(false)
+ // 생성된 방의 대기실로 이동
+ navigate(`/games/catchmind/${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/catchmind/${room.roomId}/waiting`)
+ } catch (err) {
+ console.error('Failed to join room:', err)
+ setError(err.message || '방 참가에 실패했습니다')
+ }
+ }
+
+ // 관전
+ const handleSpectateRoom = (room) => {
+ navigate(`/games/catchmind/${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={대기중만}
+ />
+
+
+
+
+
+
+
+
+ {/* 방 만들기 버튼 */}
+ }
+ onClick={() => setCreateModalOpen(true)}
+ sx={{
+ bgcolor: GAME_COLORS.primary,
+ '&:hover': { bgcolor: GAME_COLORS.primaryLight },
+ borderRadius: '12px',
+ textTransform: 'none',
+ fontWeight: 600,
+ px: 3,
+ }}
+ >
+ 방 만들기
+
+
+
+ {/* 에러 */}
+ {error && (
+ setError(null)} sx={{ mb: 3, borderRadius: '12px' }}>
+ {error}
+
+ )}
+
+ {/* 방 목록 */}
+ {loading ? (
+
+
+
+ ) : rooms.length === 0 ? (
+
+
+
+
+
+ 대기 중인 방이 없습니다
+
+
+ 새로운 게임방을 만들어보세요!
+
+ }
+ onClick={() => setCreateModalOpen(true)}
+ sx={{
+ bgcolor: GAME_COLORS.primary,
+ '&:hover': { bgcolor: GAME_COLORS.primaryLight },
+ borderRadius: '12px',
+ textTransform: 'none',
+ fontWeight: 600,
+ }}
+ >
+ 방 만들기
+
+
+ ) : (
+
+ {rooms.map((room) => (
+
+
+
+ ))}
+
+ )}
+
+ {/* 방 생성 모달 */}
+ setCreateModalOpen(false)}
+ onCreate={handleCreateRoom}
+ loading={creating}
+ />
+
+ )
+}
+
+export default CatchmindLobbyPage
diff --git a/src/domains/games/pages/CatchmindPlayPage.jsx b/src/domains/games/pages/CatchmindPlayPage.jsx
new file mode 100644
index 0000000..b256539
--- /dev/null
+++ b/src/domains/games/pages/CatchmindPlayPage.jsx
@@ -0,0 +1,937 @@
+import {useCallback, useEffect, useRef, useState} from 'react'
+import {useNavigate, useParams} from 'react-router-dom'
+import {
+ Alert,
+ Box,
+ Button,
+ Chip,
+ CircularProgress,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ IconButton,
+ LinearProgress,
+ Typography,
+} from '@mui/material'
+import {
+ EmojiEvents as TrophyIcon,
+ ExitToApp as ExitIcon,
+ Lightbulb as HintIcon,
+ Replay as ReplayIcon,
+ Stop as StopIcon,
+} from '@mui/icons-material'
+import GameCanvas from '../components/GameCanvas'
+import GameChat from '../components/GameChat'
+import BubbleOverlay from '../components/BubbleOverlay'
+import {gameService} from '../services/gameService'
+import {GAME_COLORS, GAME_LAYOUT, GAME_TYPOGRAPHY} from '../theme/gameTheme'
+import {useAuth} from '../../../contexts/AuthContext'
+import {useThemeMode} from '../../../contexts/ThemeContext'
+import {useChatWebSocket} from '../../freetalk/hooks/useChatWebSocket'
+
+const CatchmindPlayPage = () => {
+ const { roomId } = useParams()
+ const navigate = useNavigate()
+ const { user } = useAuth()
+ const { mode } = useThemeMode()
+ const isDark = mode === 'dark'
+ const currentUserId = user?.userId || user?.username || user?.sub
+ const canvasRef = useRef(null)
+
+ // WebSocket 연결
+ const {
+ isConnected,
+ messages: wsMessages,
+ gameState: wsGameState,
+ error: wsError,
+ receivedDrawing,
+ shouldClearCanvas,
+ correctAnswerBubble,
+ connect,
+ disconnect,
+ sendMessage: wsSendMessage,
+ sendDrawing,
+ clearDrawing,
+ setReceivedDrawing,
+ setShouldClearCanvas,
+ setCorrectAnswerBubble,
+ } = useChatWebSocket(roomId, currentUserId)
+
+ // 로컬 게임 상태 (WebSocket에서 받은 값으로 업데이트)
+ const [localGameState, setLocalGameState] = useState({
+ status: 'PLAYING',
+ currentRound: 1,
+ totalRounds: 5,
+ currentDrawerId: null,
+ currentWord: null,
+ roundStartTime: Date.now(),
+ roundTimeLimit: 60,
+ scores: {},
+ hintUsed: false,
+ hint: null,
+ })
+
+ // WebSocket gameState가 업데이트되면 로컬 상태에 병합
+ useEffect(() => {
+ if (wsGameState) {
+ console.log('[CatchmindPlayPage] Received wsGameState:', wsGameState)
+
+ // 라운드가 변경되면 전환 상태 해제 및 타이머 리셋
+ setLocalGameState(prev => {
+ // undefined 값은 무시하고 기존 값 유지
+ const newRound = wsGameState.currentRound ?? prev.currentRound
+ const newDrawer = wsGameState.currentDrawerId ?? prev.currentDrawerId
+ const newRoundTimeLimit = wsGameState.roundTimeLimit ?? prev.roundTimeLimit ?? 60
+
+ // 실제로 값이 변경되었는지 확인 (undefined가 아닌 새로운 값이 왔을 때만)
+ const roundChanged = wsGameState.currentRound !== undefined && prev.currentRound !== wsGameState.currentRound
+ const drawerChanged = wsGameState.currentDrawerId !== undefined && prev.currentDrawerId !== wsGameState.currentDrawerId
+
+ if (roundChanged || drawerChanged) {
+ console.log('[CatchmindPlayPage] Round or drawer changed:', {
+ prevRound: prev.currentRound,
+ newRound: newRound,
+ prevDrawer: prev.currentDrawerId,
+ newDrawer: newDrawer,
+ })
+ // 라운드 전환 완료 - 타이머 리셋
+ setIsRoundTransitioning(false)
+ setTimeLeft(newRoundTimeLimit)
+ }
+
+ // undefined 값은 기존 값으로 유지
+ return {
+ ...prev,
+ status: wsGameState.status ?? prev.status,
+ currentRound: newRound,
+ totalRounds: wsGameState.totalRounds ?? prev.totalRounds,
+ currentDrawerId: newDrawer,
+ currentWord: wsGameState.currentWord ?? prev.currentWord,
+ roundStartTime: wsGameState.roundStartTime ?? prev.roundStartTime,
+ roundTimeLimit: newRoundTimeLimit,
+ scores: wsGameState.scores ?? prev.scores,
+ hintUsed: wsGameState.hintUsed ?? prev.hintUsed,
+ hint: wsGameState.hint ?? prev.hint,
+ correctGuessers: wsGameState.correctGuessers ?? prev.correctGuessers,
+ drawerOrder: wsGameState.drawerOrder ?? prev.drawerOrder,
+ lastAnswer: wsGameState.lastAnswer ?? prev.lastAnswer,
+ finalScores: wsGameState.finalScores ?? prev.finalScores,
+ }
+ })
+ }
+ }, [wsGameState])
+
+ const gameState = localGameState
+
+ const [room, setRoom] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [timeLeft, setTimeLeft] = useState(60)
+ const [messages, setMessages] = useState([])
+ const [bubbles, setBubbles] = useState([])
+ const [showEndModal, setShowEndModal] = useState(false)
+ const [finalRanking, setFinalRanking] = useState([])
+ const [isRoundTransitioning, setIsRoundTransitioning] = useState(false)
+
+ const isDrawer = gameState.currentDrawerId === currentUserId
+ const timerDanger = timeLeft <= 10
+ // 라운드 전환 중이거나 타이머가 0이면 그리기 비활성화
+ const canDraw = isDrawer && !isRoundTransitioning && timeLeft > 0
+
+ // WebSocket 메시지를 로컬 messages에 동기화 (중복 제거)
+ useEffect(() => {
+ if (wsMessages && wsMessages.length > 0) {
+ setMessages(prev => {
+ // 로컬 시스템 메시지 유지 (ID가 system-으로 시작하는 것)
+ const localSystemMessages = prev.filter(m =>
+ m.isSystem && m.id && m.id.startsWith('system-')
+ )
+
+ // wsMessages와 로컬 시스템 메시지 병합 후 ID 기준 중복 제거
+ const merged = [...localSystemMessages, ...wsMessages]
+ const seen = new Set()
+ return merged.filter(m => {
+ if (!m.id || seen.has(m.id)) return false
+ seen.add(m.id)
+ return true
+ }).sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
+ })
+ }
+ }, [wsMessages])
+
+ // 수신된 그리기 데이터 캔버스에 적용
+ useEffect(() => {
+ if (receivedDrawing && canvasRef.current) {
+ console.log('[CatchmindPlayPage] Applying received drawing:', receivedDrawing)
+ canvasRef.current.drawStroke(receivedDrawing)
+ setReceivedDrawing(null)
+ }
+ }, [receivedDrawing, setReceivedDrawing])
+
+ // 캔버스 클리어 명령 처리
+ useEffect(() => {
+ if (shouldClearCanvas && canvasRef.current) {
+ console.log('[CatchmindPlayPage] Clearing canvas from WebSocket')
+ canvasRef.current.clear()
+ setShouldClearCanvas(false)
+ }
+ }, [shouldClearCanvas, setShouldClearCanvas])
+
+ // 정답 비눗방울 처리
+ useEffect(() => {
+ if (correctAnswerBubble) {
+ const participant = room?.participants?.find(p => p.userId === correctAnswerBubble.userId)
+ setBubbles([{
+ id: `bubble-${correctAnswerBubble.timestamp}`,
+ userId: correctAnswerBubble.userId,
+ nickname: participant?.nickname || correctAnswerBubble.userId,
+ content: correctAnswerBubble.content,
+ isCorrect: true,
+ }])
+ setCorrectAnswerBubble(null)
+ }
+ }, [correctAnswerBubble, room?.participants, setCorrectAnswerBubble])
+
+ // 게임 종료 처리 (WebSocket에서 GAME_END 이벤트 수신 시)
+ useEffect(() => {
+ if (gameState.status === 'FINISHED' && !showEndModal) {
+ console.log('[CatchmindPlayPage] Game finished, showing end modal')
+ const ranking = Object.entries(gameState.scores || {})
+ .sort(([, a], [, b]) => b - a)
+ .map(([odUserId, score], index) => ({
+ rank: index + 1,
+ userId: odUserId,
+ nickname: room?.participants?.find(p => p.userId === odUserId)?.nickname || odUserId,
+ score,
+ }))
+
+ setFinalRanking(ranking)
+ setShowEndModal(true)
+ }
+ }, [gameState.status, gameState.scores, room?.participants, showEndModal])
+
+ // 라운드 변경 시 타이머 리셋 및 캔버스 초기화
+ useEffect(() => {
+ if (gameState.currentRound > 1) {
+ setTimeLeft(gameState.roundTimeLimit || 60)
+ canvasRef.current?.clear()
+ }
+ }, [gameState.currentRound, gameState.roundTimeLimit])
+
+ // 초기 로드 및 WebSocket 연결
+ useEffect(() => {
+ const initGame = async () => {
+ try {
+ setLoading(true)
+
+ // 방 정보 조회
+ const roomResponse = await gameService.getRoom(roomId)
+ setRoom(roomResponse.data)
+
+ // 게임 상태 조회 (이미 시작된 게임인 경우)
+ let gameData
+ try {
+ const statusResponse = await gameService.getGameStatus(roomId)
+ gameData = statusResponse.data
+ console.log('[CatchmindPlayPage] Game status from API:', JSON.stringify(gameData, null, 2))
+ } catch {
+ // 게임 상태가 없으면 시작
+ const gameResponse = await gameService.startGame(roomId)
+ gameData = gameResponse.data
+ console.log('[CatchmindPlayPage] Game start response:', JSON.stringify(gameData, null, 2))
+ }
+
+ // 제시어 추출 (다양한 필드명 시도)
+ const extractedWord = gameData.currentWord?.word
+ || gameData.currentWord?.korean
+ || gameData.currentWord
+ || gameData.word
+ || gameData.answer
+ || null
+ console.log('[CatchmindPlayPage] Extracted word:', extractedWord, 'from gameData.currentWord:', gameData.currentWord)
+
+ setLocalGameState({
+ status: 'PLAYING',
+ currentRound: gameData.currentRound || 1,
+ totalRounds: gameData.totalRounds || 5,
+ currentDrawerId: gameData.currentDrawerId,
+ currentWord: extractedWord,
+ roundStartTime: gameData.roundStartTime,
+ roundTimeLimit: gameData.roundDuration || 60,
+ scores: {},
+ hintUsed: false,
+ hint: null,
+ })
+
+ setTimeLeft(gameData.roundDuration || 60)
+
+ // 시스템 메시지
+ setMessages([
+ {
+ id: 'system-start',
+ content: `🎮 게임 시작! 총 ${gameData.totalRounds || 5}라운드`,
+ isSystem: true,
+ createdAt: new Date().toISOString(),
+ },
+ {
+ id: 'system-round',
+ content: `라운드 ${gameData.currentRound || 1} 시작! 출제자: ${gameData.currentDrawerId}`,
+ isSystem: true,
+ createdAt: new Date().toISOString(),
+ },
+ ])
+
+ // WebSocket 연결
+ console.log('[CatchmindPlayPage] Connecting WebSocket...')
+ await connect()
+ console.log('[CatchmindPlayPage] WebSocket connected')
+ } catch (err) {
+ console.error('Failed to init game:', err)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ initGame()
+
+ // 컴포넌트 언마운트 시 WebSocket 연결 해제
+ return () => {
+ console.log('[CatchmindPlayPage] Disconnecting WebSocket...')
+ disconnect()
+ }
+ }, [roomId, currentUserId, user, connect, disconnect])
+
+ // 라운드 종료 처리 (WebSocket에서 ROUND_END/GAME_END 이벤트를 받으면 자동 처리됨)
+ // 이 함수는 클라이언트 측 타이머 만료 시 fallback으로만 사용
+ const handleRoundEnd = useCallback((answer, reason) => {
+ // WebSocket 연결 시에는 서버에서 ROUND_END 이벤트를 받으므로
+ // 클라이언트 측 라운드 종료 처리는 최소화
+ if (isConnected) {
+ console.log('[CatchmindPlayPage] Round end triggered locally, waiting for server event')
+ return
+ }
+
+ const isLastRound = gameState.currentRound >= gameState.totalRounds
+
+ if (isLastRound) {
+ // 게임 종료
+ const ranking = Object.entries(gameState.scores)
+ .sort(([, a], [, b]) => b - a)
+ .map(([userId, score], index) => ({
+ rank: index + 1,
+ userId,
+ nickname: room?.participants?.find(p => p.userId === userId)?.nickname || userId,
+ score,
+ }))
+
+ setFinalRanking(ranking)
+ setShowEndModal(true)
+ setLocalGameState(prev => ({ ...prev, status: 'FINISHED' }))
+
+ setMessages(prev => [...prev, {
+ id: `system-end-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
+ content: '🎮 게임 종료!',
+ isSystem: true,
+ createdAt: new Date().toISOString(),
+ }])
+ } else {
+ // 다음 라운드 (WebSocket 연결 없을 때 fallback)
+ const nextRound = gameState.currentRound + 1
+ const participants = room?.participants || []
+ const nextDrawerIndex = (nextRound - 1) % participants.length
+ const nextDrawer = participants[nextDrawerIndex]?.userId || currentUserId
+ const uniqueSuffix = Math.random().toString(36).substr(2, 9)
+
+ setMessages(prev => [...prev, {
+ id: `system-round-end-${Date.now()}-${uniqueSuffix}`,
+ content: `${reason || '라운드 종료!'} 정답: ${gameState.currentWord || '???'}`,
+ isSystem: true,
+ createdAt: new Date().toISOString(),
+ }])
+
+ // 캔버스 초기화
+ canvasRef.current?.clear()
+
+ setTimeout(() => {
+ const uniqueSuffix2 = Math.random().toString(36).substr(2, 9)
+ setLocalGameState(prev => ({
+ ...prev,
+ currentRound: nextRound,
+ currentDrawerId: nextDrawer,
+ currentWord: null,
+ roundStartTime: Date.now(),
+ hintUsed: false,
+ hint: null,
+ }))
+
+ setTimeLeft(gameState.roundTimeLimit)
+ setIsRoundTransitioning(false)
+
+ setMessages(prev => [...prev, {
+ id: `system-next-${Date.now()}-${uniqueSuffix2}`,
+ content: `라운드 ${nextRound} 시작! 출제자: ${nextDrawer}`,
+ isSystem: true,
+ createdAt: new Date().toISOString(),
+ }])
+ }, 2000)
+ }
+ }, [gameState, room, currentUserId, isConnected])
+
+ // 타이머
+ useEffect(() => {
+ if (gameState.status !== 'PLAYING') return
+ if (isRoundTransitioning) return // 라운드 전환 중에는 타이머 중지
+
+ const interval = setInterval(() => {
+ setTimeLeft(prev => {
+ if (prev <= 1) {
+ // 시간 초과 - 서버에 ROUND_TIMEOUT 전송
+ if (isConnected) {
+ console.log('[CatchmindPlayPage] Timer expired, sending ROUND_TIMEOUT to server')
+ setIsRoundTransitioning(true) // 라운드 전환 상태로 변경
+ // 서버에 타임아웃 알림 전송
+ wsSendMessage('', 'ROUND_TIMEOUT')
+ return 0 // 타이머를 0으로 유지, 서버 ROUND_END 이벤트 대기
+ }
+ // 연결 안 됐으면 로컬 fallback
+ handleRoundEnd(null, '시간 초과!')
+ return gameState.roundTimeLimit
+ }
+ return prev - 1
+ })
+ }, 1000)
+
+ return () => clearInterval(interval)
+ }, [gameState.status, gameState.roundTimeLimit, isConnected, isRoundTransitioning, handleRoundEnd, wsSendMessage])
+
+ // 메시지 전송 (WebSocket으로 전송, 서버에서 정답 체크)
+ const handleSendMessage = useCallback((content) => {
+ if (isConnected) {
+ // WebSocket 연결 시: 서버로 메시지 전송 (GUESS 타입)
+ // 서버에서 정답 체크 후 CORRECT_ANSWER 또는 일반 메시지로 브로드캐스트
+ console.log('[CatchmindPlayPage] Sending message via WebSocket:', content)
+ wsSendMessage(content, 'GUESS')
+
+ // 오답 비눗방울은 로컬에서 표시 (서버 응답 기다리지 않음)
+ setBubbles([{
+ id: `bubble-${Date.now()}`,
+ userId: currentUserId,
+ nickname: user?.nickname || '플레이어',
+ content,
+ isCorrect: false,
+ }])
+ } else {
+ // WebSocket 연결 안 됐을 때: 로컬에서 처리 (fallback)
+ const newMessage = {
+ id: `msg-${Date.now()}`,
+ userId: currentUserId,
+ nickname: user?.nickname || user?.username || '플레이어',
+ content,
+ createdAt: new Date().toISOString(),
+ }
+
+ // 정답 체크 (로컬 fallback)
+ const isCorrect = gameState.currentWord &&
+ content.toLowerCase().includes(gameState.currentWord.toLowerCase())
+
+ if (isCorrect) {
+ const score = Math.max(10, Math.floor(timeLeft * 0.5) + 10)
+
+ newMessage.isCorrect = true
+ newMessage.score = score
+
+ // 점수 업데이트
+ setLocalGameState(prev => ({
+ ...prev,
+ scores: {
+ ...prev.scores,
+ [currentUserId]: (prev.scores[currentUserId] || 0) + score,
+ },
+ }))
+
+ // 비눗방울 (정답)
+ setBubbles([{
+ id: `bubble-${Date.now()}`,
+ userId: currentUserId,
+ nickname: user?.nickname || '플레이어',
+ content,
+ isCorrect: true,
+ score,
+ }])
+
+ // 시스템 메시지
+ setMessages(prev => [...prev, {
+ id: `system-correct-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
+ content: `🎉 ${user?.nickname || currentUserId}님이 정답을 맞혔습니다! (+${score}점)`,
+ isSystem: true,
+ isCorrect: true,
+ createdAt: new Date().toISOString(),
+ }])
+
+ // 다음 라운드로
+ setTimeout(() => {
+ handleRoundEnd(content, '정답!')
+ }, 1500)
+ } else {
+ newMessage.isWrong = true
+
+ // 비눗방울 (오답)
+ setBubbles([{
+ id: `bubble-${Date.now()}`,
+ userId: currentUserId,
+ nickname: user?.nickname || '플레이어',
+ content,
+ isCorrect: false,
+ }])
+ }
+
+ setMessages(prev => [...prev, newMessage])
+ }
+ }, [currentUserId, user, gameState.currentWord, timeLeft, handleRoundEnd, isConnected, wsSendMessage])
+
+ // 그리기 데이터 전송 (WebSocket으로 전송)
+ const handleDraw = useCallback((strokeData) => {
+ if (isConnected && canDraw) {
+ console.log('[CatchmindPlayPage] Sending drawing via WebSocket')
+ sendDrawing(strokeData)
+ }
+ }, [isConnected, canDraw, sendDrawing])
+
+ // 캔버스 클리어 (WebSocket으로 전송)
+ const handleClearCanvas = useCallback(() => {
+ if (isConnected && canDraw) {
+ console.log('[CatchmindPlayPage] Sending clear canvas via WebSocket')
+ clearDrawing()
+ }
+ }, [isConnected, canDraw, clearDrawing])
+
+ // 게임 종료
+ const handleStopGame = async () => {
+ try {
+ disconnect()
+ await gameService.stopGame(roomId)
+ sessionStorage.removeItem(`roomToken_${roomId}`)
+ navigate('/games/catchmind')
+ } catch (err) {
+ console.error('Failed to stop game:', err)
+ }
+ }
+
+ // 재시작
+ const handleRestart = async () => {
+ try {
+ disconnect()
+ await gameService.restartGame(roomId)
+ setShowEndModal(false)
+ navigate(`/games/catchmind/${roomId}/waiting`)
+ } catch (err) {
+ console.error('Failed to restart:', err)
+ // 실패해도 대기실로 이동
+ setShowEndModal(false)
+ navigate(`/games/catchmind/${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/catchmind')
+ }
+
+ // 타이머 포맷
+ const formatTime = (seconds) => {
+ const min = Math.floor(seconds / 60)
+ const sec = seconds % 60
+ return `${min}:${sec.toString().padStart(2, '0')}`
+ }
+
+ // 점수 순위 - 모든 참가자를 0점부터 시작하여 표시
+ const sortedScores = (room?.participants || [])
+ .map(participant => ({
+ userId: participant.userId,
+ nickname: participant.nickname || participant.userId,
+ score: (gameState.scores || {})[participant.userId] || 0,
+ }))
+ .sort((a, b) => b.score - a.score)
+ .map((item, index) => ({
+ ...item,
+ rank: index + 1,
+ }))
+
+ if (loading) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+ {/* 헤더 - 컴팩트 */}
+
+
+
+ {room?.name || '캐치마인드'}
+
+
+
+ 출제자: {room?.participants?.find(p => p.userId === gameState.currentDrawerId)?.nickname || gameState.currentDrawerId}
+
+
+
+
+ {/* 타이머 */}
+
+
+ {formatTime(timeLeft)}
+
+
+
+
+
+
+
+
+
+ {/* 타이머 프로그레스 */}
+
+
+ {/* WebSocket 에러 */}
+ {wsError && (
+
+ {wsError}
+
+ )}
+
+ {/* 제시어 배너 (출제자만) - 컴팩트 */}
+ {isDrawer && gameState.currentWord && (
+
+
+ 제시어:
+
+
+ {gameState.currentWord}
+
+
+ )}
+
+ {/* 메인 영역: Split View */}
+
+ {/* 좌측: 캔버스 영역 (65%) */}
+
+
+
+ {/* 비눗방울 오버레이 */}
+
+
+ {/* 힌트 영역 - 컴팩트 */}
+ {!isDrawer && (
+
+ }
+ disabled={gameState.hintUsed}
+ size="small"
+ sx={{ textTransform: 'none', fontSize: '0.75rem', py: 0.25 }}
+ >
+ {gameState.hint ? `힌트: ${gameState.hint}` : '힌트 요청'}
+
+
+ )}
+
+
+ {/* 우측: 채팅 영역 (35%) */}
+
+ {/* 스코어 대시보드 - 컴팩트 */}
+
+
+
+
+ 점수판
+
+
+
+ {sortedScores.map((item) => (
+
+
+
+ {item.rank === 1 ? '🥇' : item.rank === 2 ? '🥈' : item.rank === 3 ? '🥉' : `${item.rank}.`}
+
+
+ {item.nickname}
+ {item.userId === currentUserId && ' (나)'}
+
+
+
+ {item.score}점
+
+
+ ))}
+
+
+
+ {/* 채팅 영역 */}
+
+
+
+
+
+
+ {/* 게임 종료 모달 */}
+
+
+ )
+}
+
+export default CatchmindPlayPage
diff --git a/src/domains/games/pages/CatchmindWaitingPage.jsx b/src/domains/games/pages/CatchmindWaitingPage.jsx
new file mode 100644
index 0000000..5b06f14
--- /dev/null
+++ b/src/domains/games/pages/CatchmindWaitingPage.jsx
@@ -0,0 +1,406 @@
+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 CatchmindWaitingPage = () => {
+ 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 [isInitialLoad, setIsInitialLoad] = 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')
+ // wsMessages에서 중복 제거 (ID 기준)
+ const existingIds = new Set(systemMessages.map(m => m.id))
+ const uniqueWsMessages = wsMessages.filter(m => !existingIds.has(m.id))
+ // ID 기준으로 중복 제거된 최종 배열
+ const allMessages = [...systemMessages, ...uniqueWsMessages]
+ // 혹시 wsMessages 내에서도 중복이 있을 수 있으므로 다시 한번 ID 기준 중복 제거
+ 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('[CatchmindWaitingPage] Game started via WebSocket, navigating to play page')
+ navigate(`/games/catchmind/${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/catchmind/${roomId}/play`)
+ }
+ } catch (err) {
+ console.error('Failed to fetch room:', err)
+ setError('방 정보를 불러오는데 실패했습니다')
+ } finally {
+ if (showLoading) {
+ setLoading(false)
+ }
+ setIsInitialLoad(false)
+ }
+ }, [roomId, navigate])
+
+ // 초기 로딩 및 WebSocket 연결
+ useEffect(() => {
+ const init = async () => {
+ await fetchRoom(true)
+ // WebSocket 연결
+ console.log('[CatchmindWaitingPage] Connecting WebSocket...')
+ try {
+ await connect()
+ console.log('[CatchmindWaitingPage] WebSocket connected')
+ } catch (err) {
+ console.error('[CatchmindWaitingPage] WebSocket connection failed:', err)
+ }
+ }
+ init()
+
+ // 컴포넌트 언마운트 시 WebSocket 연결 해제
+ return () => {
+ console.log('[CatchmindWaitingPage] 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)
+ // WebSocket GAME_START 이벤트로 자동 이동하지만, fallback으로 직접 이동
+ navigate(`/games/catchmind/${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)
+ // 저장된 roomToken 삭제
+ sessionStorage.removeItem(`roomToken_${roomId}`)
+ navigate('/games/catchmind')
+ } catch (err) {
+ console.error('Failed to leave room:', err)
+ }
+ }
+
+ // 채팅 메시지 전송 (WebSocket 연동)
+ const handleSendMessage = (content) => {
+ if (isConnected) {
+ // WebSocket으로 메시지 전송
+ console.log('[CatchmindWaitingPage] Sending message via WebSocket:', content)
+ wsSendMessage(content, 'TEXT')
+ } else {
+ // WebSocket 연결 안 됐을 때 fallback (로컬만)
+ 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?.maxRounds || 5}라운드
+
+
+
+
+ 시간 제한
+
+
+ {room.gameSettings?.roundTimeLimit || 60}초
+
+
+
+
+ {isHost ? (
+ }
+ onClick={handleStartGame}
+ disabled={!canStart || starting}
+ sx={{
+ bgcolor: canStart ? GAME_COLORS.status.waiting : '#9CA3AF',
+ '&:hover': { bgcolor: canStart ? '#059669' : '#9CA3AF' },
+ borderRadius: '12px',
+ textTransform: 'none',
+ fontWeight: 700,
+ px: 4,
+ py: 1.5,
+ }}
+ >
+ {starting ? '시작 중...' : canStart ? '게임 시작' : '2명 이상 필요'}
+
+ ) : (
+
+
+ 방장이 게임을 시작할 때까지 기다려주세요
+
+
+
+ )}
+
+
+
+ )
+}
+
+export default CatchmindWaitingPage
diff --git a/src/domains/games/services/gameService.js b/src/domains/games/services/gameService.js
new file mode 100644
index 0000000..1c3958f
--- /dev/null
+++ b/src/domains/games/services/gameService.js
@@ -0,0 +1,342 @@
+/**
+ * Game Service - 백엔드 API 연동
+ * 캐치마인드 게임 관련 REST API 호출
+ */
+import chatApi from '../../../api/chatApi'
+
+/**
+ * 게임방 관련 API
+ */
+export const gameRoomService = {
+ /**
+ * 게임방 목록 조회
+ * @param {Object} filters - 필터 옵션
+ * @param {string} filters.status - WAITING, PLAYING, FINISHED
+ * @param {string} filters.level - BEGINNER, INTERMEDIATE, ADVANCED
+ * @param {number} filters.limit - 조회 개수 (기본 20)
+ * @param {string} filters.cursor - 페이지네이션 커서
+ */
+ getList: async (filters = {}) => {
+ const params = new URLSearchParams()
+ // 게임방 필터
+ params.append('type', 'GAME')
+ params.append('gameType', 'CATCHMIND')
+
+ // 백엔드는 소문자 level 값 사용
+ if (filters.status) params.append('status', filters.status)
+ if (filters.level) params.append('level', filters.level.toLowerCase())
+ if (filters.limit) params.append('limit', filters.limit || 20)
+ if (filters.cursor) params.append('cursor', filters.cursor)
+
+ console.log('[gameService] getList params:', params.toString())
+ const response = await chatApi.get(`/chat/rooms?${params.toString()}`)
+
+ // 백엔드가 type 필터를 지원하지 않을 경우, 클라이언트 사이드 필터링
+ let data = response.data
+ if (data?.rooms) {
+ data.rooms = data.rooms.filter(room =>
+ room.type === 'GAME' || room.gameType === 'CATCHMIND'
+ )
+ } else if (Array.isArray(data)) {
+ data = data.filter(room =>
+ room.type === 'GAME' || room.gameType === 'CATCHMIND'
+ )
+ }
+
+ return data
+ },
+
+ /**
+ * 게임방 상세 조회
+ * @param {string} roomId
+ */
+ getDetail: async (roomId) => {
+ const response = await chatApi.get(`/chat/rooms/${roomId}`)
+ return response.data
+ },
+
+ /**
+ * 게임방 생성
+ * @param {Object} data
+ * @param {string} data.name - 방 이름
+ * @param {string} data.description - 방 설명
+ * @param {string} data.level - BEGINNER, INTERMEDIATE, ADVANCED
+ * @param {number} data.maxMembers - 최대 인원 (2-10)
+ * @param {boolean} data.isPrivate - 비공개 여부
+ * @param {string} data.password - 비공개 방 비밀번호
+ * @param {Object} data.gameSettings - 게임 설정
+ */
+ create: async (data) => {
+ const payload = {
+ name: data.name,
+ description: data.description || '',
+ level: (data.level || 'beginner').toLowerCase(), // 백엔드는 소문자 사용
+ maxMembers: data.maxParticipants || data.maxMembers || 6,
+ isPrivate: data.isPrivate || false,
+ password: data.password,
+ type: 'GAME',
+ gameType: 'CATCHMIND',
+ gameSettings: {
+ maxRounds: data.maxRounds || 5,
+ roundTimeLimit: data.roundTimeLimit || 60,
+ },
+ }
+
+ console.log('[gameService] create payload:', payload)
+ const response = await chatApi.post('/chat/rooms', payload)
+ console.log('[gameService] create response:', response)
+ return response.data
+ },
+
+ /**
+ * 게임방 참가
+ * @param {string} roomId
+ * @param {string} password - 비공개 방인 경우
+ * @returns {Promise<{room: Object, roomToken: string, tokenExpiresAt: number}>}
+ */
+ join: async (roomId, password) => {
+ const payload = password ? { password } : {}
+ const response = await chatApi.post(`/chat/rooms/${roomId}/join`, payload)
+ return response.data
+ },
+
+ /**
+ * 게임방 나가기
+ * @param {string} roomId
+ */
+ leave: async (roomId) => {
+ const response = await chatApi.post(`/chat/rooms/${roomId}/leave`, {})
+ return response.data
+ },
+}
+
+/**
+ * 게임 진행 관련 API
+ */
+export const gamePlayService = {
+ /**
+ * 게임 시작
+ * @param {string} roomId
+ */
+ start: async (roomId) => {
+ const response = await chatApi.post(`/chat/rooms/${roomId}/game/start`, {})
+ return response.data
+ },
+
+ /**
+ * 게임 중단
+ * @param {string} roomId
+ */
+ stop: async (roomId) => {
+ const response = await chatApi.post(`/chat/rooms/${roomId}/game/stop`, {})
+ return response.data
+ },
+
+ /**
+ * 게임 상태 조회
+ * @param {string} roomId
+ */
+ getStatus: async (roomId) => {
+ const response = await chatApi.get(`/chat/rooms/${roomId}/game/status`)
+ return response.data
+ },
+
+ /**
+ * 점수 조회
+ * @param {string} roomId
+ */
+ getScores: async (roomId) => {
+ const response = await chatApi.get(`/chat/rooms/${roomId}/game/scores`)
+ return response.data
+ },
+
+ /**
+ * 게임 재시작
+ * @param {string} roomId
+ */
+ restart: async (roomId) => {
+ const response = await chatApi.post(`/chat/rooms/${roomId}/game/restart`, {})
+ return response.data
+ },
+}
+
+/**
+ * 통합 게임 서비스 (mockGameService 호환 인터페이스)
+ */
+export const gameService = {
+ // 방 목록 조회
+ getRooms: async (filters = {}) => {
+ try {
+ const data = await gameRoomService.getList(filters)
+ console.log('[gameService] getRooms data:', data)
+ // 백엔드 응답: { rooms: [...], nextCursor, hasMore } 또는 직접 배열
+ const rooms = Array.isArray(data) ? data : (data?.rooms || [])
+ return {
+ success: true,
+ data: {
+ rooms,
+ totalCount: rooms.length,
+ nextCursor: data?.nextCursor,
+ hasMore: data?.hasMore,
+ },
+ }
+ } catch (error) {
+ console.error('[gameService] getRooms error:', error)
+ throw error
+ }
+ },
+
+ // 방 상세 조회
+ getRoom: async (roomId) => {
+ try {
+ const data = await gameRoomService.getDetail(roomId)
+ // 백엔드 응답을 프론트엔드 형식으로 변환
+ const room = data.room || data
+ return {
+ success: true,
+ data: {
+ roomId: room.roomId,
+ name: room.name,
+ description: room.description,
+ type: room.type,
+ gameType: room.gameType,
+ level: room.level,
+ status: room.status,
+ hostId: room.hostId,
+ hostNickname: data.hostNickname,
+ maxParticipants: room.maxMembers,
+ currentParticipants: room.currentMembers,
+ participants: data.participants || room.memberIds?.map(id => ({ userId: id })) || [],
+ gameSettings: room.gameSettings || { maxRounds: 5, roundTimeLimit: 60 },
+ createdAt: room.createdAt,
+ },
+ }
+ } catch (error) {
+ console.error('[gameService] getRoom error:', error)
+ throw error
+ }
+ },
+
+ // 방 생성
+ createRoom: async (data) => {
+ try {
+ const response = await gameRoomService.create(data)
+ console.log('[gameService] createRoom response:', response)
+ // 백엔드 응답 구조에 따라 room 추출
+ const room = response?.roomId ? response : response
+ return {
+ success: true,
+ data: {
+ ...room,
+ // 프론트엔드 필드명 맞추기
+ maxParticipants: room.maxMembers || room.maxParticipants,
+ currentParticipants: room.currentMembers || room.currentParticipants || 1,
+ },
+ }
+ } catch (error) {
+ console.error('[gameService] createRoom error:', error)
+ throw error
+ }
+ },
+
+ // 방 참가
+ joinRoom: async (roomId, password) => {
+ try {
+ const data = await gameRoomService.join(roomId, password)
+ return {
+ success: true,
+ data: {
+ room: data.room,
+ roomToken: data.roomToken,
+ tokenExpiresAt: data.tokenExpiresAt,
+ },
+ }
+ } catch (error) {
+ console.error('[gameService] joinRoom error:', error)
+ throw error
+ }
+ },
+
+ // 방 나가기
+ leaveRoom: async (roomId) => {
+ try {
+ await gameRoomService.leave(roomId)
+ return { success: true }
+ } catch (error) {
+ console.error('[gameService] leaveRoom error:', error)
+ throw error
+ }
+ },
+
+ // 게임 시작
+ startGame: async (roomId) => {
+ try {
+ const data = await gamePlayService.start(roomId)
+ return {
+ success: true,
+ data,
+ }
+ } catch (error) {
+ console.error('[gameService] startGame error:', error)
+ throw error
+ }
+ },
+
+ // 게임 중단
+ stopGame: async (roomId) => {
+ try {
+ const data = await gamePlayService.stop(roomId)
+ return {
+ success: true,
+ data,
+ }
+ } catch (error) {
+ console.error('[gameService] stopGame error:', error)
+ throw error
+ }
+ },
+
+ // 게임 상태 조회
+ getGameStatus: async (roomId) => {
+ try {
+ const data = await gamePlayService.getStatus(roomId)
+ return {
+ success: true,
+ data,
+ }
+ } catch (error) {
+ console.error('[gameService] getGameStatus error:', error)
+ throw error
+ }
+ },
+
+ // 점수 조회
+ getScores: async (roomId) => {
+ try {
+ const data = await gamePlayService.getScores(roomId)
+ return {
+ success: true,
+ data,
+ }
+ } catch (error) {
+ console.error('[gameService] getScores error:', error)
+ throw error
+ }
+ },
+
+ // 게임 재시작
+ restartGame: async (roomId) => {
+ try {
+ const data = await gamePlayService.restart(roomId)
+ return {
+ success: true,
+ data,
+ }
+ } catch (error) {
+ console.error('[gameService] restartGame error:', error)
+ throw error
+ }
+ },
+}
+
+export default gameService
diff --git a/src/domains/games/services/mockGameService.js b/src/domains/games/services/mockGameService.js
new file mode 100644
index 0000000..ea08eda
--- /dev/null
+++ b/src/domains/games/services/mockGameService.js
@@ -0,0 +1,321 @@
+/**
+ * Mock Game Service
+ * 백엔드 API 구현 전 테스트용 목 서비스
+ */
+
+// 목 데이터
+const mockRooms = [
+ {
+ roomId: 'game-room-1',
+ name: '초보 환영방',
+ description: '편하게 오세요~',
+ type: 'GAME',
+ gameType: 'CATCHMIND',
+ level: 'BEGINNER',
+ status: 'WAITING',
+ hostId: 'user-1',
+ hostNickname: '홍길동',
+ maxParticipants: 6,
+ currentParticipants: 2,
+ participants: [
+ { userId: 'user-1', nickname: '홍길동', isHost: true },
+ { userId: 'user-2', nickname: '김철수', isHost: false },
+ ],
+ gameSettings: {
+ maxRounds: 5,
+ roundTimeLimit: 60,
+ },
+ createdAt: new Date().toISOString(),
+ },
+ {
+ roomId: 'game-room-2',
+ name: '고수만 오세요',
+ description: '빠른 게임 진행',
+ type: 'GAME',
+ gameType: 'CATCHMIND',
+ level: 'ADVANCED',
+ status: 'PLAYING',
+ hostId: 'user-3',
+ hostNickname: '이영희',
+ maxParticipants: 4,
+ currentParticipants: 4,
+ participants: [
+ { userId: 'user-3', nickname: '이영희', isHost: true },
+ { userId: 'user-4', nickname: '박민수', isHost: false },
+ { userId: 'user-5', nickname: '정수진', isHost: false },
+ { userId: 'user-6', nickname: '최동욱', isHost: false },
+ ],
+ gameSettings: {
+ maxRounds: 3,
+ roundTimeLimit: 45,
+ },
+ createdAt: new Date(Date.now() - 600000).toISOString(),
+ },
+ {
+ roomId: 'game-room-3',
+ name: '아무나 ㄱㄱ',
+ description: '',
+ type: 'GAME',
+ gameType: 'CATCHMIND',
+ level: 'INTERMEDIATE',
+ status: 'WAITING',
+ hostId: 'user-7',
+ hostNickname: '강지훈',
+ maxParticipants: 6,
+ currentParticipants: 1,
+ participants: [
+ { userId: 'user-7', nickname: '강지훈', isHost: true },
+ ],
+ gameSettings: {
+ maxRounds: 5,
+ roundTimeLimit: 60,
+ },
+ createdAt: new Date(Date.now() - 300000).toISOString(),
+ },
+]
+
+const mockWords = [
+ '사과', '바나나', '호랑이', '자동차', '비행기',
+ '컴퓨터', '햄버거', '피자', '축구', '농구',
+ '기타', '피아노', '나비', '꽃', '태양',
+]
+
+// 지연 시뮬레이션
+const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms))
+
+// 현재 사용자 (테스트용)
+let currentUserId = 'test-user'
+let currentUserNickname = '테스트유저'
+
+export const mockGameService = {
+ // 현재 사용자 설정 (테스트용)
+ setCurrentUser: (userId, nickname) => {
+ currentUserId = userId
+ currentUserNickname = nickname
+ },
+
+ // 방 목록 조회
+ getRooms: async (filters = {}) => {
+ await delay(300)
+
+ let rooms = [...mockRooms]
+
+ if (filters.status) {
+ rooms = rooms.filter(r => r.status === filters.status)
+ }
+ if (filters.level) {
+ rooms = rooms.filter(r => r.level === filters.level)
+ }
+
+ return {
+ success: true,
+ data: {
+ rooms,
+ totalCount: rooms.length,
+ },
+ }
+ },
+
+ // 방 상세 조회
+ getRoom: async (roomId) => {
+ await delay(200)
+
+ const room = mockRooms.find(r => r.roomId === roomId)
+ if (!room) {
+ throw new Error('방을 찾을 수 없습니다')
+ }
+
+ return {
+ success: true,
+ data: room,
+ }
+ },
+
+ // 방 생성
+ createRoom: async (data) => {
+ await delay(500)
+
+ const newRoom = {
+ roomId: `game-room-${Date.now()}`,
+ name: data.name,
+ description: data.description || '',
+ type: 'GAME',
+ gameType: 'CATCHMIND',
+ level: data.level || 'BEGINNER',
+ status: 'WAITING',
+ hostId: currentUserId,
+ hostNickname: currentUserNickname,
+ maxParticipants: data.maxParticipants || 6,
+ currentParticipants: 1,
+ participants: [
+ { userId: currentUserId, nickname: currentUserNickname, isHost: true },
+ ],
+ gameSettings: {
+ maxRounds: data.maxRounds || 5,
+ roundTimeLimit: data.roundTimeLimit || 60,
+ },
+ createdAt: new Date().toISOString(),
+ }
+
+ mockRooms.unshift(newRoom)
+
+ return {
+ success: true,
+ data: newRoom,
+ }
+ },
+
+ // 방 참가
+ joinRoom: async (roomId) => {
+ await delay(300)
+
+ const room = mockRooms.find(r => r.roomId === roomId)
+ if (!room) {
+ throw new Error('방을 찾을 수 없습니다')
+ }
+
+ if (room.currentParticipants >= room.maxParticipants) {
+ throw new Error('방이 가득 찼습니다')
+ }
+
+ if (room.status === 'PLAYING') {
+ throw new Error('게임이 진행 중입니다')
+ }
+
+ // 이미 참가 중인지 확인
+ const alreadyJoined = room.participants.some(p => p.userId === currentUserId)
+ if (!alreadyJoined) {
+ room.participants.push({
+ userId: currentUserId,
+ nickname: currentUserNickname,
+ isHost: false,
+ })
+ room.currentParticipants++
+ }
+
+ return {
+ success: true,
+ data: {
+ room,
+ roomToken: `mock-token-${roomId}-${Date.now()}`,
+ },
+ }
+ },
+
+ // 방 나가기
+ leaveRoom: async (roomId) => {
+ await delay(200)
+
+ const room = mockRooms.find(r => r.roomId === roomId)
+ if (!room) {
+ throw new Error('방을 찾을 수 없습니다')
+ }
+
+ const participantIndex = room.participants.findIndex(p => p.userId === currentUserId)
+ if (participantIndex !== -1) {
+ const wasHost = room.participants[participantIndex].isHost
+ room.participants.splice(participantIndex, 1)
+ room.currentParticipants--
+
+ // 방장이 나가면 다음 사람이 방장
+ if (wasHost && room.participants.length > 0) {
+ room.participants[0].isHost = true
+ room.hostId = room.participants[0].userId
+ room.hostNickname = room.participants[0].nickname
+ }
+
+ // 모두 나가면 방 삭제
+ if (room.participants.length === 0) {
+ const roomIndex = mockRooms.findIndex(r => r.roomId === roomId)
+ if (roomIndex !== -1) {
+ mockRooms.splice(roomIndex, 1)
+ }
+ }
+ }
+
+ return { success: true }
+ },
+
+ // 게임 시작
+ startGame: async (roomId) => {
+ await delay(300)
+
+ const room = mockRooms.find(r => r.roomId === roomId)
+ if (!room) {
+ throw new Error('방을 찾을 수 없습니다')
+ }
+
+ if (room.hostId !== currentUserId) {
+ throw new Error('방장만 게임을 시작할 수 있습니다')
+ }
+
+ if (room.currentParticipants < 2) {
+ throw new Error('최소 2명이 필요합니다')
+ }
+
+ room.status = 'PLAYING'
+
+ const drawerOrder = room.participants.map(p => p.userId)
+ const randomWord = mockWords[Math.floor(Math.random() * mockWords.length)]
+
+ return {
+ success: true,
+ data: {
+ gameSessionId: `session-${Date.now()}`,
+ roomId,
+ status: 'PLAYING',
+ currentRound: 1,
+ totalRounds: room.gameSettings.maxRounds,
+ currentDrawerId: drawerOrder[0],
+ drawerOrder,
+ roundStartTime: Date.now(),
+ serverTime: Date.now(),
+ roundDuration: room.gameSettings.roundTimeLimit,
+ currentWord: drawerOrder[0] === currentUserId ? { word: randomWord } : null,
+ },
+ }
+ },
+
+ // 게임 종료
+ stopGame: async (roomId) => {
+ await delay(200)
+
+ const room = mockRooms.find(r => r.roomId === roomId)
+ if (room) {
+ room.status = 'FINISHED'
+ }
+
+ return {
+ success: true,
+ data: {
+ roomId,
+ status: 'FINISHED',
+ reason: 'STOPPED',
+ },
+ }
+ },
+
+ // 게임 재시작
+ restartGame: async (roomId) => {
+ await delay(300)
+
+ const room = mockRooms.find(r => r.roomId === roomId)
+ if (!room) {
+ throw new Error('방을 찾을 수 없습니다')
+ }
+
+ room.status = 'WAITING'
+
+ return {
+ success: true,
+ data: room,
+ }
+ },
+
+ // 랜덤 단어 가져오기 (테스트용)
+ getRandomWord: () => {
+ return mockWords[Math.floor(Math.random() * mockWords.length)]
+ },
+}
+
+export default mockGameService
diff --git a/src/domains/games/theme/gameTheme.js b/src/domains/games/theme/gameTheme.js
new file mode 100644
index 0000000..c2d2322
--- /dev/null
+++ b/src/domains/games/theme/gameTheme.js
@@ -0,0 +1,184 @@
+/**
+ * Game Design System
+ * 캐치마인드 게임 전용 디자인 토큰
+ */
+
+export const GAME_COLORS = {
+ // 브랜드
+ primary: '#8B5CF6',
+ primaryLight: '#A78BFA',
+ primaryBg: '#F3E8FF',
+
+ // 게임 상태
+ status: {
+ waiting: '#10B981',
+ waitingBg: '#D1FAE5',
+ playing: '#F59E0B',
+ playingBg: '#FEF3C7',
+ finished: '#6B7280',
+ finishedBg: '#F3F4F6',
+ },
+
+ // 정답/오답
+ answer: {
+ correct: '#F59E0B',
+ correctBg: '#FEF3C7',
+ correctText: '#92400E',
+ wrong: '#EF4444',
+ wrongBg: '#FEE2E2',
+ },
+
+ // 타이머
+ timer: {
+ normal: '#10B981',
+ warning: '#F59E0B',
+ danger: '#EF4444',
+ },
+
+ // 캔버스
+ canvas: {
+ border: '#E5E7EB',
+ borderActive: '#10B981',
+ borderDanger: '#EF4444',
+ background: '#FFFFFF',
+ },
+
+ // 비눗방울
+ bubble: {
+ normal: 'linear-gradient(135deg, rgba(224,242,254,0.95) 0%, rgba(186,230,253,0.9) 100%)',
+ correct: 'linear-gradient(135deg, rgba(254,243,199,0.95) 0%, rgba(253,230,138,0.9) 100%)',
+ },
+
+ // 순위
+ rank: {
+ gold: '#F59E0B',
+ silver: '#9CA3AF',
+ bronze: '#CD7F32',
+ },
+}
+
+export const GAME_TYPOGRAPHY = {
+ word: {
+ fontFamily: 'Pretendard, -apple-system, sans-serif',
+ fontWeight: 700,
+ fontSize: '28px',
+ },
+ timer: {
+ fontFamily: '"JetBrains Mono", "Fira Code", monospace',
+ fontWeight: 600,
+ fontSize: '24px',
+ },
+ score: {
+ fontFamily: '"JetBrains Mono", "Fira Code", monospace',
+ fontWeight: 700,
+ fontSize: '16px',
+ },
+ chat: {
+ fontFamily: 'Pretendard, -apple-system, sans-serif',
+ fontWeight: 400,
+ fontSize: '14px',
+ },
+}
+
+export const GAME_LAYOUT = {
+ splitView: {
+ canvas: '65%',
+ chat: '35%',
+ },
+ canvas: {
+ width: 640,
+ height: 320,
+ },
+ spacing: {
+ header: 36,
+ toolbar: 32,
+ gap: 6,
+ },
+}
+
+export const GAME_ANIMATIONS = {
+ bubble: {
+ duration: '3s',
+ easing: 'ease-out',
+ },
+ timerPulse: {
+ duration: '1s',
+ easing: 'ease-in-out',
+ },
+ transition: {
+ duration: '0.3s',
+ easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
+ },
+}
+
+// MUI sx props에서 사용할 수 있는 스타일 객체들
+export const gameStyles = {
+ // 게임 상태 뱃지
+ statusBadge: (status) => ({
+ px: 1.5,
+ py: 0.5,
+ borderRadius: '12px',
+ fontSize: '0.75rem',
+ fontWeight: 600,
+ bgcolor: GAME_COLORS.status[`${status}Bg`] || GAME_COLORS.status.waitingBg,
+ color: GAME_COLORS.status[status] || GAME_COLORS.status.waiting,
+ }),
+
+ // 타이머 스타일
+ timer: (seconds) => ({
+ fontFamily: GAME_TYPOGRAPHY.timer.fontFamily,
+ fontWeight: GAME_TYPOGRAPHY.timer.fontWeight,
+ fontSize: GAME_TYPOGRAPHY.timer.fontSize,
+ color: seconds <= 10
+ ? GAME_COLORS.timer.danger
+ : seconds <= 30
+ ? GAME_COLORS.timer.warning
+ : GAME_COLORS.timer.normal,
+ animation: seconds <= 10 ? 'pulse 1s ease-in-out infinite' : 'none',
+ }),
+
+ // 캔버스 컨테이너
+ canvasContainer: (isActive, isDanger) => ({
+ border: '3px solid',
+ borderColor: isDanger
+ ? GAME_COLORS.canvas.borderDanger
+ : isActive
+ ? GAME_COLORS.canvas.borderActive
+ : GAME_COLORS.canvas.border,
+ borderRadius: '12px',
+ overflow: 'hidden',
+ bgcolor: GAME_COLORS.canvas.background,
+ transition: `border-color ${GAME_ANIMATIONS.transition.duration} ${GAME_ANIMATIONS.transition.easing}`,
+ }),
+
+ // 정답 메시지
+ correctMessage: {
+ bgcolor: GAME_COLORS.answer.correctBg,
+ color: GAME_COLORS.answer.correctText,
+ fontWeight: 700,
+ borderRadius: '8px',
+ px: 1.5,
+ py: 0.75,
+ },
+
+ // 순위 아이콘
+ rankIcon: (rank) => {
+ const colors = {
+ 1: GAME_COLORS.rank.gold,
+ 2: GAME_COLORS.rank.silver,
+ 3: GAME_COLORS.rank.bronze,
+ }
+ return {
+ color: colors[rank] || 'text.secondary',
+ fontWeight: 700,
+ }
+ },
+}
+
+export default {
+ GAME_COLORS,
+ GAME_TYPOGRAPHY,
+ GAME_LAYOUT,
+ GAME_ANIMATIONS,
+ gameStyles,
+}
diff --git a/src/domains/grammar/components/ChatInput.jsx b/src/domains/grammar/components/ChatInput.jsx
index de97179..9a8110f 100644
--- a/src/domains/grammar/components/ChatInput.jsx
+++ b/src/domains/grammar/components/ChatInput.jsx
@@ -2,10 +2,13 @@ import {useState} from 'react'
import {Box, CircularProgress, FormControl, IconButton, MenuItem, Select, TextField, Tooltip,} from '@mui/material'
import {School as SchoolIcon, Send as SendIcon} from '@mui/icons-material'
import {useSettings} from '../../../contexts/SettingsContext'
+import {useThemeMode} from '../../../contexts/ThemeContext'
import {GRAMMAR_LEVEL_COLORS, GRAMMAR_LEVELS, TEXT_LIMITS,} from '../constants/grammarConstants'
export default function ChatInput({onSend, loading = false, level, onLevelChange}) {
const {t, isKorean} = useSettings()
+ const {mode} = useThemeMode()
+ const isDark = mode === 'dark'
const [message, setMessage] = useState('')
const handleSend = () => {
@@ -34,8 +37,8 @@ export default function ChatInput({onSend, loading = false, level, onLevelChange
sx={{
p: 2,
borderTop: '1px solid',
- borderColor: 'divider',
- backgroundColor: '#fff',
+ borderColor: isDark ? '#3f3f46' : 'divider',
+ backgroundColor: isDark ? '#27272a' : '#fff',
}}
>
))}
+
+ {/* Feedback */}
+ {feedback && (
+
+
+ 💡 {feedback}
+
+
+ )}
@@ -186,7 +208,7 @@ export default function ChatMessage({
width: 36,
height: 36,
borderRadius: '50%',
- backgroundColor: '#e5e7eb',
+ backgroundColor: isDark ? '#3f3f46' : '#e5e7eb',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -194,7 +216,7 @@ export default function ChatMessage({
flexShrink: 0,
}}
>
-
+
)
@@ -234,14 +256,14 @@ export default function ChatMessage({
sx={{
p: 2,
borderRadius: '20px 20px 20px 4px',
- backgroundColor: '#f3f4f6',
- border: '1px solid #e5e7eb',
+ backgroundColor: isDark ? '#3f3f46' : '#f3f4f6',
+ border: `1px solid ${isDark ? '#52525b' : '#e5e7eb'}`,
minHeight: isStreaming ? 48 : 'auto',
}}
>
{displayText}
{/* 스트리밍 커서 */}
diff --git a/src/domains/grammar/components/GrammarInput.jsx b/src/domains/grammar/components/GrammarInput.jsx
index b87871b..cebac31 100644
--- a/src/domains/grammar/components/GrammarInput.jsx
+++ b/src/domains/grammar/components/GrammarInput.jsx
@@ -11,6 +11,7 @@ import {
} from '@mui/material'
import {School as SchoolIcon, Spellcheck as SpellcheckIcon,} from '@mui/icons-material'
import {useSettings} from '../../../contexts/SettingsContext'
+import {useThemeMode} from '../../../contexts/ThemeContext'
import {
GRAMMAR_LEVEL_BG_COLORS,
GRAMMAR_LEVEL_COLORS,
@@ -20,6 +21,8 @@ import {
export default function GrammarInput({onCheck, loading = false}) {
const {t, isKorean} = useSettings()
+ const {mode} = useThemeMode()
+ const isDark = mode === 'dark'
const [sentence, setSentence] = useState('')
const [level, setLevel] = useState(GRAMMAR_LEVELS.BEGINNER)
const [error, setError] = useState('')
@@ -208,7 +211,7 @@ export default function GrammarInput({onCheck, loading = false}) {
fontSize: '1.1rem',
lineHeight: 1.7,
fontFamily: '"DM Sans", sans-serif',
- backgroundColor: '#fff',
+ backgroundColor: isDark ? '#27272a' : '#fff',
'&:hover fieldset': {
borderColor: GRAMMAR_LEVEL_COLORS[level],
},
@@ -219,7 +222,7 @@ export default function GrammarInput({onCheck, loading = false}) {
},
'& .MuiInputBase-input': {
'&::placeholder': {
- color: '#9ca3af',
+ color: isDark ? '#a1a1aa' : '#9ca3af',
opacity: 1,
},
},
@@ -236,7 +239,7 @@ export default function GrammarInput({onCheck, loading = false}) {
@@ -272,8 +275,8 @@ export default function GrammarInput({onCheck, loading = false}) {
boxShadow: `0 12px 28px -4px ${GRAMMAR_LEVEL_COLORS[level]}50`,
},
'&:disabled': {
- background: '#e5e7eb',
- color: '#9ca3af',
+ background: isDark ? '#3f3f46' : '#e5e7eb',
+ color: isDark ? '#a1a1aa' : '#9ca3af',
boxShadow: 'none',
},
}}
diff --git a/src/domains/grammar/components/GrammarResult.jsx b/src/domains/grammar/components/GrammarResult.jsx
index 9cfbddb..a82567f 100644
--- a/src/domains/grammar/components/GrammarResult.jsx
+++ b/src/domains/grammar/components/GrammarResult.jsx
@@ -8,6 +8,7 @@ import {
} from '@mui/icons-material'
import {useState} from 'react'
import {useSettings} from '../../../contexts/SettingsContext'
+import {useThemeMode} from '../../../contexts/ThemeContext'
import {
getScoreColor,
getScoreGrade,
@@ -17,6 +18,8 @@ import {
export default function GrammarResult({result}) {
const {t, isKorean} = useSettings()
+ const {mode} = useThemeMode()
+ const isDark = mode === 'dark'
const [expandedError, setExpandedError] = useState(null)
if (!result) return null
@@ -182,7 +185,7 @@ export default function GrammarResult({result}) {
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
- backgroundColor: '#fff',
+ backgroundColor: isDark ? '#27272a' : '#fff',
}}
>
@@ -335,7 +338,7 @@ export default function GrammarResult({result}) {
>
{error.original}
-
+
{error.corrected}
diff --git a/src/domains/grammar/components/SessionSidebar.jsx b/src/domains/grammar/components/SessionSidebar.jsx
index 5282ba6..34b258f 100644
--- a/src/domains/grammar/components/SessionSidebar.jsx
+++ b/src/domains/grammar/components/SessionSidebar.jsx
@@ -17,6 +17,7 @@ import {
} from '@mui/material'
import {Add as AddIcon, Chat as ChatIcon, Delete as DeleteIcon, History as HistoryIcon,} from '@mui/icons-material'
import {useSettings} from '../../../contexts/SettingsContext'
+import {useThemeMode} from '../../../contexts/ThemeContext'
import {GRAMMAR_LEVEL_BG_COLORS, GRAMMAR_LEVEL_COLORS} from '../constants/grammarConstants'
export default function SessionSidebar({
@@ -28,6 +29,8 @@ export default function SessionSidebar({
loading = false,
}) {
const {t, isKorean} = useSettings()
+ const {mode} = useThemeMode()
+ const isDark = mode === 'dark'
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [sessionToDelete, setSessionToDelete] = useState(null)
@@ -78,8 +81,8 @@ export default function SessionSidebar({
display: 'flex',
flexDirection: 'column',
borderRight: '1px solid',
- borderColor: 'divider',
- backgroundColor: '#fafafa',
+ borderColor: isDark ? '#3f3f46' : 'divider',
+ backgroundColor: isDark ? '#27272a' : '#fafafa',
}}
>
{/* Header */}
@@ -164,14 +167,15 @@ export default function SessionSidebar({
},
},
'&:hover': {
- backgroundColor: '#f3f4f6',
+ backgroundColor: isDark ? '#3f3f46' : '#f3f4f6',
},
}}
>
+
{session.lastMessage || `${session.messageCount} ${isKorean ? '메시지' : 'messages'}`}
-
+
{formatDate(session.updatedAt)}
diff --git a/src/domains/grammar/pages/WritingPage.jsx b/src/domains/grammar/pages/WritingPage.jsx
index 1f479ef..40c2cd1 100644
--- a/src/domains/grammar/pages/WritingPage.jsx
+++ b/src/domains/grammar/pages/WritingPage.jsx
@@ -2,6 +2,7 @@ import {useCallback, useEffect, useRef, useState} from 'react'
import {Alert, Box, Drawer, IconButton, Typography, useMediaQuery, useTheme,} from '@mui/material'
import {Edit as EditIcon, Menu as MenuIcon, SmartToy as AiIcon,} from '@mui/icons-material'
import {useSettings} from '../../../contexts/SettingsContext'
+import {useThemeMode} from '../../../contexts/ThemeContext'
import ChatMessage from '../components/ChatMessage'
import ChatInput from '../components/ChatInput'
import SessionSidebar from '../components/SessionSidebar'
@@ -12,6 +13,8 @@ import {GRAMMAR_LEVELS} from '../constants/grammarConstants'
export default function WritingPage() {
const {t, isKorean} = useSettings()
const theme = useTheme()
+ const {mode} = useThemeMode()
+ const isDark = mode === 'dark'
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const [sidebarOpen, setSidebarOpen] = useState(!isMobile)
@@ -124,17 +127,22 @@ export default function WritingPage() {
if (response.session) {
setLevel(response.session.level || GRAMMAR_LEVELS.BEGINNER)
}
- // Convert messages to our format
- const formattedMessages = (response.messages || []).map((msg) => ({
- id: msg.messageId,
- content: msg.content,
- correctedContent: msg.correctedContent,
- grammarScore: msg.grammarScore,
- errors: msg.errorsJson ? JSON.parse(msg.errorsJson) : [],
- aiResponse: msg.role === 'ASSISTANT' ? msg.content : null,
- isUser: msg.role === 'USER',
- createdAt: msg.createdAt,
- }))
+ // Convert messages to our format and sort by createdAt (oldest first)
+ const formattedMessages = (response.messages || [])
+ .map((msg) => ({
+ id: msg.messageId,
+ content: msg.content,
+ correctedContent: msg.correctedContent,
+ grammarScore: msg.grammarScore,
+ errors: msg.errors || (msg.errorsJson ? JSON.parse(msg.errorsJson) : []),
+ feedback: msg.feedback || null,
+ isCorrect: msg.isCorrect,
+ aiResponse: msg.role === 'ASSISTANT' ? msg.content : null,
+ conversationTip: msg.conversationTip || null,
+ isUser: msg.role === 'USER',
+ createdAt: msg.createdAt,
+ }))
+ .sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
setMessages(formattedMessages)
} catch (err) {
console.error('Failed to load session messages:', err)
@@ -221,10 +229,10 @@ export default function WritingPage() {
height: 'calc(100vh - 120px)',
display: 'flex',
overflow: 'hidden',
- backgroundColor: '#fff',
+ backgroundColor: isDark ? '#18181b' : '#fff',
borderRadius: {xs: 0, md: '20px'},
border: {xs: 'none', md: '1px solid'},
- borderColor: 'divider',
+ borderColor: isDark ? '#3f3f46' : 'divider',
mx: {xs: 0, md: 3},
my: {xs: 0, md: 2},
}}
@@ -260,11 +268,11 @@ export default function WritingPage() {
sx={{
p: 2,
borderBottom: '1px solid',
- borderColor: 'divider',
+ borderColor: isDark ? '#3f3f46' : 'divider',
display: 'flex',
alignItems: 'center',
gap: 2,
- backgroundColor: '#fafafa',
+ backgroundColor: isDark ? '#27272a' : '#fafafa',
}}
>
setSidebarOpen(!sidebarOpen)} sx={{mr: 0.5}}>
@@ -302,7 +310,7 @@ export default function WritingPage() {
flex: 1,
overflow: 'auto',
py: 2,
- backgroundColor: '#fff',
+ backgroundColor: isDark ? '#18181b' : '#fff',
}}
>
{error && (
@@ -355,7 +363,7 @@ export default function WritingPage() {
mt: 4,
p: 2,
borderRadius: '12px',
- backgroundColor: '#f3f4f6',
+ backgroundColor: isDark ? '#3f3f46' : '#f3f4f6',
maxWidth: 400,
}}
>
diff --git a/src/domains/grammar/services/grammarService.js b/src/domains/grammar/services/grammarService.js
index 30de7fa..cfc1f00 100644
--- a/src/domains/grammar/services/grammarService.js
+++ b/src/domains/grammar/services/grammarService.js
@@ -1,7 +1,7 @@
import grammarApi from '../../../api/grammarApi'
-// Mock 데이터 사용 여부 (true: 목 데이터 사용, false: 실제 API 호출)
-const USE_MOCK = true
+// Mock 데이터 사용 여부 (환경변수로 제어: VITE_USE_MOCK=true)
+const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true'
// ============================================
// Mock 데이터
@@ -116,7 +116,10 @@ const withMock = (apiCall, mockData) => {
setTimeout(() => resolve(mockData), 800)
})
}
- return apiCall().catch(() => mockData)
+ // 실제 API 호출 시 응답의 data 필드 추출 (백엔드 응답: { isSuccess, message, data })
+ return apiCall()
+ .then(response => response.data || response)
+ .catch(() => mockData)
}
/**
diff --git a/src/domains/grammar/services/grammarStreamService.js b/src/domains/grammar/services/grammarStreamService.js
index 0d311da..ce0c6a1 100644
--- a/src/domains/grammar/services/grammarStreamService.js
+++ b/src/domains/grammar/services/grammarStreamService.js
@@ -3,12 +3,11 @@
* 실시간 토큰 단위 AI 응답을 위한 WebSocket 서비스
*/
-// WebSocket URL - 환경변수에서 가져오거나 기본값 사용
-const WS_URL = import.meta.env.VITE_GRAMMAR_WS_URL ||
- 'wss://placeholder.execute-api.ap-northeast-2.amazonaws.com/dev'
+// WebSocket URL - 환경변수에서 가져옴
+const WS_URL = import.meta.env.VITE_GRAMMAR_WS_URL
-// Mock 모드 (WebSocket 서버가 없을 때 테스트용)
-const USE_MOCK = true
+// Mock 모드 (환경변수로 제어: VITE_USE_MOCK=true)
+const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true'
const MOCK_DELAY = 50 // 토큰 간 딜레이 (ms)
/**
diff --git a/src/domains/vocab/components/TestQuestion.jsx b/src/domains/vocab/components/TestQuestion.jsx
index e30095b..c3a9f0b 100644
--- a/src/domains/vocab/components/TestQuestion.jsx
+++ b/src/domains/vocab/components/TestQuestion.jsx
@@ -1,4 +1,5 @@
import {Box, Paper, RadioGroup, Typography} from '@mui/material'
+import {useThemeMode} from '../../../contexts/ThemeContext'
export default function TestQuestion({
question,
@@ -7,6 +8,9 @@ export default function TestQuestion({
showResult = false,
disabled = false,
}) {
+ const {mode} = useThemeMode()
+ const isDark = mode === 'dark'
+
if (!question) return null
const getOptionStyle = (option, index) => {
@@ -16,7 +20,7 @@ export default function TestQuestion({
if (!showResult) {
return {
border: isSelected ? '2px solid #059669' : '2px solid #e7e5e4',
- backgroundColor: isSelected ? '#ecfdf5' : '#ffffff',
+ backgroundColor: isSelected ? '#ecfdf5' : (isDark ? '#27272a' : '#ffffff'),
transform: isSelected ? 'scale(1.02)' : 'scale(1)',
}
}
@@ -199,7 +203,7 @@ export default function TestQuestion({
? '#059669'
: isWrong
? '#ef4444'
- : '#1c1917',
+ : (isDark ? '#fafafa' : '#1c1917'),
flex: 1,
}}
>
diff --git a/src/domains/vocab/components/WordDetailModal.jsx b/src/domains/vocab/components/WordDetailModal.jsx
index 4941aaa..dc4f505 100644
--- a/src/domains/vocab/components/WordDetailModal.jsx
+++ b/src/domains/vocab/components/WordDetailModal.jsx
@@ -31,6 +31,7 @@ import {
WORD_STATUS_LABELS,
} from '../constants/vocabConstants'
import {useTranslation} from '../../../contexts/SettingsContext'
+import {useThemeMode} from '../../../contexts/ThemeContext'
export default function WordDetailModal({
open,
@@ -44,6 +45,8 @@ export default function WordDetailModal({
isPlayingTTS,
}) {
const {t} = useTranslation()
+ const {mode} = useThemeMode()
+ const isDark = mode === 'dark'
const [selectedVoice, setSelectedVoice] = useState(VOICE_TYPES.FEMALE)
if (!word) return null
@@ -189,7 +192,7 @@ export default function WordDetailModal({
{wordData.korean}
@@ -223,7 +226,7 @@ export default function WordDetailModal({
"{wordData.example}"
diff --git a/src/domains/vocab/pages/DailyLearning.jsx b/src/domains/vocab/pages/DailyLearning.jsx
index dd2b4d9..0a9a65b 100644
--- a/src/domains/vocab/pages/DailyLearning.jsx
+++ b/src/domains/vocab/pages/DailyLearning.jsx
@@ -33,8 +33,116 @@ import FlashCard from '../components/FlashCard'
import {dailyService, userWordService, voiceService} from '../services/vocabService'
import {LEVEL_LABELS, LEVELS} from '../constants/vocabConstants'
import {useTranslation} from '../../../contexts/SettingsContext'
+import {useAuth} from '../../../contexts/AuthContext'
+import {useThemeMode} from '../../../contexts/ThemeContext'
-const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1'
+// 카드 셔플 애니메이션 컴포넌트
+function ShuffleAnimation({count, isKorean}) {
+ return (
+
+ {/* 카드 셔플 애니메이션 */}
+
+ {[...Array(Math.min(count, 5))].map((_, i) => (
+
+
+ 📚
+
+
+ ))}
+
+
+ {/* 텍스트 */}
+
+ {isKorean ? '카드를 섞는 중...' : 'Shuffling cards...'}
+
+
+ {isKorean
+ ? `${count}개의 단어를 다시 학습합니다`
+ : `Reviewing ${count} words again`
+ }
+
+
+ )
+}
// Level Selection Screen
function LevelSelect({onSelect, loading, t, isKorean}) {
@@ -142,6 +250,10 @@ function LevelSelect({onSelect, loading, t, isKorean}) {
export default function DailyLearning() {
const navigate = useNavigate()
const {t, isKorean} = useTranslation()
+ const {user} = useAuth()
+ const {mode} = useThemeMode()
+ const isDark = mode === 'dark'
+ const userId = user?.userId || user?.username
const [phase, setPhase] = useState('loading')
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
@@ -154,7 +266,13 @@ export default function DailyLearning() {
const [results, setResults] = useState({correct: 0, incorrect: 0})
const [swipeDirection, setSwipeDirection] = useState(null)
const [isEntering, setIsEntering] = useState(false)
+ const [unknownWords, setUnknownWords] = useState([]) // "몰라요" 선택한 단어들
+ const [totalWordCount, setTotalWordCount] = useState(0) // 전체 단어 수 (진행률 계산용)
+ const [isShuffling, setIsShuffling] = useState(false) // 셔플 애니메이션 상태
+ const [shuffleCount, setShuffleCount] = useState(0) // 셔플할 단어 수
+ // 마운트 시 먼저 level 없이 시도 (기존 학습이 있으면 성공)
+ // 실패하면 level 선택 화면으로 이동
useEffect(() => {
fetchDailyWords()
}, [])
@@ -164,7 +282,7 @@ export default function DailyLearning() {
setLoading(true)
setError(null)
- const response = await dailyService.getWords(TEMP_USER_ID, level)
+ const response = await dailyService.getWords(level)
const dailyData = response?.data || response
const allWords = [
@@ -179,6 +297,7 @@ export default function DailyLearning() {
}
setWords(allWords)
+ setTotalWordCount(allWords.length) // 전체 단어 수 저장
const learnedCount = dailyData?.learnedCount || 0
if (learnedCount > 0 && learnedCount < allWords.length) {
@@ -212,7 +331,10 @@ export default function DailyLearning() {
}
const currentWord = words[currentIndex]
- const progress = words.length > 0 ? (learnedIds.size / words.length) * 100 : 0
+ // 진행률: 학습 완료된 단어 / 전체 단어 수
+ const progress = totalWordCount > 0 ? (learnedIds.size / totalWordCount) * 100 : 0
+ // 현재 라운드에서 남은 단어 (몰라요 단어 재학습 시 표시용)
+ const remainingInRound = words.length - currentIndex
const playTTS = useCallback(async (word) => {
if (!word || isPlayingTTS) return
@@ -248,17 +370,19 @@ export default function DailyLearning() {
setSwipeDirection(isCorrect ? 'right' : 'left')
- try {
- await userWordService.update(TEMP_USER_ID, currentWord.wordId, isCorrect)
-
- setResults(prev => ({
- ...prev,
- [isCorrect ? 'correct' : 'incorrect']: prev[isCorrect ? 'correct' : 'incorrect'] + 1
- }))
-
- setLearnedIds(prev => new Set([...prev, currentWord.wordId]))
- } catch (err) {
- console.error('Answer update error:', err)
+ if (isCorrect) {
+ // "알아요" 선택 - API 호출하고 학습 완료 처리
+ try {
+ await dailyService.markLearned(currentWord.wordId)
+ setLearnedIds(prev => new Set([...prev, currentWord.wordId]))
+ setResults(prev => ({...prev, correct: prev.correct + 1}))
+ } catch (err) {
+ console.error('Answer update error:', err)
+ }
+ } else {
+ // "몰라요" 선택 - API 호출 X, 나중에 다시 학습하도록 저장
+ setUnknownWords(prev => [...prev, currentWord])
+ setResults(prev => ({...prev, incorrect: prev.incorrect + 1}))
}
setTimeout(() => {
@@ -269,20 +393,68 @@ export default function DailyLearning() {
}, 250)
}
+ // 배열 섞기 함수
+ const shuffleArray = (array) => {
+ const shuffled = [...array]
+ for (let i = shuffled.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
+ }
+ return shuffled
+ }
+
const moveToNext = () => {
setIsFlipped(false)
if (currentIndex < words.length - 1) {
+ // 다음 단어로 이동
setCurrentIndex(prev => prev + 1)
+ } else if (unknownWords.length > 0) {
+ // 현재 리스트 끝 + "몰라요" 단어 있음 → 셔플 애니메이션 후 다시 학습
+ setShuffleCount(unknownWords.length)
+ setIsShuffling(true)
+ setTimeout(() => {
+ const shuffled = shuffleArray(unknownWords)
+ setWords(shuffled)
+ setUnknownWords([])
+ setCurrentIndex(0)
+ setIsShuffling(false)
+ }, 1500) // 1.5초 애니메이션
} else {
+ // 모든 단어 "알아요" 완료 → 학습 완료
setPhase('complete')
}
}
+ // 건너뛰기 - "몰라요"와 동일하게 처리
+ const handleSkip = () => {
+ if (!currentWord || swipeDirection || isShuffling) return
+
+ setIsFlipped(false)
+
+ if (currentIndex < words.length - 1) {
+ // 다음 단어가 있으면 현재 단어를 unknownWords에 추가하고 다음으로
+ setUnknownWords(prev => [...prev, currentWord])
+ setCurrentIndex(prev => prev + 1)
+ } else {
+ // 마지막 단어 건너뛰기 → 모든 unknown 단어 + 현재 단어로 셔플
+ const allUnknown = [...unknownWords, currentWord]
+ setShuffleCount(allUnknown.length)
+ setIsShuffling(true)
+ setTimeout(() => {
+ const shuffled = shuffleArray(allUnknown)
+ setWords(shuffled)
+ setUnknownWords([])
+ setCurrentIndex(0)
+ setIsShuffling(false)
+ }, 1500)
+ }
+ }
+
const handleToggleBookmark = async () => {
if (!currentWord) return
try {
const newBookmarked = !currentWord.bookmarked
- await userWordService.updateTag(TEMP_USER_ID, currentWord.wordId, {
+ await userWordService.updateTag(userId, currentWord.wordId, {
bookmarked: newBookmarked,
})
setWords(prev =>
@@ -296,11 +468,14 @@ export default function DailyLearning() {
}
const handleRestart = () => {
+ // 처음부터 다시 시작 - 단어 목록 다시 가져오기
setCurrentIndex(0)
setLearnedIds(new Set())
+ setUnknownWords([])
setIsFlipped(false)
- setPhase('learning')
setResults({correct: 0, incorrect: 0})
+ setPhase('loading')
+ fetchDailyWords() // 단어 다시 로드
}
// Loading Screen
@@ -375,7 +550,7 @@ export default function DailyLearning() {
{t('dailyLearning.completedSession')}
-
+
@@ -402,7 +577,7 @@ export default function DailyLearning() {
px: 3,
py: 1.5,
borderRadius: '12px',
- backgroundColor: accuracy >= 80 ? '#ecfdf5' : accuracy >= 50 ? '#fff7ed' : '#fef2f2',
+ backgroundColor: isDark ? (accuracy >= 80 ? '#064e3b' : accuracy >= 50 ? '#78350f' : '#7f1d1d') : (accuracy >= 80 ? '#ecfdf5' : accuracy >= 50 ? '#fff7ed' : '#fef2f2'),
}}
>
- {/* Header */}
-
- navigate('/vocab')}>
-
-
-
-
- {currentIndex + 1} / {words.length}
-
-
- setAutoPlayTTS(e.target.checked)}
- size="small"
- sx={{
- '& .MuiSwitch-switchBase.Mui-checked': {
- color: '#059669',
- },
- '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': {
- backgroundColor: '#059669',
- },
- }}
- />
- }
- label={}
+ <>
+ {/* 셔플 애니메이션 오버레이 */}
+ {isShuffling && (
+
-
+ )}
- {/* Progress Bar */}
-
-
-
- {t('dailyLearning.progress')}
-
-
- {Math.round(progress)}%
-
-
-
+ {/* Header */}
+
-
+ >
+ navigate('/vocab')}>
+
+
+
+
+ {currentIndex + 1} / {words.length}
+
+
+ setAutoPlayTTS(e.target.checked)}
+ size="small"
+ sx={{
+ '& .MuiSwitch-switchBase.Mui-checked': {
+ color: '#059669',
+ },
+ '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': {
+ backgroundColor: '#059669',
+ },
+ }}
+ />
+ }
+ label={}
+ />
+
- {/* FlashCard */}
-
- playTTS(currentWord)}
- isPlayingTTS={isPlayingTTS}
- />
-
+ {/* Progress Bar */}
+
+
+
+ {t('dailyLearning.progress')}
+
+
+ {Math.round(progress)}%
+
+
+
+
- {/* Answer Buttons */}
-
-
-
-
-
- {/* Navigation */}
-
-
+ playTTS(currentWord)}
+ isPlayingTTS={isPlayingTTS}
+ />
+
-
-
+
-
-
-
-
-
+ {t('dailyLearning.dontKnow')}
+
+
+
+
+ {/* Navigation */}
+
+
+
+
+
+ {currentWord?.bookmarked ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ >
)
}
diff --git a/src/domains/vocab/pages/StatsPage.jsx b/src/domains/vocab/pages/StatsPage.jsx
index 0c6ed67..9edff1e 100644
--- a/src/domains/vocab/pages/StatsPage.jsx
+++ b/src/domains/vocab/pages/StatsPage.jsx
@@ -13,75 +13,182 @@ import {
ListItem,
ListItemText,
Paper,
- Tab,
- Tabs,
- Tooltip,
Typography,
} from '@mui/material'
import {
ArrowBack as BackIcon,
- CalendarMonth as CalendarIcon,
+ CheckCircle as CheckIcon,
+ LocalFireDepartment as FireIcon,
+ MenuBook as BookIcon,
+ Quiz as QuizIcon,
+ School as SchoolIcon,
+ Timeline as TimelineIcon,
TrendingUp as TrendingUpIcon,
+ VolumeUp as VolumeIcon,
Warning as WarningIcon,
} from '@mui/icons-material'
import {statsService, voiceService} from '../services/vocabService'
-import {DIFFICULTY_LABELS, LEVEL_COLORS, LEVEL_LABELS, VOICE_TYPES,} from '../constants/vocabConstants'
+import {DIFFICULTY_LABELS, LEVEL_LABELS, VOICE_TYPES} from '../constants/vocabConstants'
import {useTranslation} from '../../../contexts/SettingsContext'
+import {useAuth} from '../../../contexts/AuthContext'
+import {useThemeMode} from '../../../contexts/ThemeContext'
import {BadgeSection} from '../../badge'
-const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1'
-
-// 학습 캘린더 히트맵 컴포넌트
+// 학습 캘린더 히트맵 컴포넌트 (GitHub 스타일)
function LearningCalendar({data}) {
+ const [hoveredDay, setHoveredDay] = useState(null)
+ const [tooltipPos, setTooltipPos] = useState({x: 0, y: 0})
+
const today = new Date()
+ const todayStr = today.toISOString().split('T')[0]
+
+ // 12주(84일) 전부터 오늘까지
const startDate = new Date(today)
- startDate.setDate(startDate.getDate() - 83) // 12주 전
+ startDate.setDate(startDate.getDate() - 83)
+
+ // 시작일을 해당 주의 일요일로 조정
+ const startDayOfWeek = startDate.getDay()
+ startDate.setDate(startDate.getDate() - startDayOfWeek)
const weeks = []
+ const monthLabels = []
let currentDate = new Date(startDate)
+ let lastMonth = -1
- // 12주 데이터 생성
- for (let w = 0; w < 12; w++) {
+ // 주별로 데이터 생성
+ while (currentDate <= today || weeks.length < 13) {
const week = []
for (let d = 0; d < 7; d++) {
const dateStr = currentDate.toISOString().split('T')[0]
- const dayData = data?.find(d => d.date === dateStr)
+ // 백엔드는 "period" 필드를 사용, 폴백으로 "date"도 지원
+ const dayData = data?.find(item => (item.period || item.date) === dateStr)
+ const isFuture = currentDate > today
+
+ // 월 라벨 추가 (각 주의 첫 날이 새 달이면)
+ if (d === 0 && currentDate.getMonth() !== lastMonth && !isFuture) {
+ const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월']
+ monthLabels.push({
+ month: monthNames[currentDate.getMonth()],
+ weekIndex: weeks.length,
+ })
+ lastMonth = currentDate.getMonth()
+ }
+
+ // 백엔드는 newWordsLearned 사용, 폴백으로 learnedCount도 지원
+ const count = dayData?.newWordsLearned || dayData?.learnedCount || 0
+
week.push({
date: dateStr,
- count: dayData?.learnedCount || 0,
- isToday: dateStr === today.toISOString().split('T')[0],
+ count,
+ isToday: dateStr === todayStr,
+ isFuture,
+ dayOfWeek: d,
})
currentDate.setDate(currentDate.getDate() + 1)
}
weeks.push(week)
+ if (weeks.length >= 14) break
}
- const getColor = (count) => {
+ // GitHub 스타일 색상 (초록 계열)
+ const getColor = (count, isFuture) => {
+ if (isFuture) return 'transparent'
if (count === 0) return '#ebedf0'
- if (count < 20) return '#9be9a8'
- if (count < 40) return '#40c463'
- if (count < 55) return '#30a14e'
+ if (count < 5) return '#9be9a8'
+ if (count < 15) return '#40c463'
+ if (count < 30) return '#30a14e'
return '#216e39'
}
+ const getLevel = (count) => {
+ if (count === 0) return 0
+ if (count < 5) return 1
+ if (count < 15) return 2
+ if (count < 30) return 3
+ return 4
+ }
+
const dayLabels = ['일', '월', '화', '수', '목', '금', '토']
+ const formatDate = (dateStr) => {
+ const date = new Date(dateStr)
+ const year = date.getFullYear()
+ const month = date.getMonth() + 1
+ const day = date.getDate()
+ const weekday = dayLabels[date.getDay()]
+ return `${year}년 ${month}월 ${day}일 (${weekday})`
+ }
+
+ const handleMouseEnter = (e, day) => {
+ if (day.isFuture) return
+ const rect = e.target.getBoundingClientRect()
+ setTooltipPos({
+ x: rect.left + rect.width / 2,
+ y: rect.top - 8,
+ })
+ setHoveredDay(day)
+ }
+
+ // 총 학습량 계산 (백엔드는 newWordsLearned 사용)
+ const totalLearned = data?.reduce((sum, item) => sum + (item.newWordsLearned || item.learnedCount || 0), 0) || 0
+ const activeDays = data?.filter(item => (item.newWordsLearned || item.learnedCount || 0) > 0).length || 0
+
return (
-
-
+
+ {/* 요약 정보 */}
+
+
+ 최근 12주간 {totalLearned}개 단어 학습
+
+
+
+
+ {/* 월 라벨 */}
+
+ {monthLabels.map((label, idx) => (
+
+ {label.month}
+
+ ))}
+
+
+ {/* 캘린더 그리드 */}
+
{/* 요일 라벨 */}
-
+
{dayLabels.map((label, idx) => (
{idx % 2 === 1 ? label : ''}
@@ -89,85 +196,180 @@ function LearningCalendar({data}) {
))}
- {/* 히트맵 그리드 */}
+ {/* 주별 셀 */}
{weeks.map((week, wIdx) => (
-
+
{week.map((day, dIdx) => (
-
-
-
+ onMouseEnter={(e) => handleMouseEnter(e, day)}
+ onMouseLeave={() => setHoveredDay(null)}
+ sx={{
+ width: 12,
+ height: 12,
+ backgroundColor: getColor(day.count, day.isFuture),
+ borderRadius: '2px',
+ border: day.isToday
+ ? '2px solid #10b981'
+ : day.isFuture
+ ? 'none'
+ : '1px solid rgba(27, 31, 35, 0.06)',
+ cursor: day.isFuture ? 'default' : 'pointer',
+ transition: 'all 0.15s ease',
+ boxSizing: 'border-box',
+ '&:hover': day.isFuture ? {} : {
+ transform: 'scale(1.2)',
+ borderColor: 'rgba(27, 31, 35, 0.15)',
+ boxShadow: '0 1px 3px rgba(0,0,0,0.12)',
+ },
+ }}
+ />
))}
))}
{/* 범례 */}
-
- 적음
- {[0, 10, 30, 45, 55].map((count, idx) => (
-
- ))}
- 많음
+
+
+ 오늘 학습하셨나요?
+
+
+ Less
+ {[0, 1, 2, 3, 4].map((level) => (
+
+ ))}
+ More
+
+
+ {/* 호버 툴팁 */}
+ {hoveredDay && (
+
+
+ {hoveredDay.count > 0
+ ? `${hoveredDay.count}개 단어 학습`
+ : '학습 기록 없음'}
+
+
+ {formatDate(hoveredDay.date)}
+
+
+ )}
)
}
-// 취약 단어 목록 컴포넌트
-function WeakWordsList({words, onPlayTTS, playingWordId}) {
+// 복습 필요 단어 목록 컴포넌트
+function WeakWordsList({words, onPlayTTS, playingWordId, isDark}) {
if (!words || words.length === 0) {
return (
-
- 취약 단어가 없습니다
-
+
+
+
+ 모든 단어를 잘 학습했어요!
+
+
+ 복습이 필요한 단어가 없습니다
+
+
)
}
return (
- {words.map((item, index) => (
+ {words.slice(0, 5).map((item, index) => (
+
+
+ {index + 1}
+
+
{item.english}
-
+ {item.incorrectCount > 0 && (
+
+ {item.incorrectCount}회 오답
+
+ )}
}
secondary={item.korean}
@@ -176,11 +378,12 @@ function WeakWordsList({words, onPlayTTS, playingWordId}) {
size="small"
onClick={() => onPlayTTS?.(item)}
disabled={playingWordId === item.wordId}
+ sx={{
+ backgroundColor: isDark ? '#3f3f46' : '#f3f4f6',
+ '&:hover': {backgroundColor: isDark ? '#52525b' : '#e5e7eb'},
+ }}
>
-
+
))}
@@ -189,32 +392,60 @@ function WeakWordsList({words, onPlayTTS, playingWordId}) {
}
// 레벨별 진행률 차트
-function LevelProgressChart({data}) {
+function LevelProgressChart({data, isDark}) {
if (!data) return null
+ const levelConfig = {
+ BEGINNER: {icon: '🌱', color: '#10b981', bgColor: '#ecfdf5'},
+ INTERMEDIATE: {icon: '🌿', color: '#f97316', bgColor: '#fff7ed'},
+ ADVANCED: {icon: '🌳', color: '#ef4444', bgColor: '#fef2f2'},
+ }
+
return (
-
+
{Object.entries(LEVEL_LABELS).map(([level, label]) => {
const levelData = data[level] || {total: 0, learned: 0}
- const progress = levelData.total > 0
- ? (levelData.learned / levelData.total) * 100
- : 0
+ const progress = levelData.total > 0 ? (levelData.learned / levelData.total) * 100 : 0
+ const config = levelConfig[level]
return (
-
-
-
- {label}
-
-
- {levelData.learned}/{levelData.total}
+
+
+
+
+ {config.icon}
+
+
+ {label}
+
+
+
+ {levelData.learned} / {levelData.total}
)
@@ -229,83 +460,131 @@ function DifficultyChart({data}) {
const total = Object.values(data).reduce((sum, val) => sum + val, 0)
- const colors = {
- EASY: '#4caf50',
- NORMAL: '#2196f3',
- HARD: '#ff9800',
+ const config = {
+ EASY: {label: '쉬움', color: '#10b981', bgColor: '#ecfdf5', icon: '😊'},
+ NORMAL: {label: '보통', color: '#3b82f6', bgColor: '#eff6ff', icon: '🤔'},
+ HARD: {label: '어려움', color: '#ef4444', bgColor: '#fef2f2', icon: '😰'},
}
return (
-
- {/* 막대 그래프 */}
-
- {Object.entries(DIFFICULTY_LABELS).map(([key, label]) => {
- const count = data[key] || 0
- const height = total > 0 ? (count / total) * 100 : 0
-
- return (
-
-
- {count}
-
-
-
- {label}
-
-
- )
- })}
-
+
+ {Object.entries(DIFFICULTY_LABELS).map(([key]) => {
+ const count = data[key] || 0
+ const percentage = total > 0 ? ((count / total) * 100).toFixed(0) : 0
+ const cfg = config[key]
+
+ return (
+
+ {cfg.icon}
+
+ {count}
+
+
+ {cfg.label} ({percentage}%)
+
+
+ )
+ })}
)
}
-// 통계 요약 카드
-function StatCard({title, value, subtitle, icon: Icon, color}) {
+// 히어로 통계 카드
+function HeroStatCard({icon: Icon, label, value, subValue, color, bgGradient}) {
return (
-
+
-
- {title}
+
+ {label}
-
+
{value}
- {subtitle && (
-
- {subtitle}
+ {subValue && (
+
+ {subValue}
)}
- {Icon && (
-
-
-
- )}
+
+
+
)
}
+// 미니 통계 카드
+function MiniStatCard({icon: Icon, label, value, color, bgColor}) {
+ return (
+
+
+
+
+
+
+ {value}
+
+
+ {label}
+
+
+
+ )
+}
+
export default function StatsPage() {
const navigate = useNavigate()
- const {t} = useTranslation()
- const [tab, setTab] = useState(0) // 0: 일간, 1: 주간, 2: 월간
+ const {t, isKorean} = useTranslation()
+ const {user} = useAuth()
+ const {mode} = useThemeMode()
+ const isDark = mode === 'dark'
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
@@ -323,26 +602,29 @@ export default function StatsPage() {
fetchAllStats()
}, [])
- useEffect(() => {
- fetchPeriodStats()
- }, [tab])
-
const fetchAllStats = async () => {
try {
setLoading(true)
setError(null)
const [overviewRes, dailyRes, weakRes] = await Promise.all([
- statsService.getOverall(TEMP_USER_ID),
- statsService.getDaily(TEMP_USER_ID, {limit: 84}),
- statsService.getWeakness(TEMP_USER_ID),
+ statsService.getOverall(),
+ statsService.getDaily(null, {limit: 84}),
+ statsService.getWeakness(),
])
- setOverviewStats(overviewRes?.data)
- setCalendarData(dailyRes?.data?.dailyStats || [])
- setWeakWords(weakRes?.data?.weakWords || [])
- setLevelProgress(overviewRes?.data?.levelProgress)
- setDifficultyDist(overviewRes?.data?.difficultyDistribution)
+ // API 응답 데이터 접근 (data 필드 또는 직접 접근)
+ const overview = overviewRes?.data || overviewRes
+ const daily = dailyRes?.data || dailyRes
+ const weak = weakRes?.data || weakRes
+
+ setOverviewStats(overview)
+ // 다양한 응답 형식 지원: history, dailyStats, 또는 배열 자체
+ const calendarHistory = daily?.history || daily?.dailyStats || (Array.isArray(daily) ? daily : [])
+ setCalendarData(calendarHistory)
+ setWeakWords(weak?.frequentMistakes || weak?.weakWords || weak?.weakestWords || [])
+ setLevelProgress(overview?.levelProgress)
+ setDifficultyDist(overview?.difficultyDistribution)
} catch (err) {
console.error('Fetch stats error:', err)
setError('통계를 불러오는데 실패했습니다.')
@@ -351,19 +633,6 @@ export default function StatsPage() {
}
}
- const fetchPeriodStats = async () => {
- // 기간별 통계는 getDaily로 처리
- try {
- const limits = [7, 30, 90] // 일간, 주간, 월간
- const response = await statsService.getDaily(TEMP_USER_ID, {
- limit: limits[tab],
- })
- // 기간별 통계 처리
- } catch (err) {
- console.error('Period stats error:', err)
- }
- }
-
const handlePlayTTS = async (word) => {
if (playingWordId) return
@@ -392,122 +661,195 @@ export default function StatsPage() {
return (
-
+
)
}
+ // 데이터 추출
+ const totalLearned = overviewStats?.totalLearned || overviewStats?.newWordsLearned || 0
+ const successRate = overviewStats?.successRate || overviewStats?.averageAccuracy || 0
+ const currentStreak = overviewStats?.currentStreak || overviewStats?.streakDays || 0
+ const longestStreak = overviewStats?.longestStreak || currentStreak
+ const testsCompleted = overviewStats?.testsCompleted || 0
+ const correctAnswers = overviewStats?.correctAnswers || 0
+ const incorrectAnswers = overviewStats?.incorrectAnswers || 0
+ const wordsReviewed = overviewStats?.wordsReviewed || 0
+
return (
-
+
{/* 헤더 */}
- navigate('/vocab')}>
+ navigate('/vocab')}
+ sx={{
+ backgroundColor: isDark ? '#3f3f46' : '#f3f4f6',
+ '&:hover': {backgroundColor: isDark ? '#52525b' : '#e5e7eb'},
+ }}
+ >
-
- {t('stats.title')}
-
+
+
+ 학습 통계
+
+
+ {user?.username || '사용자'}님의 학습 현황
+
+
{error && (
- setError(null)}>
+ setError(null)}>
{error}
)}
- {/* 기간 탭 */}
- setTab(v)}
- sx={{mb: 3}}
- variant="fullWidth"
- >
-
-
-
-
-
- {/* 요약 카드 */}
+ {/* 히어로 섹션 - 핵심 통계 */}
-
-
+
-
-
+
-
-
+
-
-
+
- {/* 학습 캘린더 */}
-
-
- {t('stats.learningHistory')}
+ {/* 추가 통계 미니 카드 */}
+
+
+ 상세 통계
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
- {/* 레벨별 진행률 */}
-
-
- {t('stats.levelProgress')}
-
-
+ {/* 학습 캘린더 */}
+
+
+
+
+ 학습 기록
+
+
+
{/* 난이도 분포 */}
-
-
- {t('stats.difficultyDist')}
-
-
-
+ {difficultyDist && (
+
+
+ 체감 난이도 분포
+
+
+
+ )}
- {/* 취약 단어 */}
-
-
-
- {t('stats.weakWordsTop10')}
+ {/* 레벨별 진행률 */}
+ {levelProgress && (
+
+
+ 레벨별 학습 진행률
- navigate('/vocab/daily?mode=weak')}
- />
+
+
+ )}
+
+ {/* 복습이 필요한 단어 */}
+
+
+
+
+
+ 복습이 필요한 단어
+
+
+ {weakWords.length > 0 && (
+ navigate('/vocab/daily?mode=weak')}
+ />
+ )}
diff --git a/src/domains/vocab/pages/TestPage.jsx b/src/domains/vocab/pages/TestPage.jsx
index 329a9d6..2a5124a 100644
--- a/src/domains/vocab/pages/TestPage.jsx
+++ b/src/domains/vocab/pages/TestPage.jsx
@@ -26,12 +26,13 @@ import {
import TestQuestion from '../components/TestQuestion'
import {testService} from '../services/vocabService'
import {useTranslation} from '../../../contexts/SettingsContext'
+import {useAuth} from '../../../contexts/AuthContext'
+import {useThemeMode} from '../../../contexts/ThemeContext'
-const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1'
const QUESTION_TIME_LIMIT = 5
// Test Setup Screen
-function TestSetup({onStart, recentResults, loading, t}) {
+function TestSetup({onStart, recentResults, loading, t, isDark}) {
return (
@@ -183,6 +184,7 @@ function TestInProgress({
onPrev,
onSubmit,
t,
+ isDark,
}) {
const currentQuestion = questions[currentIndex]
const progress = ((currentIndex + 1) / questions.length) * 100
@@ -205,14 +207,14 @@ function TestInProgress({
px: 2,
py: 1,
borderRadius: '12px',
- backgroundColor: timeRemaining <= 2 ? '#fef2f2' : '#f5f5f4',
+ backgroundColor: timeRemaining <= 2 ? '#fef2f2' : (isDark ? '#3f3f46' : '#f5f5f4'),
}}
>
-
+
{timeRemaining}
@@ -300,8 +302,8 @@ function TestInProgress({
? '#059669'
: idx === currentIndex
? '#10b981'
- : '#f5f5f4',
- color: answers[q.wordId] || idx === currentIndex ? 'white' : '#57534e',
+ : (isDark ? '#3f3f46' : '#f5f5f4'),
+ color: answers[q.wordId] || idx === currentIndex ? 'white' : (isDark ? '#a1a1aa' : '#57534e'),
transition: 'all 0.2s ease',
}}
>
@@ -314,7 +316,7 @@ function TestInProgress({
}
// Result Screen
-function TestResult({result, onRetry, onHome, t}) {
+function TestResult({result, onRetry, onHome, t, isDark}) {
const score = result.successRate || 0
const isGreat = score >= 80
const isGood = score >= 60
@@ -353,7 +355,7 @@ function TestResult({result, onRetry, onHome, t}) {
{result.totalQuestions || 0} {t('test.question')} / {result.correctCount || 0} {t('test.correct')}
-
+ :
@@ -457,6 +459,10 @@ function TestResult({result, onRetry, onHome, t}) {
export default function TestPage() {
const navigate = useNavigate()
const {t} = useTranslation()
+ const {user} = useAuth()
+ const {mode} = useThemeMode()
+ const isDark = mode === 'dark'
+ const userId = user?.userId || user?.username
const [phase, setPhase] = useState('setup')
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
@@ -497,7 +503,7 @@ export default function TestPage() {
const fetchRecentResults = async () => {
try {
- const response = await testService.getResults(TEMP_USER_ID, {limit: 5})
+ const response = await testService.getResults(userId, {limit: 5})
setRecentResults(response?.testResults || [])
} catch (err) {
console.error('Fetch results error:', err)
@@ -508,7 +514,7 @@ export default function TestPage() {
try {
setLoading(true)
setError(null)
- const response = await testService.start(TEMP_USER_ID, 'DAILY')
+ const response = await testService.start(userId, 'DAILY')
const testData = response?.data || response
if (testData?.testId) {
@@ -554,7 +560,7 @@ export default function TestPage() {
answer: answers[q.wordId] || '',
}))
- const response = await testService.submit(TEMP_USER_ID, testId, answersArray)
+ const response = await testService.submit(userId, testId, answersArray)
const resultData = response?.data || response
if (resultData) {
@@ -597,7 +603,7 @@ export default function TestPage() {
)}
{phase === 'setup' &&
- }
+ }
{phase === 'testing' && questions.length > 0 && (
)}
{phase === 'result' && result && (
- navigate('/vocab')} t={t}/>
+ navigate('/vocab')} t={t} isDark={isDark}/>
)}
)
diff --git a/src/domains/vocab/pages/VocabDashboard.jsx b/src/domains/vocab/pages/VocabDashboard.jsx
index 7667110..fe0f37b 100644
--- a/src/domains/vocab/pages/VocabDashboard.jsx
+++ b/src/domains/vocab/pages/VocabDashboard.jsx
@@ -14,28 +14,37 @@ import {
LinearProgress,
Tooltip,
Typography,
+ useTheme,
} from '@mui/material'
import {
+ ArrowForward as ArrowIcon,
CheckCircle as CheckIcon,
- EmojiEvents as TrophyIcon,
LocalFireDepartment as FireIcon,
MenuBook as VocabIcon,
PlayArrow as PlayIcon,
Quiz as TestIcon,
+ School as LearnIcon,
Star as StarIcon,
StarBorder as StarBorderIcon,
+ Timeline as StatsIcon,
TrendingUp as TrendingIcon,
VolumeUp as VolumeIcon,
+ Warning as WarningIcon,
} from '@mui/icons-material'
import {dailyService, statsService, userWordService, voiceService} from '../services/vocabService'
-import {DAILY_GOAL, LEVEL_LABELS,} from '../constants/vocabConstants'
+import {DAILY_GOAL} from '../constants/vocabConstants'
import {useTranslation} from '../../../contexts/SettingsContext'
-
-const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1'
+import {useAuth} from '../../../contexts/AuthContext'
+import {useThemeMode} from '../../../contexts/ThemeContext'
export default function VocabDashboard() {
const navigate = useNavigate()
+ const theme = useTheme()
+ const {mode} = useThemeMode()
+ const isDark = mode === 'dark'
const {t, isKorean} = useTranslation()
+ const {user} = useAuth()
+ const userId = user?.userId || user?.username
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [dailyData, setDailyData] = useState(null)
@@ -54,16 +63,20 @@ export default function VocabDashboard() {
setError(null)
const [daily, stats, weekly, weakness] = await Promise.all([
- dailyService.getWords(TEMP_USER_ID).catch(() => null),
- statsService.getOverall(TEMP_USER_ID).catch(() => null),
- statsService.getDaily(TEMP_USER_ID, {limit: 7}).catch(() => null),
- statsService.getWeakness(TEMP_USER_ID).catch(() => null),
+ dailyService.getWords().catch(() => null),
+ statsService.getOverall().catch(() => null),
+ statsService.getDaily(null, {limit: 7}).catch(() => null),
+ statsService.getWeakness().catch(() => null),
])
setDailyData(daily)
setStatsData(stats)
- setWeeklyStats(weekly?.dailyStats || [])
- setWeakWords(weakness?.weakestWords?.slice(0, 5) || [])
+ // API: { data: { history: [...] } } 또는 mock: { history: [...] }
+ const weeklyData = weekly?.data || weekly
+ setWeeklyStats(weeklyData?.history || weeklyData?.dailyStats || [])
+ // API: { data: { frequentMistakes: [...] } } 또는 mock: { frequentMistakes: [...] }
+ const weaknessData = weakness?.data || weakness
+ setWeakWords(weaknessData?.frequentMistakes?.slice(0, 5) || weaknessData?.weakestWords?.slice(0, 5) || [])
} catch (err) {
console.error('Dashboard fetch error:', err)
setError('Failed to load data.')
@@ -81,6 +94,8 @@ export default function VocabDashboard() {
audio.onended = () => setPlayingTTS(null)
audio.onerror = () => setPlayingTTS(null)
await audio.play()
+ } else {
+ setPlayingTTS(null)
}
} catch (err) {
console.error('TTS error:', err)
@@ -90,7 +105,7 @@ export default function VocabDashboard() {
const handleToggleBookmark = async (word) => {
try {
- await userWordService.updateTag(TEMP_USER_ID, word.wordId, {
+ await userWordService.updateTag(userId, word.wordId, {
bookmarked: !word.bookmarked,
})
setWeakWords((prev) =>
@@ -113,36 +128,45 @@ export default function VocabDashboard() {
)
}
- const learnedCount = dailyData?.learnedCount || 0
- const totalWords = dailyData?.totalWords || DAILY_GOAL.TOTAL
- const progress = totalWords > 0 ? (learnedCount / totalWords) * 100 : 0
- const newWordsCount = dailyData?.newWords?.length || 0
- const reviewWordsCount = dailyData?.reviewWords?.length || 0
+ // API 응답 구조: { status, message, data: { dailyStudy, progress, newWords, reviewWords } }
+ // 또는 mock: { dailyStudy, progress, newWords, reviewWords }
+ const daily = dailyData?.data || dailyData
+ const learnedCount = daily?.progress?.learned || daily?.dailyStudy?.learnedCount || 0
+ const totalWords = daily?.progress?.total || daily?.dailyStudy?.totalWords || DAILY_GOAL.TOTAL
+ const progress = daily?.progress?.percentage ?? (totalWords > 0 ? (learnedCount / totalWords) * 100 : 0)
+ const isCompleted = daily?.progress?.isCompleted || daily?.dailyStudy?.isCompleted || false
+
+ // 통계 데이터 (API: { status, data: {...} } 또는 mock: {...})
+ const stats = statsData?.data || statsData
+ const currentStreak = stats?.currentStreak || stats?.streakDays || 0
+ const longestStreak = stats?.longestStreak || 0
+ const wordsLearned = stats?.newWordsLearned || stats?.totalLearned || 0
+ const successRate = stats?.successRate || stats?.averageAccuracy || 0
+ const testsCompleted = stats?.testsCompleted || stats?.testCount || 0
- // Calculate streak from weekly stats
- const streak = weeklyStats.filter(s => s?.isCompleted).length
+ // 주간 통계에서 완료일 수 계산
+ const weeklyCompleted = weeklyStats.filter(s => s?.isCompleted).length
return (
{/* Header */}
-
-
+
+
-
+
-
+
{t('vocabDash.title')}
@@ -153,504 +177,541 @@ export default function VocabDashboard() {
{error && (
-
+
{error}
)}
- {/* Hero Progress Card */}
-
- {/* Decorative Elements */}
-
-
-
-
-
-
-
+ {/* 오늘의 학습 카드 */}
+
+
+
+
+
+
+
+ {isKorean ? '오늘의 학습' : "Today's Learning"}
+
+
+
+ {Math.round(progress)}
+
+ %
+
+
+ {isCompleted && (
+ }
+ label={isKorean ? '완료' : 'Done'}
+ sx={{
+ backgroundColor: 'white',
+ color: '#059669',
+ fontWeight: 700,
+ }}
+ />
+ )}
+
+
+
+
+
+ {learnedCount} / {totalWords} {isKorean ? '단어' : 'words'}
+
+
+ {totalWords - learnedCount} {isKorean ? '남음' : 'left'}
+
+
+
+
+
+
+
+
+
+
+ {/* 연속 학습 카드 */}
+
+
+
+
+
+ {currentStreak}
-
- {Math.round(progress)}%
+
+ {isKorean ? '일 연속 학습' : 'Day Streak'}
-
+
+
+ {isKorean ? '최장 기록' : 'Best'}: {longestStreak} {isKorean ? '일' : 'days'}
+
+
+
+
+
+
- {streak > 0 && (
+ {/* 통계 요약 카드 4개 */}
+
+
+ navigate('/vocab/stats')}
+ >
+
-
-
-
- {streak}
-
-
- {t('vocabDash.days')}
-
-
+
- )}
-
-
-
-
-
- {learnedCount} / {totalWords} {t('vocabDash.wordsLearned')}
+
+ {wordsLearned}
-
-
-
-
-
-
-
- {isKorean ? '새 단어' : 'New Words'}
-
-
- {newWordsCount} / {DAILY_GOAL.NEW_WORDS}
-
-
-
-
- {isKorean ? '복습' : 'Review'}
+
+ {isKorean ? '학습한 단어' : 'Words Learned'}
-
- {reviewWordsCount} / {DAILY_GOAL.REVIEW_WORDS}
-
-
-
-
-
-
-
+
+
+
- {/* Quick Actions */}
-
-
+
navigate('/vocab/stats')}
>
-
+
-
+
-
- {t('vocabDash.viewStats')}
+
+ {successRate.toFixed?.(0) || 0}%
-
- {statsData?.totalWords || 0}
+
+ {isKorean ? '정답률' : 'Accuracy'}
-
- {t('vocabDash.wordsLearned')}
+
+
+
+
+
+ navigate('/vocab/test')}
+ >
+
+
+
+
+
+ {testsCompleted}
-
+ {isKorean ? '테스트 완료' : 'Tests Done'}
+
+
+
+
+
+
+ navigate('/vocab/words')}
+ >
+
+
+ >
+
+
+
+ {weeklyCompleted}/7
+
+
+ {isKorean ? '이번주 완료' : 'This Week'}
+
+
-
+ {/* 빠른 액션 */}
+
+
navigate('/vocab/test')}
>
-
+
-
+
-
- {t('vocabDash.takeQuiz')}
-
-
- {statsData?.avgSuccessRate?.toFixed(0) || 0}%
-
-
- {isKorean ? '평균 점수' : 'average score'}
-
-
+
+
+ {isKorean ? '퀴즈 풀기' : 'Take Quiz'}
+
+
+ {isKorean ? '실력을 테스트해보세요' : 'Test your knowledge'}
+
+
+
-
+
navigate('/vocab/words')}
>
-
+
-
+
-
- {t('vocabDash.viewWordList')}
-
-
- {statsData?.wordStatusCounts?.MASTERED || 0}
-
-
- {isKorean ? '마스터' : 'mastered'}
-
-
+
+ {isKorean ? '단어장' : 'Word List'}
+
+
+ {isKorean ? '학습한 단어 보기' : 'View your words'}
+
+
+
+
+
+
+
+
+ navigate('/vocab/stats')}
+ >
+
+
+ >
+
+
+
+
+ {isKorean ? '학습 통계' : 'Statistics'}
+
+
+ {isKorean ? '상세 통계 보기' : 'View detailed stats'}
+
+
+
- {/* Weekly Progress */}
-
+ {/* 주간 학습 현황 */}
+
-
- {t('vocabDash.weeklyProgress')}
-
-
- {(isKorean ? ['월', '화', '수', '목', '금', '토', '일'] : ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']).map((day, index) => {
- const stat = weeklyStats[index]
- const isCompleted = stat?.isCompleted
- const hasProgress = stat?.learnedCount > 0
- const isToday = index === new Date().getDay() - 1 || (new Date().getDay() === 0 && index === 6)
+
+
+ {isKorean ? '이번주 학습 현황' : 'This Week'}
+
+ = 7 ? 'success' : 'default'}
+ />
+
- return (
-
-
- {day}
-
+
+ {(() => {
+ const days = isKorean ? ['월', '화', '수', '목', '금', '토', '일'] : ['M', 'T', 'W', 'T', 'F', 'S', 'S']
+ const today = new Date()
+ const dayOfWeek = today.getDay()
+ const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek
+
+ return days.map((day, index) => {
+ const date = new Date(today)
+ date.setDate(today.getDate() + mondayOffset + index)
+ const dateStr = date.toISOString().split('T')[0]
+
+ // 해당 날짜의 통계 찾기
+ const stat = weeklyStats.find(s => s?.period === dateStr || s?.date === dateStr)
+ const isCompleted = stat?.isCompleted
+ const hasProgress = (stat?.newWordsLearned || stat?.learnedCount || 0) > 0
+ const isToday = date.toDateString() === today.toDateString()
+ const isFuture = date > today
+
+ return (
- {isCompleted ? (
-
+
+ {day}
+
+
+ {date.getDate()}
+
+
+ {isCompleted ? (
-
- ) : hasProgress ? (
-
+ ) : hasProgress ? (
- {stat?.learnedCount}
+ {stat?.newWordsLearned || stat?.learnedCount || 0}
-
- ) : (
-
- )}
+ ) : isFuture ? null : (
+
+ X
+
+ )}
+
-
- )
- })}
+ )
+ })
+ })()}
- {/* Weak Words */}
+ {/* 취약 단어 */}
{weakWords.length > 0 && (
-
+
+
- {t('vocabDash.focusWords')}
+ {isKorean ? '복습이 필요한 단어' : 'Words to Review'}
-
+
-
- {isKorean ? '추가 연습이 필요한 단어입니다' : 'These words need extra attention'}
-
- {weakWords.map((word, index) => (
-
-
-
-
+
+ {weakWords.map((word) => (
+
+
+
{word.english}
+
+ {word.korean}
+
+
+
+
+
+ handlePlayTTS(word)}
+ disabled={playingTTS === word.wordId}
+ >
+
+
+
+
+ handleToggleBookmark(word)}>
+ {word.bookmarked ? (
+
+ ) : (
+
+ )}
+
+
-
- {word.korean}
-
-
-
-
-
- handlePlayTTS(word)}
- disabled={playingTTS === word.wordId}
- sx={{
- backgroundColor: playingTTS === word.wordId ? 'primary.main' : 'transparent',
- '&:hover': {backgroundColor: 'rgba(5, 150, 105, 0.1)'},
- }}
- >
-
-
-
-
- handleToggleBookmark(word)}>
- {word.bookmarked ? (
-
- ) : (
-
- )}
-
-
-
-
- ))}
+ ))}
+
)}
diff --git a/src/domains/vocab/pages/WordListPage.jsx b/src/domains/vocab/pages/WordListPage.jsx
index e9c82c8..8aa1e35 100644
--- a/src/domains/vocab/pages/WordListPage.jsx
+++ b/src/domains/vocab/pages/WordListPage.jsx
@@ -28,8 +28,9 @@ import WordDetailModal from '../components/WordDetailModal'
import {myWordService, voiceService} from '../services/vocabService'
import {LEVEL_LABELS, WORD_STATUS_LABELS,} from '../constants/vocabConstants'
import {useTranslation} from '../../../contexts/SettingsContext'
+import {useAuth} from '../../../contexts/AuthContext'
+import {useThemeMode} from '../../../contexts/ThemeContext'
-const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1'
const PAGE_SIZE = 20
// 디바운스 훅
@@ -47,6 +48,10 @@ function useDebounce(value, delay) {
export default function WordListPage() {
const navigate = useNavigate()
const {t} = useTranslation()
+ const {user} = useAuth()
+ const {mode} = useThemeMode()
+ const isDark = mode === 'dark'
+ const userId = user?.userId || user?.username
const [searchParams] = useSearchParams()
const observerRef = useRef(null)
const loadMoreRef = useRef(null)
@@ -87,7 +92,7 @@ export default function WordListPage() {
params.incorrectOnly = true
}
- const response = await myWordService.getList(TEMP_USER_ID, params)
+ const response = await myWordService.getList(userId, params)
const data = response?.data || response
const newWords = data?.userWords || []
@@ -168,7 +173,7 @@ export default function WordListPage() {
const newBookmarked = !word.bookmarked
try {
- await myWordService.toggleBookmark(TEMP_USER_ID, word.wordId, newBookmarked)
+ await myWordService.toggleBookmark(userId, word.wordId, newBookmarked)
setUserWords(prev =>
prev.map(w =>
@@ -275,7 +280,7 @@ export default function WordListPage() {
sx={{
mb: 3,
'& .MuiOutlinedInput-root': {
- backgroundColor: 'white',
+ backgroundColor: isDark ? '#27272a' : 'white',
},
}}
/>
@@ -349,7 +354,7 @@ export default function WordListPage() {
-
+
{word.english}
{word.level && (
@@ -380,7 +385,7 @@ export default function WordListPage() {
)}
-
+
{word.korean}
@@ -407,13 +412,13 @@ export default function WordListPage() {
sx={{
width: 40,
height: 40,
- backgroundColor: playingWordId === word.wordId ? '#059669' : '#f5f5f4',
- '&:hover': {backgroundColor: playingWordId === word.wordId ? '#047857' : '#e7e5e4'},
+ backgroundColor: playingWordId === word.wordId ? '#059669' : (isDark ? '#3f3f46' : '#f5f5f4'),
+ '&:hover': {backgroundColor: playingWordId === word.wordId ? '#047857' : (isDark ? '#52525b' : '#e7e5e4')},
}}
>
{word.bookmarked ? (
@@ -457,7 +462,7 @@ export default function WordListPage() {
width: 64,
height: 64,
borderRadius: '16px',
- backgroundColor: '#f5f5f4',
+ backgroundColor: isDark ? '#3f3f46' : '#f5f5f4',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
diff --git a/src/domains/vocab/services/vocabService.js b/src/domains/vocab/services/vocabService.js
index 613b1d7..2426831 100644
--- a/src/domains/vocab/services/vocabService.js
+++ b/src/domains/vocab/services/vocabService.js
@@ -1,7 +1,7 @@
import vocabApi from '../../../api/vocabApi'
-// Mock 데이터 사용 여부 (true: 목 데이터 사용, false: 실제 API 호출)
-const USE_MOCK = true
+// Mock 데이터 사용 여부 (환경변수로 제어: VITE_USE_MOCK=true)
+const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true'
// ============================================
// Mock 데이터
@@ -235,7 +235,17 @@ const withMock = (apiCall, mockData) => {
// interceptor가 response.data를 반환하므로 mockData를 직접 반환
return Promise.resolve(mockData)
}
- return apiCall().catch(() => mockData)
+ // 실제 API 호출 시 응답의 data 필드 추출 (백엔드 응답: { isSuccess, message, data })
+ return apiCall()
+ .then(response => response.data || response)
+ .catch((err) => {
+ // 400 에러(level required 등)는 re-throw하여 컴포넌트에서 처리
+ if (err.response?.status === 400) {
+ throw err
+ }
+ // 네트워크 에러 등은 mock fallback
+ return mockData
+ })
}
/**
@@ -245,7 +255,7 @@ export const wordService = {
// GET /words - 단어 목록 조회
getList: ({level, category, limit = 20, cursor} = {}) =>
withMock(
- () => vocabApi.get('/words', {params: {level, category, limit, cursor}}),
+ () => vocabApi.get('/vocab/words', {params: {level, category, limit, cursor}}),
{
words: mockWords.filter(w => (!level || w.level === level) && (!category || w.category === category)).slice(0, limit),
hasMore: false,
@@ -256,7 +266,7 @@ export const wordService = {
// GET /words - 단어 목록 조회 (별칭)
getWords: (params) =>
withMock(
- () => vocabApi.get('/words', {params}),
+ () => vocabApi.get('/vocab/words', {params}),
{words: mockWords, hasMore: false}
),
@@ -284,14 +294,14 @@ export const wordService = {
// POST /words/batch - 배치 단어 생성
createBatch: (words) =>
withMock(
- () => vocabApi.post('/words/batch', {words}),
+ () => vocabApi.post('/vocab/words/batch', {words}),
{successCount: words.length, failCount: 0, totalRequested: words.length}
),
// POST /words/batch/get - 배치 단어 조회
getBatch: (wordIds) =>
withMock(
- () => vocabApi.post('/words/batch/get', {wordIds}),
+ () => vocabApi.post('/vocab/words/batch/get', {wordIds}),
{
words: mockWords.filter(w => wordIds.includes(w.wordId)),
requestedCount: wordIds.length,
@@ -301,31 +311,45 @@ export const wordService = {
}
/**
- * 일일 학습 API - Backend: POST /daily-study/record, GET /user-words/review
+ * 일일 학습 API - Backend: GET /vocab/daily?level={level}, POST /vocab/daily/words/{wordId}/learned
+ * userId는 토큰에서 추출됨
*/
export const dailyService = {
- // 일일 학습용 단어 조회 (새 단어 + 복습 단어)
- getWords: (userId, level) =>
+ // GET /vocab/daily?level={level} - 오늘의 학습 단어 조회
+ // 첫 호출 시 자동으로 생성됨
+ getWords: (level) =>
withMock(
- () => vocabApi.get('/user-words/review', {params: {userId, ...(level ? {level} : {})}}),
+ () => vocabApi.get('/vocab/daily', {params: {level: level?.toUpperCase()}}),
{
- newWords: mockWords.filter(w => !level || w.level === level).slice(0, 10),
+ dailyStudy: {
+ date: new Date().toISOString().split('T')[0],
+ totalWords: 55,
+ learnedCount: 0,
+ isCompleted: false,
+ },
+ newWords: mockWords.filter(w => !level || w.level === level.toUpperCase()).slice(0, 50),
reviewWords: mockUserWords.filter(w => w.status === 'REVIEWING').slice(0, 5),
- learnedCount: 0,
- isCompleted: false,
+ progress: {
+ total: 55,
+ learned: 0,
+ remaining: 55,
+ percentage: 0,
+ isCompleted: false,
+ },
}
),
- // POST /daily-study/record - 일일 학습 기록
- markLearned: (userId, wordId, isCorrect, studyType = 'REVIEW') =>
+ // POST /vocab/daily/words/{wordId}/learned - 단어 학습 완료 표시
+ // body 필요 없음 (userId는 토큰에서 추출)
+ markLearned: (wordId) =>
withMock(
- () => vocabApi.post('/daily-study/record', {userId, wordId, isCorrect, studyType}),
+ () => vocabApi.post(`/vocab/daily/words/${wordId}/learned`),
{
- userId,
- date: new Date().toISOString().split('T')[0],
- wordsStudied: 1,
- correctCount: isCorrect ? 1 : 0,
- incorrectCount: isCorrect ? 0 : 1,
+ total: 55,
+ learned: 1,
+ remaining: 54,
+ percentage: 1.82,
+ isCompleted: false,
}
),
}
@@ -337,7 +361,7 @@ export const userWordService = {
// GET /user-words/review - 복습 예정 단어 조회
getList: (userId, {status, limit = 20, cursor, date} = {}) =>
withMock(
- () => vocabApi.get('/user-words/review', {params: {userId, status, limit, cursor, date}}),
+ () => vocabApi.get('/vocab/user-words', {params: {userId, status, limit, cursor, date}}),
{
userWords: mockUserWords.filter(w => !status || w.status === status).slice(0, limit),
hasMore: false,
@@ -348,14 +372,14 @@ export const userWordService = {
// GET /user-words/review - 사용자 단어 조회 (별칭)
getUserWords: (userId, params) =>
withMock(
- () => vocabApi.get('/user-words/review', {params: {userId, ...params}}),
+ () => vocabApi.get('/vocab/user-words', {params: {userId, ...params}}),
{words: mockUserWords, hasMore: false}
),
- // POST /user-words/{wordId}/review - 사용자 단어 학습 업데이트
+ // PUT /user-words/{wordId} - 사용자 단어 학습 업데이트
update: (userId, wordId, isCorrect) =>
withMock(
- () => vocabApi.post(`/user-words/${wordId}/review`, {userId, isCorrect}),
+ () => vocabApi.put(`/vocab/user-words/${wordId}`, {userId, isCorrect}),
{
userId,
wordId,
@@ -371,16 +395,18 @@ export const userWordService = {
),
// PATCH /user-words/{wordId}/tag - 사용자 단어 태그 업데이트
+ // userId는 토큰에서 추출되므로 body에 포함하지 않음
updateTag: (userId, wordId, {bookmarked, favorite, difficulty}) =>
withMock(
- () => vocabApi.patch(`/user-words/${wordId}/tag`, {userId, bookmarked, favorite, difficulty}),
+ () => vocabApi.patch(`/vocab/user-words/${wordId}/tag`, {bookmarked, favorite, difficulty}),
{success: true, userId, wordId, bookmarked, favorite, difficulty}
),
// PATCH /user-words/{wordId}/tag - 사용자 단어 업데이트 (별칭)
+ // userId는 토큰에서 추출되므로 body에 포함하지 않음
updateUserWord: (userId, wordId, data) =>
withMock(
- () => vocabApi.patch(`/user-words/${wordId}/tag`, {userId, ...data}),
+ () => vocabApi.patch(`/vocab/user-words/${wordId}/tag`, data),
{success: true, ...data}
),
}
@@ -392,7 +418,7 @@ export const myWordService = {
// GET /user-words/review - 나의 단어 목록 (필터링)
getList: (userId, {bookmarked, incorrectOnly, limit = 20, cursor} = {}) =>
withMock(
- () => vocabApi.get('/user-words/review', {
+ () => vocabApi.get('/vocab/user-words', {
params: {userId, bookmarked, incorrectOnly, limit, cursor}
}),
{
@@ -406,21 +432,22 @@ export const myWordService = {
// 북마크된 단어 조회
getBookmarked: (userId, {limit = 20, cursor} = {}) =>
withMock(
- () => vocabApi.get('/user-words/review', {params: {userId, bookmarked: true, limit, cursor}}),
+ () => vocabApi.get('/vocab/user-words', {params: {userId, bookmarked: true, limit, cursor}}),
{userWords: mockUserWords.filter(w => w.bookmarked).slice(0, limit), hasMore: false}
),
// 오답 단어 조회
getIncorrect: (userId, {limit = 20, cursor} = {}) =>
withMock(
- () => vocabApi.get('/user-words/review', {params: {userId, incorrectOnly: true, limit, cursor}}),
+ () => vocabApi.get('/vocab/user-words', {params: {userId, incorrectOnly: true, limit, cursor}}),
{userWords: mockUserWords.filter(w => w.incorrectCount > 0).slice(0, limit), hasMore: false}
),
// PATCH /user-words/{wordId}/tag - 북마크 토글
+ // userId는 토큰에서 추출되므로 body에 포함하지 않음
toggleBookmark: (userId, wordId, bookmarked) =>
withMock(
- () => vocabApi.patch(`/user-words/${wordId}/tag`, {userId, bookmarked}),
+ () => vocabApi.patch(`/vocab/user-words/${wordId}/tag`, {bookmarked}),
{success: true, wordId, bookmarked}
),
}
@@ -432,7 +459,7 @@ export const testService = {
// POST /tests/start - 시험 시작
start: (userId, testType = 'DAILY', wordCount = 20, level) =>
withMock(
- () => vocabApi.post('/tests/start', {userId, testType, wordCount, level}),
+ () => vocabApi.post('/vocab/test/start', {userId, testType, wordCount, level}),
{
testId: `test-${Date.now()}`,
testType,
@@ -445,10 +472,10 @@ export const testService = {
}
),
- // POST /tests/{testId}/submit - 시험 제출
+ // POST /vocab/test/submit - 시험 제출
submit: (userId, testId, answers) =>
withMock(
- () => vocabApi.post(`/tests/${testId}/submit`, {userId, answers}),
+ () => vocabApi.post('/vocab/test/submit', {userId, testId, answers}),
{
testId,
totalQuestions: answers.length,
@@ -463,63 +490,95 @@ export const testService = {
// 시험 결과 조회 (프론트엔드 전용 - 백엔드에서 미구현)
getResults: (userId, {limit = 20, cursor} = {}) =>
withMock(
- () => vocabApi.get('/tests/results', {params: {userId, limit, cursor}}),
+ () => vocabApi.get('/vocab/test/results', {params: {userId, limit, cursor}}),
{testResults: mockTestResults.slice(0, limit), hasMore: false}
),
}
/**
- * 통계 API - Backend: GET /statistics
+ * 통계 API - Backend: GET /stats/total, GET /stats/history, GET /vocab/stats/weakness
+ * userId는 토큰에서 추출되므로 파라미터로 전달하지 않음
*/
export const statsService = {
- // GET /statistics - 학습 통계 조회
- getOverall: (userId, period = 'ALL') =>
+ // GET /stats/total - 전체 통계 조회
+ getOverall: () =>
withMock(
- () => vocabApi.get('/statistics', {params: {userId, period}}),
+ () => vocabApi.get('/stats/total'),
{
- totalWords: mockWords.length,
- totalLearned: 15,
- masteredWords: 5,
- learningWords: 8,
- newWords: 7,
- averageSuccessRate: 78.5,
- averageAccuracy: 78.5,
- studyStreak: 7,
+ periodType: 'TOTAL',
+ period: 'ALL',
+ testsCompleted: 4,
+ questionsAnswered: 100,
+ correctAnswers: 78,
+ incorrectAnswers: 22,
+ successRate: 78.0,
+ newWordsLearned: 50,
+ wordsReviewed: 20,
+ currentStreak: 7,
+ longestStreak: 15,
+ lastStudyDate: new Date().toISOString().split('T')[0],
+ // 프론트엔드 호환용 필드
+ totalLearned: 50,
+ averageSuccessRate: 78.0,
+ averageAccuracy: 78.0,
streakDays: 7,
- dailyStats: generateDailyStats().slice(0, 7),
- levelProgress: {
- BEGINNER: {total: 8, learned: 6},
- INTERMEDIATE: {total: 7, learned: 5},
- ADVANCED: {total: 5, learned: 2},
- },
- difficultyDistribution: {
- EASY: 6,
- NORMAL: 9,
- HARD: 5,
- },
}
),
- // GET /statistics - 기간별 통계 (프론트엔드 래핑)
- getDaily: (userId, {limit = 30, period = 'MONTH'} = {}) =>
+ // GET /stats/history - 히스토리 조회 (히트맵/차트용)
+ getDaily: (userId, {limit = 7} = {}) =>
withMock(
- () => vocabApi.get('/statistics', {params: {userId, period}}),
- {dailyStats: generateDailyStats().slice(0, limit)}
+ () => vocabApi.get('/stats/history', {params: {limit}}),
+ {
+ history: generateDailyStats().slice(0, limit).map(s => ({
+ period: s.date,
+ testsCompleted: Math.floor(Math.random() * 3),
+ questionsAnswered: s.wordsStudied,
+ correctAnswers: s.correctCount,
+ successRate: s.successRate,
+ newWordsLearned: s.learnedCount,
+ wordsReviewed: Math.floor(Math.random() * 10),
+ // 프론트엔드 호환용
+ date: s.date,
+ learnedCount: s.learnedCount,
+ isCompleted: s.learnedCount >= 55,
+ })),
+ dailyStats: generateDailyStats().slice(0, limit),
+ hasMore: false,
+ }
),
- // 취약 단어 조회 (프론트엔드 전용 - 백엔드에서 미구현)
- getWeakness: (userId) =>
+ // GET /vocab/stats/weakness - 취약점 분석
+ getWeakness: () =>
withMock(
- () => vocabApi.get('/statistics', {params: {userId, includeWeak: true}}),
+ () => vocabApi.get('/vocab/stats/weakness'),
{
+ weakCategories: [
+ {category: 'BUSINESS', incorrectRate: 35.5, totalAnswered: 100, incorrectCount: 35},
+ {category: 'ACADEMIC', incorrectRate: 28.0, totalAnswered: 50, incorrectCount: 14},
+ ],
+ frequentMistakes: mockUserWords
+ .filter(w => w.incorrectCount > 0)
+ .sort((a, b) => b.incorrectCount - a.incorrectCount)
+ .slice(0, 10)
+ .map(w => ({
+ wordId: w.wordId,
+ english: w.english,
+ korean: w.korean,
+ incorrectCount: w.incorrectCount,
+ accuracy: Math.round((w.correctCount / (w.correctCount + w.incorrectCount)) * 100),
+ })),
weakWords: mockUserWords
.filter(w => w.incorrectCount > 0)
- .sort((a, b) => (a.correctCount / (a.correctCount + a.incorrectCount)) - (b.correctCount / (b.correctCount + b.incorrectCount)))
.slice(0, 10)
.map(w => ({
...w,
accuracy: Math.round((w.correctCount / (w.correctCount + w.incorrectCount)) * 100),
})),
+ weakestWords: mockUserWords
+ .filter(w => w.incorrectCount > 0)
+ .slice(0, 5),
+ recommendedReview: 15,
}
),
}
@@ -531,7 +590,7 @@ export const voiceService = {
// POST /voice/synthesize - 음성 합성
synthesize: (wordId, text, voice = 'female', type = 'word') =>
withMock(
- () => vocabApi.post('/voice/synthesize', {wordId, text, voice, type}),
+ () => vocabApi.post('/vocab/voice/synthesize', {wordId, text, voice, type}),
{
audioUrl: null, // Mock에서는 실제 오디오 없음
cached: false,
diff --git a/src/i18n/translations.js b/src/i18n/translations.js
index a7cd430..6ef4b88 100644
--- a/src/i18n/translations.js
+++ b/src/i18n/translations.js
@@ -375,6 +375,14 @@ export const translations = {
ALL_BADGES: '마스터',
},
},
+
+ // Games
+ games: {
+ title: '게임',
+ description: '재미있는 게임으로 영어 실력을 향상하세요',
+ catchmindTitle: '캐치마인드',
+ catchmindDesc: '그림 맞추기 게임',
+ },
},
en: {
@@ -748,6 +756,14 @@ export const translations = {
ALL_BADGES: 'Master',
},
},
+
+ // Games
+ games: {
+ title: 'Games',
+ description: 'Improve your English with fun games',
+ catchmindTitle: 'Catchmind',
+ catchmindDesc: 'Drawing guessing game',
+ },
},
}
diff --git a/src/index.css b/src/index.css
index 82ee46c..7eb2f6a 100644
--- a/src/index.css
+++ b/src/index.css
@@ -89,9 +89,9 @@ body {
font-family: var(--font-body);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
- background-color: var(--color-gray-50);
- color: var(--color-gray-900);
line-height: 1.6;
+ /* Let MUI CssBaseline control background-color and color for dark mode support */
+ transition: background-color 0.2s ease, color 0.2s ease;
}
#root {
@@ -258,6 +258,179 @@ h1, h2, h3, h4, h5, h6 {
-webkit-backdrop-filter: blur(12px);
}
+/* ============================================
+ Dark Mode Support
+ ============================================ */
+.dark-mode .glass {
+ background: rgba(39, 39, 42, 0.8);
+}
+
+.dark-mode ::-webkit-scrollbar-track {
+ background: #27272a;
+}
+
+.dark-mode ::-webkit-scrollbar-thumb {
+ background: #52525b;
+}
+
+.dark-mode ::-webkit-scrollbar-thumb:hover {
+ background: #71717a;
+}
+
+.dark-mode ::selection {
+ background-color: #059669;
+ color: #ecfdf5;
+}
+
+/* Dark Mode - MUI Components */
+.dark-mode .MuiCard-root,
+.dark-mode .MuiPaper-root:not(.MuiAlert-root):not(.MuiMenu-paper) {
+ background-color: #27272a !important;
+ color: #fafafa !important;
+}
+
+.dark-mode .MuiCardContent-root {
+ color: #fafafa !important;
+}
+
+.dark-mode .MuiTypography-root {
+ color: #fafafa !important;
+}
+
+.dark-mode .MuiTypography-colorTextSecondary,
+.dark-mode .MuiTypography-root.MuiTypography-colorTextSecondary,
+.dark-mode [class*="MuiTypography-colorTextSecondary"] {
+ color: #a1a1aa !important;
+}
+
+/* Preserve colored text */
+.dark-mode .MuiTypography-colorPrimary {
+ color: #34d399 !important;
+}
+
+.dark-mode .MuiTypography-colorError {
+ color: #f87171 !important;
+}
+
+.dark-mode .MuiInputBase-root {
+ background-color: #3f3f46 !important;
+ color: #fafafa !important;
+}
+
+.dark-mode .MuiOutlinedInput-notchedOutline {
+ border-color: #52525b !important;
+}
+
+.dark-mode .MuiInputLabel-root {
+ color: #a1a1aa !important;
+}
+
+.dark-mode .MuiChip-root:not(.MuiChip-colorPrimary):not(.MuiChip-colorSecondary):not(.MuiChip-colorSuccess):not(.MuiChip-colorError):not(.MuiChip-colorWarning) {
+ background-color: #3f3f46 !important;
+ color: #fafafa !important;
+}
+
+.dark-mode .MuiChip-outlined {
+ border-color: #52525b !important;
+ background-color: transparent !important;
+}
+
+.dark-mode .MuiButton-outlined {
+ border-color: #52525b !important;
+ color: #fafafa !important;
+}
+
+.dark-mode .MuiButton-text {
+ color: #fafafa !important;
+}
+
+.dark-mode .MuiIconButton-root {
+ color: #fafafa !important;
+}
+
+.dark-mode .MuiSvgIcon-root {
+ color: inherit !important;
+}
+
+.dark-mode .MuiAlert-root {
+ background-color: #3f3f46 !important;
+}
+
+.dark-mode .MuiAlert-standardSuccess {
+ background-color: rgba(52, 211, 153, 0.15) !important;
+}
+
+.dark-mode .MuiAlert-standardError {
+ background-color: rgba(248, 113, 113, 0.15) !important;
+}
+
+.dark-mode .MuiAlert-standardWarning {
+ background-color: rgba(251, 191, 36, 0.15) !important;
+}
+
+.dark-mode .MuiTableCell-root {
+ border-color: #3f3f46 !important;
+ color: #fafafa !important;
+}
+
+.dark-mode .MuiTableHead-root .MuiTableCell-root {
+ background-color: #27272a !important;
+}
+
+.dark-mode .MuiDivider-root {
+ border-color: #3f3f46 !important;
+}
+
+.dark-mode .MuiLinearProgress-root {
+ background-color: #3f3f46 !important;
+}
+
+.dark-mode .MuiMenu-paper,
+.dark-mode .MuiPopover-paper {
+ background-color: #27272a !important;
+}
+
+.dark-mode .MuiMenuItem-root {
+ color: #fafafa !important;
+}
+
+.dark-mode .MuiMenuItem-root:hover {
+ background-color: #3f3f46 !important;
+}
+
+.dark-mode .MuiList-root {
+ background-color: #27272a !important;
+}
+
+.dark-mode .MuiListItemButton-root:hover {
+ background-color: #3f3f46 !important;
+}
+
+.dark-mode .MuiListItemText-primary {
+ color: #fafafa !important;
+}
+
+.dark-mode .MuiListItemText-secondary {
+ color: #a1a1aa !important;
+}
+
+.dark-mode .MuiTab-root {
+ color: #a1a1aa !important;
+}
+
+.dark-mode .MuiTab-root.Mui-selected {
+ color: #34d399 !important;
+}
+
+.dark-mode .MuiTabs-indicator {
+ background-color: #34d399 !important;
+}
+
+.dark-mode .MuiTooltip-tooltip {
+ background-color: #3f3f46 !important;
+ color: #fafafa !important;
+}
+
/* Card Hover Effect */
.card-hover {
transition: transform var(--transition-base), box-shadow var(--transition-base);
diff --git a/src/layouts/AuthLayout/index.jsx b/src/layouts/AuthLayout/index.jsx
new file mode 100644
index 0000000..904e720
--- /dev/null
+++ b/src/layouts/AuthLayout/index.jsx
@@ -0,0 +1,50 @@
+import {Box, Paper, Typography} from '@mui/material';
+
+export default function AuthLayout({children}) {
+ return (
+
+
+
+
+
+ AI
+
+
+
+ AI 언어 학습
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/layouts/MainLayout/Footer/index.jsx b/src/layouts/MainLayout/Footer/index.jsx
index 27093f4..d60e513 100644
--- a/src/layouts/MainLayout/Footer/index.jsx
+++ b/src/layouts/MainLayout/Footer/index.jsx
@@ -1,8 +1,11 @@
import {Box, Container, Link, Typography} from '@mui/material'
import {useTranslation} from '../../../contexts/SettingsContext'
+import {useThemeMode} from '../../../contexts/ThemeContext'
const Footer = () => {
const {t} = useTranslation()
+ const {mode} = useThemeMode()
+ const isDark = mode === 'dark'
return (
{
py: 3,
px: 2,
mt: 'auto',
- backgroundColor: 'background.paper',
+ backgroundColor: isDark ? '#27272a' : '#ffffff',
borderTop: 1,
- borderColor: 'divider',
+ borderColor: isDark ? '#3f3f46' : 'divider',
}}
>
diff --git a/src/layouts/MainLayout/Header/index.jsx b/src/layouts/MainLayout/Header/index.jsx
index e468f15..f93dfa8 100644
--- a/src/layouts/MainLayout/Header/index.jsx
+++ b/src/layouts/MainLayout/Header/index.jsx
@@ -28,6 +28,7 @@ import {
import {useThemeMode} from '../../../contexts/ThemeContext'
import {useSettings, useTranslation} from '../../../contexts/SettingsContext'
import {LANGUAGE_LABELS, LANGUAGES} from '../../../i18n/translations'
+import {useAuth} from '../../../contexts/AuthContext'
const Header = ({onMenuClick, sidebarOpen}) => {
const theme = useTheme()
@@ -40,6 +41,7 @@ const Header = ({onMenuClick, sidebarOpen}) => {
const [anchorEl, setAnchorEl] = useState(null)
const [notificationAnchor, setNotificationAnchor] = useState(null)
const [langAnchor, setLangAnchor] = useState(null)
+ const {logout} = useAuth()
const handleProfileMenuOpen = (event) => {
setAnchorEl(event.currentTarget)
@@ -70,8 +72,9 @@ const Header = ({onMenuClick, sidebarOpen}) => {
handleLangClose()
}
- const handleLogout = () => {
+ const handleLogout = async () => {
handleProfileMenuClose()
+ await logout() // Cognito 로그아웃 (토큰 삭제)
navigate('/login')
}
@@ -82,8 +85,8 @@ const Header = ({onMenuClick, sidebarOpen}) => {
sx={{
zIndex: theme.zIndex.drawer + 1,
background: mode === 'dark'
- ? 'rgba(30, 30, 30, 0.85)'
- : 'rgba(255, 255, 255, 0.85)',
+ ? 'rgba(24, 24, 27, 0.95)'
+ : 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(20px)',
borderBottom: '1px solid',
borderColor: mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)',
diff --git a/src/layouts/MainLayout/HorizontalNav/index.jsx b/src/layouts/MainLayout/HorizontalNav/index.jsx
index c4ea8f4..3e11a9f 100644
--- a/src/layouts/MainLayout/HorizontalNav/index.jsx
+++ b/src/layouts/MainLayout/HorizontalNav/index.jsx
@@ -15,6 +15,7 @@ import {
School as LearnIcon,
Settings as SettingsIcon,
SmartToy as AiIcon,
+ SportsEsports as GameIcon,
TrendingUp as TrendingIcon,
} from '@mui/icons-material'
import {useThemeMode} from '../../../contexts/ThemeContext'
@@ -43,7 +44,7 @@ const HorizontalNav = () => {
id: 'speaking',
label: t('sidebar.speaking'),
icon: SpeakingIcon,
- color: '#3b82f6',
+ color: '#059669',
children: [
{
id: 'opic',
@@ -119,6 +120,21 @@ const HorizontalNav = () => {
},
],
},
+ {
+ id: 'games',
+ label: t('games.title'),
+ icon: GameIcon,
+ color: '#8b5cf6',
+ children: [
+ {
+ id: 'catchmind',
+ label: t('games.catchmindTitle'),
+ icon: GameIcon,
+ path: '/games/catchmind',
+ desc: t('games.catchmindDesc')
+ },
+ ],
+ },
{
id: 'dashboard',
label: t('nav.dashboard'),
@@ -137,7 +153,7 @@ const HorizontalNav = () => {
id: 'settings',
label: t('nav.settings'),
icon: SettingsIcon,
- color: '#6b7280',
+ color: mode === 'dark' ? '#a1a1aa' : '#6b7280',
path: '/settings',
},
]
@@ -282,7 +298,7 @@ const HorizontalNav = () => {
width: DROPDOWN_ITEM_WIDTH + DROPDOWN_PADDING * 2,
// 최대 하위 메뉴 개수 기준으로 고정 높이 설정
minHeight: maxChildren * DROPDOWN_ITEM_HEIGHT + DROPDOWN_PADDING * 2,
- backgroundColor: mode === 'dark' ? '#1e1e1e' : 'white',
+ backgroundColor: mode === 'dark' ? '#27272a' : 'white',
boxShadow: mode === 'dark'
? '0 10px 40px -10px rgba(0,0,0,0.5)'
: '0 10px 40px -10px rgba(0,0,0,0.15)',
diff --git a/src/layouts/MainLayout/Sidebar/index.jsx b/src/layouts/MainLayout/Sidebar/index.jsx
index 8ee4644..2046c00 100644
--- a/src/layouts/MainLayout/Sidebar/index.jsx
+++ b/src/layouts/MainLayout/Sidebar/index.jsx
@@ -34,6 +34,7 @@ import {
School as LearnIcon,
Settings as SettingsIcon,
SmartToy as AiIcon,
+ SportsEsports as GameIcon,
TrendingUp as TrendingIcon,
} from '@mui/icons-material'
import {useThemeMode} from '../../../contexts/ThemeContext'
@@ -52,7 +53,7 @@ const Sidebar = ({open, collapsed, onToggleCollapse, onClose}) => {
const [expandedMenus, setExpandedMenus] = useState(() => {
const saved = localStorage.getItem('expandedMenus')
- return saved ? JSON.parse(saved) : {speaking: true, writing: true, vocab: true}
+ return saved ? JSON.parse(saved) : {speaking: true, writing: true, vocab: true, games: true}
})
useEffect(() => {
@@ -67,8 +68,8 @@ const Sidebar = ({open, collapsed, onToggleCollapse, onClose}) => {
id: 'speaking',
label: t('sidebar.speaking'),
icon: SpeakingIcon,
- color: '#3b82f6',
- bgColor: '#eff6ff',
+ color: '#059669',
+ bgColor: '#ecfdf5',
children: [
{
id: 'opic',
@@ -146,6 +147,22 @@ const Sidebar = ({open, collapsed, onToggleCollapse, onClose}) => {
},
],
},
+ {
+ id: 'games',
+ label: t('games.title'),
+ icon: GameIcon,
+ color: '#8b5cf6',
+ bgColor: '#f3e8ff',
+ children: [
+ {
+ id: 'catchmind',
+ label: t('games.catchmindTitle'),
+ icon: GameIcon,
+ path: '/games/catchmind',
+ description: t('games.catchmindDesc'),
+ },
+ ],
+ },
],
},
{
@@ -175,7 +192,7 @@ const Sidebar = ({open, collapsed, onToggleCollapse, onClose}) => {
icon: SettingsIcon,
path: '/settings',
description: t('sidebar.settingsDesc'),
- color: '#6b7280',
+ color: mode === 'dark' ? '#a1a1aa' : '#6b7280',
bgColor: '#f3f4f6',
},
],
diff --git a/src/layouts/MainLayout/index.jsx b/src/layouts/MainLayout/index.jsx
index 971b7e1..6626f15 100644
--- a/src/layouts/MainLayout/index.jsx
+++ b/src/layouts/MainLayout/index.jsx
@@ -5,6 +5,7 @@ import Header from './Header'
import Sidebar from './Sidebar'
import HorizontalNav from './HorizontalNav'
import Footer from './Footer'
+import {useThemeMode} from '../../contexts/ThemeContext'
const DRAWER_WIDTH = 280
const DRAWER_WIDTH_COLLAPSED = 76
@@ -14,6 +15,8 @@ const USE_HORIZONTAL_NAV = true
const MainLayout = () => {
const theme = useTheme()
+ const {mode} = useThemeMode()
+ const isDark = mode === 'dark'
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
// 모바일 사이드바 열림 상태
@@ -51,7 +54,7 @@ const MainLayout = () => {
const topOffset = USE_HORIZONTAL_NAV && !isMobile ? 120 : 64
return (
-
+
{/* Header */}
{
sx={{
flex: 1,
p: 3,
- backgroundColor: 'background.default',
+ backgroundColor: isDark ? '#18181b' : '#fafaf9',
+ transition: 'background-color 0.2s ease',
}}
>
diff --git a/src/main.jsx b/src/main.jsx
index 9cabb0e..e5b117e 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -8,6 +8,11 @@ import {ThemeProvider} from './contexts/ThemeContext'
import {ChatProvider} from './contexts/ChatContext'
import {SettingsProvider} from './contexts/SettingsContext'
import './index.css'
+import {Amplify} from 'aws-amplify'
+import {AuthProvider} from './contexts/AuthContext.jsx'
+import awsConfig from './aws-config'
+
+Amplify.configure(awsConfig)
createRoot(document.getElementById('root')).render(
@@ -16,7 +21,9 @@ createRoot(document.getElementById('root')).render(
-
+
+
+
diff --git a/src/pages/Dashboard/index.jsx b/src/pages/Dashboard/index.jsx
new file mode 100644
index 0000000..d73d094
--- /dev/null
+++ b/src/pages/Dashboard/index.jsx
@@ -0,0 +1,26 @@
+import {Box, Button, Card, CardContent, Typography} from '@mui/material';
+import {useNavigate} from 'react-router-dom';
+import {useAuth} from '../../contexts/AuthContext';
+
+export default function DashboardPage() {
+ const navigate = useNavigate();
+ const {user, logout} = useAuth();
+
+ const handleLogout = async () => {
+ await logout();
+ navigate('/login');
+ };
+
+ return (
+
+ 🎉 로그인 성공!
+
+
+ 이메일: {user?.email}
+ JWT 토큰이 자동으로 관리되고 있습니다.
+
+
+
+
+ );
+}
diff --git a/src/pages/Login/index.jsx b/src/pages/Login/index.jsx
new file mode 100644
index 0000000..e22fc44
--- /dev/null
+++ b/src/pages/Login/index.jsx
@@ -0,0 +1,12 @@
+import {useNavigate} from 'react-router-dom';
+import AuthLayout from '../../layouts/AuthLayout';
+import LoginForm from '../../domains/auth/components/LoginForm';
+
+export default function LoginPage() {
+ const navigate = useNavigate();
+ return (
+
+ navigate('/signup')}/>
+
+ );
+}
diff --git a/src/pages/SignUp/index.jsx b/src/pages/SignUp/index.jsx
new file mode 100644
index 0000000..5e0eacd
--- /dev/null
+++ b/src/pages/SignUp/index.jsx
@@ -0,0 +1,12 @@
+import {useNavigate} from 'react-router-dom';
+import AuthLayout from '../../layouts/AuthLayout';
+import SignupForm from '../../domains/auth/components/SignupForm';
+
+export default function SignUpPage() {
+ const navigate = useNavigate();
+ return (
+
+ navigate('/login')}/>
+
+ );
+}
diff --git a/src/theme/theme.js b/src/theme/theme.js
index 773340a..1b0b938 100644
--- a/src/theme/theme.js
+++ b/src/theme/theme.js
@@ -402,15 +402,15 @@ export const darkTheme = createTheme({
contrastText: '#1c1917',
},
background: {
- default: '#0c0a09', // Stone 950
- paper: '#1c1917', // Stone 900
+ default: '#18181b', // Zinc 900 - softer dark
+ paper: '#27272a', // Zinc 800 - for cards
},
text: {
primary: '#fafaf9', // Stone 50
secondary: '#a8a29e', // Stone 400
disabled: '#78716c', // Stone 500
},
- divider: '#292524', // Stone 800
+ divider: '#3f3f46', // Zinc 700
action: {
hover: 'rgba(52, 211, 153, 0.08)',
selected: 'rgba(52, 211, 153, 0.16)',
diff --git a/vite.config.js b/vite.config.js
index 4c5fcda..9833a92 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -5,7 +5,15 @@ export default defineConfig({
plugins: [react()],
server: {
port: 3000,
- open: true
+ open: true,
+ proxy: {
+ '/api': {
+ target: 'https://gc8l9ijhzc.execute-api.ap-northeast-2.amazonaws.com/dev',
+ changeOrigin: true,
+ rewrite: (path) => path.replace(/^\/api/, ''),
+ secure: true,
+ }
+ }
},
build: {
outDir: 'dist',