diff --git a/src/domains/freetalk/components/CommandAutocomplete.jsx b/src/domains/freetalk/components/CommandAutocomplete.jsx
new file mode 100644
index 0000000..09b83e3
--- /dev/null
+++ b/src/domains/freetalk/components/CommandAutocomplete.jsx
@@ -0,0 +1,150 @@
+import { useEffect, useState } from 'react'
+import { Box, Paper, Typography, List, ListItem, ListItemButton, ListItemText } from '@mui/material'
+import { useThemeMode } from '../../../contexts/ThemeContext'
+import { searchCommands } from '../types/chatCommandTypes'
+
+/**
+ * 채팅 명령어 자동완성 컴포넌트
+ * 사용자가 "/"를 입력하면 사용 가능한 명령어 목록을 표시합니다.
+ *
+ * @param {Object} props
+ * @param {string} props.input - 현재 입력 값
+ * @param {function} props.onSelect - 명령어 선택 시 호출되는 콜백
+ * @param {boolean} props.show - 표시 여부
+ * @returns {JSX.Element|null}
+ */
+const CommandAutocomplete = ({ input, onSelect, show }) => {
+ const { mode } = useThemeMode()
+ const isDark = mode === 'dark'
+ const [selectedIndex, setSelectedIndex] = useState(0)
+ const [filteredCommands, setFilteredCommands] = useState([])
+
+ // 입력값에 따라 명령어 필터링
+ useEffect(() => {
+ if (!show || !input.startsWith('/')) {
+ setFilteredCommands([])
+ setSelectedIndex(0)
+ return
+ }
+
+ const commands = searchCommands(input)
+ setFilteredCommands(commands)
+ setSelectedIndex(0)
+ }, [input, show])
+
+ // 키보드 이벤트 처리
+ useEffect(() => {
+ const handleKeyDown = (e) => {
+ if (!show || filteredCommands.length === 0) return
+
+ if (e.key === 'ArrowDown') {
+ e.preventDefault()
+ setSelectedIndex((prev) => (prev + 1) % filteredCommands.length)
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault()
+ setSelectedIndex((prev) => (prev - 1 + filteredCommands.length) % filteredCommands.length)
+ } else if (e.key === 'Enter' && filteredCommands[selectedIndex]) {
+ e.preventDefault()
+ onSelect(filteredCommands[selectedIndex].command)
+ } else if (e.key === 'Escape') {
+ onSelect('')
+ }
+ }
+
+ window.addEventListener('keydown', handleKeyDown)
+ return () => window.removeEventListener('keydown', handleKeyDown)
+ }, [show, filteredCommands, selectedIndex, onSelect])
+
+ // 표시할 항목이 없으면 렌더링하지 않음
+ if (!show || filteredCommands.length === 0) {
+ return null
+ }
+
+ return (
+
+
+
+ 사용 가능한 명령어 ({filteredCommands.length})
+
+
+
+
+ {filteredCommands.map((cmd, index) => (
+
+ onSelect(cmd.command)}
+ sx={{
+ py: 1.5,
+ px: 2,
+ '&.Mui-selected': {
+ bgcolor: isDark ? 'rgba(250, 204, 21, 0.2)' : 'rgba(254, 229, 0, 0.3)',
+ '&:hover': {
+ bgcolor: isDark ? 'rgba(250, 204, 21, 0.25)' : 'rgba(254, 229, 0, 0.4)',
+ },
+ },
+ '&:hover': {
+ bgcolor: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)',
+ },
+ }}
+ >
+
+
+ {cmd.command}
+
+
+ {cmd.usage}
+
+
+ }
+ secondary={
+
+ {cmd.description}
+
+ }
+ />
+
+
+ ))}
+
+
+
+
+ 화살표로 선택, Enter로 입력, Esc로 닫기
+
+
+
+ )
+}
+
+export default CommandAutocomplete
diff --git a/src/domains/freetalk/components/PollCard.jsx b/src/domains/freetalk/components/PollCard.jsx
new file mode 100644
index 0000000..65e5bd6
--- /dev/null
+++ b/src/domains/freetalk/components/PollCard.jsx
@@ -0,0 +1,175 @@
+import { useState } from 'react'
+import { Box, Button, Card, CardContent, Chip, Typography, IconButton } from '@mui/material'
+import {
+ HowToVote as VoteIcon,
+ CheckCircle as CheckIcon,
+ Cancel as CancelIcon,
+ Person as PersonIcon,
+} from '@mui/icons-material'
+import { useThemeMode } from '../../../contexts/ThemeContext'
+import { calculatePollResults } from '../types/chatCommandTypes'
+import PollResultBar from './PollResultBar'
+
+/**
+ * 투표 카드 컴포넌트
+ * 투표 생성, 투표하기, 결과 보기 기능을 제공합니다.
+ *
+ * @param {Object} props
+ * @param {Object} props.poll - 투표 데이터
+ * @param {string} props.currentUserId - 현재 사용자 ID
+ * @param {function} props.onVote - 투표 시 호출되는 콜백
+ * @param {function} props.onEndPoll - 투표 종료 시 호출되는 콜백
+ * @returns {JSX.Element}
+ */
+const PollCard = ({ poll, currentUserId, onVote, onEndPoll }) => {
+ const { mode } = useThemeMode()
+ const isDark = mode === 'dark'
+ const [selectedOption, setSelectedOption] = useState(null)
+
+ const { totalVotes, percentages } = calculatePollResults(poll.options)
+ const hasVoted = poll.options.some((opt) => opt.voters.includes(currentUserId))
+ const isCreator = poll.creatorId === currentUserId
+ const showResults = !poll.isActive || hasVoted
+
+ const handleVote = (optionId) => {
+ if (!poll.isActive || hasVoted) return
+ setSelectedOption(optionId)
+ onVote?.(poll.pollId, optionId)
+ }
+
+ const handleEndPoll = () => {
+ onEndPoll?.(poll.pollId)
+ }
+
+ return (
+
+
+ {/* 헤더 */}
+
+
+
+ 투표
+
+ {poll.isActive ? (
+
+ ) : (
+
+ )}
+
+
+ {/* 질문 */}
+
+ {poll.question}
+
+
+ {/* 옵션 목록 */}
+
+ {poll.options.map((option, index) => {
+ const percentage = percentages[index]
+ const isSelected = selectedOption === option.optionId
+ const userVoted = option.voters.includes(currentUserId)
+
+ return (
+
+ {showResults ? (
+ // 결과 보기 모드
+
+
+
+
+ {option.text}
+
+ {userVoted && }
+
+
+ {option.voteCount}표 ({percentage}%)
+
+
+
+
+ ) : (
+ // 투표하기 모드
+
+ )}
+
+ )
+ })}
+
+
+ {/* 하단 정보 */}
+
+
+
+
+ 총 {totalVotes}명 참여
+
+
+
+ {isCreator && poll.isActive && (
+
+
+
+ )}
+
+
+ {/* 생성자 정보 */}
+
+ 생성자: {poll.creatorId}
+
+
+
+ )
+}
+
+export default PollCard
diff --git a/src/domains/freetalk/components/PollResultBar.jsx b/src/domains/freetalk/components/PollResultBar.jsx
new file mode 100644
index 0000000..bd46201
--- /dev/null
+++ b/src/domains/freetalk/components/PollResultBar.jsx
@@ -0,0 +1,57 @@
+import { Box, LinearProgress } from '@mui/material'
+import { useThemeMode } from '../../../contexts/ThemeContext'
+
+/**
+ * 투표 결과 진행바 컴포넌트
+ * 투표 옵션의 득표율을 시각적으로 표시합니다.
+ *
+ * @param {Object} props
+ * @param {number} props.percentage - 득표율 (0-100)
+ * @param {boolean} props.isUserVoted - 사용자가 해당 옵션에 투표했는지 여부
+ * @returns {JSX.Element}
+ */
+const PollResultBar = ({ percentage, isUserVoted = false }) => {
+ const { mode } = useThemeMode()
+ const isDark = mode === 'dark'
+
+ return (
+
+
+ {isUserVoted && (
+
+ )}
+
+ )
+}
+
+export default PollResultBar
diff --git a/src/domains/freetalk/components/SystemCommandMessage.jsx b/src/domains/freetalk/components/SystemCommandMessage.jsx
new file mode 100644
index 0000000..253382f
--- /dev/null
+++ b/src/domains/freetalk/components/SystemCommandMessage.jsx
@@ -0,0 +1,211 @@
+import { Box, Paper, Typography } from '@mui/material'
+import { useThemeMode } from '../../../contexts/ThemeContext'
+import { SystemCommandConfig } from '../types/chatCommandTypes'
+
+/**
+ * 시스템 명령어 메시지 컴포넌트
+ * /dice, /coin, /random, /members, /help 등의 명령어 결과를 표시합니다.
+ *
+ * @param {Object} props
+ * @param {Object} props.data - 시스템 명령어 데이터
+ * @param {string} props.data.commandType - 명령어 타입
+ * @param {string} props.data.userId - 실행한 사용자 ID
+ * @param {Object} props.data.result - 명령어 결과
+ * @param {string} props.data.displayText - 표시할 텍스트
+ * @returns {JSX.Element}
+ */
+const SystemCommandMessage = ({ data }) => {
+ const { mode } = useThemeMode()
+ const isDark = mode === 'dark'
+
+ // 명령어 타입 추출
+ const commandType = data?.commandType || data?.type || data?.raw?.type || 'help'
+ const config = SystemCommandConfig[commandType] || SystemCommandConfig.help
+ const { icon, color, bgColor } = config
+
+ // 표시 텍스트 추출
+ const displayText = data?.displayText || data?.message || data?.content || ''
+ const userId = data?.userId || data?.nickname || ''
+
+ // 결과 값 추출
+ const result = data?.result || data?.raw || {}
+
+ return (
+
+
+
+ {/* 아이콘 */}
+
+ {icon}
+
+
+ {/* 내용 */}
+
+ {/* displayText가 있으면 그대로 표시 (백엔드에서 이미 포맷팅됨) */}
+ {displayText ? (
+
+ {displayText}
+
+ ) : (
+ <>
+
+
+ {userId}
+
+
+ {/* 추가 결과 정보 */}
+ {renderCommandResult(commandType, result, isDark)}
+ >
+ )}
+
+
+
+
+ )
+}
+
+/**
+ * 명령어 타입별 결과 렌더링
+ */
+function renderCommandResult(commandType, result, isDark) {
+ if (!result) return null
+
+ switch (commandType) {
+ case 'dice':
+ return (
+
+ {result.value}
+
+ )
+
+ case 'coin':
+ return (
+
+ {result.side === 'heads' ? '앞면' : '뒷면'}
+
+ )
+
+ case 'random':
+ return (
+
+ {result.value}
+ {result.min !== undefined && result.max !== undefined && (
+
+ ({result.min}-{result.max})
+
+ )}
+
+ )
+
+ case 'members':
+ return result.memberIds ? (
+
+
+ 참여 중인 멤버 ({result.totalCount}명):
+
+
+ {result.memberIds.map((memberId) => (
+
+ {memberId}
+
+ ))}
+
+
+ ) : null
+
+ case 'help':
+ return result.commands ? (
+
+
+ 사용 가능한 명령어:
+
+
+ {result.commands.map((cmd) => (
+
+ {cmd.command} - {cmd.description}
+
+ ))}
+
+
+ ) : null
+
+ default:
+ return null
+ }
+}
+
+export default SystemCommandMessage
diff --git a/src/domains/freetalk/hooks/useChatWebSocket.js b/src/domains/freetalk/hooks/useChatWebSocket.js
index 7afd702..adc8037 100644
--- a/src/domains/freetalk/hooks/useChatWebSocket.js
+++ b/src/domains/freetalk/hooks/useChatWebSocket.js
@@ -1,6 +1,6 @@
-import {useCallback, useEffect, useRef, useState} from 'react'
-import {chatWebSocketService} from '../services/chatWebSocketService'
-import {chatRoomService} from '../../chat/services/chatService'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { chatWebSocketService } from '../services/chatWebSocketService'
+import { chatRoomService } from '../../chat/services/chatService'
/**
* Chat WebSocket 훅
@@ -26,10 +26,10 @@ export function useChatWebSocket(roomId, userId) {
* WebSocket 연결
*/
const connect = useCallback(async (forceNewToken = false) => {
- console.log('[useChatWebSocket] Attempting to connect...', {roomId, userId, forceNewToken})
+ console.log('[useChatWebSocket] Attempting to connect...', { roomId, userId, forceNewToken })
if (!roomId || !userId) {
- console.error('[useChatWebSocket] roomId and userId are required', {roomId, userId})
+ console.error('[useChatWebSocket] roomId and userId are required', { roomId, userId })
return
}
@@ -74,9 +74,12 @@ export function useChatWebSocket(roomId, userId) {
id: messageId,
content: data.content,
userId: data.userId,
+ nickname: data.nickname,
messageType: data.messageType || 'TEXT',
createdAt: data.createdAt || new Date().toISOString(),
isOwn: data.userId === userId,
+ // 추가 데이터 (투표, 시스템 명령어 등)
+ data: data.data || data.payload,
}
setMessages((prev) => {
// 중복 메시지 방지 (같은 messageId가 이미 있으면 추가하지 않음)
@@ -87,6 +90,101 @@ export function useChatWebSocket(roomId, userId) {
})
},
+ onPollCreate: (data) => {
+ console.log('[useChatWebSocket] Poll created:', data)
+ const pollData = data.data || data
+ const pollMessage = {
+ id: `poll-${pollData.pollId}`,
+ messageType: 'POLL_CREATE',
+ userId: pollData.creatorId,
+ createdAt: pollData.createdAt || new Date().toISOString(),
+ isOwn: pollData.creatorId === userId,
+ data: pollData,
+ }
+ setMessages((prev) => [...prev, pollMessage])
+ },
+
+ onPollVote: (data) => {
+ console.log('[useChatWebSocket] Poll vote:', data)
+ const voteData = data.data || data
+ setMessages((prev) =>
+ prev.map((msg) => {
+ if (msg.id === `poll-${voteData.pollId}` && msg.data) {
+ return {
+ ...msg,
+ data: {
+ ...msg.data,
+ options: voteData.updatedOptions || msg.data.options,
+ },
+ }
+ }
+ return msg
+ })
+ )
+ },
+
+ onPollEnd: (data) => {
+ console.log('[useChatWebSocket] Poll ended:', data)
+ const endData = data.data || data
+ setMessages((prev) =>
+ prev.map((msg) => {
+ if (msg.id === `poll-${endData.pollId}` && msg.data) {
+ return {
+ ...msg,
+ data: {
+ ...msg.data,
+ isActive: false,
+ options: endData.finalResults || msg.data.options,
+ },
+ }
+ }
+ return msg
+ })
+ )
+ },
+
+ onClearChat: (data) => {
+ console.log('[useChatWebSocket] Clear chat:', data)
+ const clearData = data.data || data
+ // 특정 사용자의 메시지만 삭제
+ setMessages((prev) =>
+ prev.filter((msg) => !clearData.messageIds?.includes(msg.id))
+ )
+ },
+
+ onLeaveRoom: (data) => {
+ console.log('[useChatWebSocket] User left room:', data)
+ const leaveData = data.data || data
+ const systemMessage = {
+ id: `leave-${Date.now()}`,
+ content: `${leaveData.userId}님이 채팅방을 나갔습니다.`,
+ messageType: 'SYSTEM',
+ createdAt: leaveData.leftAt || new Date().toISOString(),
+ isSystem: true,
+ }
+ setMessages((prev) => [...prev, systemMessage])
+ },
+
+ onSystemCommand: (data) => {
+ const commandData = data.data || {}
+ const commandMessage = {
+ id: `syscmd-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
+ messageType: 'SYSTEM_COMMAND',
+ userId: commandData.userId || commandData.nickname || data.userId,
+ createdAt: data.createdAt || new Date().toISOString(),
+ data: {
+ commandType: commandData.type || 'help',
+ userId: commandData.userId || commandData.nickname,
+ displayText: data.content || data.message || '',
+ result: typeof commandData.result === 'object'
+ ? commandData.result
+ : { value: commandData.result },
+ raw: commandData,
+ },
+ }
+ setMessages((prev) => [...prev, commandMessage])
+ },
+
onGameStart: (data) => {
console.log('[useChatWebSocket] Game started - FULL DATA:', JSON.stringify(data, null, 2))
// 실제 게임 데이터 추출 (data.data에 중첩되어 있을 수 있음)
@@ -117,6 +215,97 @@ export function useChatWebSocket(roomId, userId) {
}))
},
+ // 끝말잇기 게임 시작
+ onWordchainStart: (data) => {
+ console.log('[useChatWebSocket] Wordchain started - FULL DATA:', JSON.stringify(data, null, 2))
+ const gameData = data.data || data
+ // 서버 필드명 매핑: starterWord->currentWord, currentPlayerId->currentTurnUserId, timeLimit->turnTimeLimit
+ const wordchainState = {
+ status: 'PLAYING',
+ gameType: 'WORDCHAIN',
+ currentTurnUserId: gameData.currentPlayerId || gameData.currentTurnUserId,
+ currentWord: gameData.starterWord || gameData.currentWord,
+ nextLetter: gameData.nextLetter,
+ turnTimeLimit: gameData.timeLimit || gameData.turnTimeLimit || 15,
+ turnStartTime: gameData.turnStartTime || Date.now(),
+ scores: gameData.scores || {},
+ players: gameData.players || gameData.activePlayers || [],
+ }
+ setGameState(wordchainState)
+ // PlayPage에서 사용할 수 있도록 sessionStorage에 저장
+ sessionStorage.setItem(`wordchainState_${roomId}`, JSON.stringify(wordchainState))
+ },
+
+ // 끝말잇기 정답
+ onWordchainCorrect: (data) => {
+ console.log('[useChatWebSocket] Wordchain correct:', data)
+ const correctData = data.data || data
+ // 서버 필드명 매핑: word->currentWord, nextPlayerId->currentTurnUserId, nextTimeLimit->turnTimeLimit
+ setGameState((prev) => {
+ const newState = {
+ ...prev,
+ currentWord: correctData.word || correctData.currentWord,
+ nextLetter: correctData.nextLetter || prev?.nextLetter,
+ currentTurnUserId: correctData.nextPlayerId || correctData.nextTurnUserId,
+ turnTimeLimit: correctData.nextTimeLimit || prev?.turnTimeLimit,
+ turnStartTime: correctData.turnStartTime || Date.now(),
+ scores: correctData.scores || prev?.scores,
+ usedWords: prev?.usedWords ? [...prev.usedWords, correctData.word] : [correctData.word],
+ }
+ // sessionStorage 업데이트
+ sessionStorage.setItem(`wordchainState_${roomId}`, JSON.stringify(newState))
+ return newState
+ })
+ },
+
+ // 끝말잇기 오답
+ onWordchainWrong: (data) => {
+ console.log('[useChatWebSocket] Wordchain wrong:', data)
+ },
+
+ // 끝말잇기 타임아웃
+ onWordchainTimeout: (data) => {
+ console.log('[useChatWebSocket] Wordchain timeout:', data)
+ const timeoutData = data.data || data
+ // 서버 필드명 매핑: nextPlayerId->currentTurnUserId, nextTimeLimit->turnTimeLimit
+ setGameState((prev) => {
+ const newState = {
+ ...prev,
+ currentTurnUserId: timeoutData.nextPlayerId || timeoutData.nextTurnUserId,
+ nextLetter: timeoutData.nextLetter || prev?.nextLetter,
+ turnTimeLimit: timeoutData.nextTimeLimit || prev?.turnTimeLimit,
+ turnStartTime: timeoutData.turnStartTime || Date.now(),
+ players: timeoutData.activePlayers || prev?.players,
+ }
+ // sessionStorage 업데이트
+ sessionStorage.setItem(`wordchainState_${roomId}`, JSON.stringify(newState))
+ return newState
+ })
+ },
+
+ // 끝말잇기 게임 종료
+ onWordchainEnd: (data) => {
+ console.log('[useChatWebSocket] Wordchain ended:', data)
+ const endData = data.data || data
+ setGameState((prev) => {
+ const newState = {
+ ...prev,
+ status: 'FINISHED',
+ winner: endData.winnerId ? {
+ id: endData.winnerId,
+ nickname: endData.winnerNickname,
+ } : null,
+ ranking: endData.ranking,
+ finalScores: endData.scores || prev?.scores,
+ usedWords: endData.usedWords || prev?.usedWords,
+ wordDefinitions: endData.wordDefinitions || {},
+ }
+ // sessionStorage 업데이트
+ sessionStorage.setItem(`wordchainState_${roomId}`, JSON.stringify(newState))
+ return newState
+ })
+ },
+
onRoundStart: (data) => {
console.log('[useChatWebSocket] Round started - FULL DATA:', JSON.stringify(data, null, 2))
// 실제 라운드 데이터 추출 (data.data에 중첩되어 있을 수 있음)
diff --git a/src/domains/freetalk/pages/ChatRoomPage.jsx b/src/domains/freetalk/pages/ChatRoomPage.jsx
index cc80e6e..781a891 100644
--- a/src/domains/freetalk/pages/ChatRoomPage.jsx
+++ b/src/domains/freetalk/pages/ChatRoomPage.jsx
@@ -1,5 +1,5 @@
-import {useCallback, useEffect, useRef, useState} from 'react'
-import {useNavigate, useParams} from 'react-router-dom'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
import {
Alert,
AppBar,
@@ -21,27 +21,31 @@ import {
Send as SendIcon,
VolumeUp as VolumeUpIcon,
} from '@mui/icons-material'
-import {chatRoomService, messageService, voiceService} from '../../chat/services/chatService'
-import {useAuth} from '../../../contexts/AuthContext'
-import {useChatWebSocket} from '../hooks/useChatWebSocket'
-import {useThemeMode} from '../../../contexts/ThemeContext'
+import { chatRoomService, messageService, voiceService } from '../../chat/services/chatService'
+import { useAuth } from '../../../contexts/AuthContext'
+import { useChatWebSocket } from '../hooks/useChatWebSocket'
+import { useThemeMode } from '../../../contexts/ThemeContext'
+import CommandAutocomplete from '../components/CommandAutocomplete'
+import PollCard from '../components/PollCard'
+import SystemCommandMessage from '../components/SystemCommandMessage'
+import { parseCommand, MessageType } from '../types/chatCommandTypes'
const levelColors = {
- beginner: {bg: '#e8f5e9', color: '#2e7d32', label: '초급'},
- intermediate: {bg: '#fff3e0', color: '#ef6c00', label: '중급'},
- advanced: {bg: '#fce4ec', color: '#c2185b', label: '고급'},
+ beginner: { bg: '#e8f5e9', color: '#2e7d32', label: '초급' },
+ intermediate: { bg: '#fff3e0', color: '#ef6c00', label: '중급' },
+ advanced: { bg: '#fce4ec', color: '#c2185b', label: '고급' },
}
const ChatRoomPage = () => {
- const {roomId} = useParams()
+ const { roomId } = useParams()
const navigate = useNavigate()
- const {user} = useAuth()
- const {mode} = useThemeMode()
+ const { user } = useAuth()
+ const { mode } = useThemeMode()
const isDark = mode === 'dark'
const currentUserId = user?.userId || user?.username || user?.sub
// 디버깅: 사용자 정보 확인
- console.log('[ChatRoomPage] User info:', {user, currentUserId, roomId})
+ console.log('[ChatRoomPage] User info:', { user, currentUserId, roomId })
const messagesEndRef = useRef(null)
const messagesContainerRef = useRef(null)
@@ -51,6 +55,7 @@ const ChatRoomPage = () => {
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [playingTTS, setPlayingTTS] = useState(null)
+ const [showCommandAutocomplete, setShowCommandAutocomplete] = useState(false)
// WebSocket 훅 사용 (채팅방에서는 게임 관련 기능 제외)
const {
@@ -85,11 +90,12 @@ const ChatRoomPage = () => {
// 기존 메시지 목록 조회 (초기 로드용)
const fetchMessages = useCallback(async () => {
try {
- const response = await messageService.getList(roomId, {limit: 50})
+ const response = await messageService.getList(roomId, { limit: 50 })
const transformedMessages = (response.messages || []).map((msg, index) => ({
id: msg.messageId || `msg-${index}-${Date.now()}`,
content: msg.content,
userId: msg.userId,
+ nickname: msg.nickname,
messageType: msg.messageType,
createdAt: new Date(msg.createdAt),
isOwn: msg.userId === currentUserId,
@@ -117,12 +123,12 @@ const ChatRoomPage = () => {
// WebSocket 연결 (별도 effect)
useEffect(() => {
- console.log('[ChatRoomPage] WebSocket effect triggered:', {roomId, currentUserId, isConnected})
+ console.log('[ChatRoomPage] WebSocket effect triggered:', { roomId, currentUserId, isConnected })
if (currentUserId && roomId) {
- console.log('[ChatRoomPage] Connecting WebSocket...', {roomId, currentUserId})
+ console.log('[ChatRoomPage] Connecting WebSocket...', { roomId, currentUserId })
wsConnect()
} else {
- console.log('[ChatRoomPage] Missing required values:', {roomId, currentUserId})
+ console.log('[ChatRoomPage] Missing required values:', { roomId, currentUserId })
}
return () => {
@@ -133,7 +139,7 @@ const ChatRoomPage = () => {
// 스크롤 맨 아래로
const scrollToBottom = () => {
- messagesEndRef.current?.scrollIntoView({behavior: 'smooth'})
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
useEffect(() => {
@@ -153,6 +159,19 @@ const ChatRoomPage = () => {
const messageContent = newMessage.trim()
setNewMessage('')
+ setShowCommandAutocomplete(false)
+
+ // 명령어 파싱
+ const { isCommand, command, args } = parseCommand(messageContent)
+
+ // /leave 명령어는 클라이언트에서 직접 처리
+ if (isCommand && command === '/leave') {
+ handleLeaveRoom()
+ return
+ }
+
+ // /clear 명령어는 서버로 전송 (서버에서 처리)
+ // 나머지 명령어들도 서버로 전송하여 처리
// WebSocket으로 전송
if (isConnected) {
@@ -219,6 +238,41 @@ const ChatRoomPage = () => {
}
}
+ // 투표하기
+ const handleVote = (pollId, optionId) => {
+ if (isConnected) {
+ wsSendMessage(`/vote ${pollId} ${optionId}`, 'TEXT')
+ }
+ }
+
+ // 투표 종료
+ const handleEndPoll = (pollId) => {
+ if (isConnected) {
+ wsSendMessage(`/endpoll ${pollId}`, 'TEXT')
+ }
+ }
+
+ // 명령어 자동완성 선택
+ const handleCommandSelect = (command) => {
+ if (command) {
+ setNewMessage(command + ' ')
+ }
+ setShowCommandAutocomplete(false)
+ }
+
+ // 입력값 변경 처리
+ const handleInputChange = (e) => {
+ const value = e.target.value
+ setNewMessage(value)
+
+ // "/" 입력 시 자동완성 표시
+ if (value.startsWith('/') && value.length > 0) {
+ setShowCommandAutocomplete(true)
+ } else {
+ setShowCommandAutocomplete(false)
+ }
+ }
+
// 새로고침
const handleRefresh = () => {
fetchMessages()
@@ -240,23 +294,23 @@ const ChatRoomPage = () => {
if (loading) {
return (
-
-
+
+
)
}
return (
-
+
{/* 헤더 */}
- navigate('/freetalk/people')} sx={{mr: 1}}>
-
+ navigate('/freetalk/people')} sx={{ mr: 1 }}>
+
-
-
-
+
+
+
{room?.name || '채팅방'}
{/* 연결 상태 표시 */}
@@ -267,7 +321,7 @@ const ChatRoomPage = () => {
}}
/>
-
+
{room?.level && (
{
-
+
-
+
{/* 에러 메시지 */}
{error && (
-
+
{error}
)}
@@ -317,108 +371,132 @@ const ChatRoomPage = () => {
}}
>
{messages.length === 0 ? (
-
+
아직 메시지가 없습니다. 첫 메시지를 보내보세요!
) : (
- messages.map((message) => (
-
- {/* 시스템 메시지 */}
- {message.isSystem ? (
-
-
- {message.content}
-
+ messages.map((message) => {
+ // 투표 메시지
+ if (message.messageType === MessageType.POLL_CREATE) {
+ return (
+
+
- ) : (
- <>
- {/* 아바타 (상대방만) */}
- {!message.isOwn && (
-
- {message.userId?.charAt(0)?.toUpperCase() || 'U'}
-
- )}
-
-
- {/* 사용자 이름 (상대방만) */}
+ )
+ }
+
+ // 시스템 명령어 메시지
+ if (message.messageType === MessageType.SYSTEM_COMMAND) {
+ return (
+
+ )
+ }
+
+ // 일반 메시지
+ return (
+
+ {/* 시스템 메시지 */}
+ {message.isSystem ? (
+
+
+ {message.content}
+
+
+ ) : (
+ <>
+ {/* 아바타 (상대방만) */}
{!message.isOwn && (
-
- {message.userId}
-
+
+ {(message.nickname || message.userId)?.charAt(0)?.toUpperCase() || 'U'}
+
)}
- {/* 메시지 버블 */}
-
- {message.isOwn && (
-
- {formatTime(message.createdAt)}
+
+ {/* 사용자 이름 (상대방만) */}
+ {!message.isOwn && (
+
+ {message.nickname || message.userId}
)}
-
-
- {message.content}
-
-
-
- {!message.isOwn && (
-
- handlePlayTTS(message.id)}
- disabled={playingTTS === message.id}
- sx={{p: 0.5}}
- >
- {playingTTS === message.id ? (
-
- ) : (
-
- )}
-
+ {/* 메시지 버블 */}
+
+ {message.isOwn && (
{formatTime(message.createdAt)}
-
- )}
+ )}
+
+
+
+ {message.content}
+
+
+
+ {!message.isOwn && (
+
+ handlePlayTTS(message.id)}
+ disabled={playingTTS === message.id}
+ sx={{ p: 0.5 }}
+ >
+ {playingTTS === message.id ? (
+
+ ) : (
+
+ )}
+
+
+ {formatTime(message.createdAt)}
+
+
+ )}
+
-
- >
- )}
-
- ))
+ >
+ )}
+
+ )
+ })
)}
-
+
{/* 입력 영역 */}
@@ -430,13 +508,21 @@ const ChatRoomPage = () => {
alignItems: 'center',
gap: 1,
borderRadius: 0,
+ position: 'relative',
}}
>
+ {/* 명령어 자동완성 */}
+
+
setNewMessage(e.target.value)}
+ onChange={handleInputChange}
onKeyPress={handleKeyPress}
size="small"
multiline
@@ -454,11 +540,11 @@ const ChatRoomPage = () => {
sx={{
bgcolor: 'primary.main',
color: 'white',
- '&:hover': {bgcolor: 'primary.dark'},
- '&:disabled': {bgcolor: 'grey.300'},
+ '&:hover': { bgcolor: 'primary.dark' },
+ '&:disabled': { bgcolor: 'grey.300' },
}}
>
-
+
diff --git a/src/domains/freetalk/services/chatWebSocketService.js b/src/domains/freetalk/services/chatWebSocketService.js
index f4d042c..f8fbe75 100644
--- a/src/domains/freetalk/services/chatWebSocketService.js
+++ b/src/domains/freetalk/services/chatWebSocketService.js
@@ -177,8 +177,33 @@ class ChatWebSocketConnection {
break
case 'system_command':
case 'SYSTEM_COMMAND':
- // 시스템 명령어 응답 (예: /member, /help 등)
- this.callbacks.onMessage?.(data)
+ // 시스템 명령어 응답 (예: /dice, /coin, /random, /members, /help 등)
+ this.callbacks.onSystemCommand?.(data)
+ break
+ case 'poll_create':
+ case 'POLL_CREATE':
+ console.log('[ChatWebSocket] Poll create received:', data)
+ this.callbacks.onPollCreate?.(data)
+ break
+ case 'poll_vote':
+ case 'POLL_VOTE':
+ console.log('[ChatWebSocket] Poll vote received:', data)
+ this.callbacks.onPollVote?.(data)
+ break
+ case 'poll_end':
+ case 'POLL_END':
+ console.log('[ChatWebSocket] Poll end received:', data)
+ this.callbacks.onPollEnd?.(data)
+ break
+ case 'clear_chat':
+ case 'CLEAR_CHAT':
+ console.log('[ChatWebSocket] Clear chat received:', data)
+ this.callbacks.onClearChat?.(data)
+ break
+ case 'leave_room':
+ case 'LEAVE_ROOM':
+ console.log('[ChatWebSocket] Leave room received:', data)
+ this.callbacks.onLeaveRoom?.(data)
break
case 'error':
case 'ERROR':
@@ -188,6 +213,31 @@ class ChatWebSocketConnection {
// 추측 메시지 - 일반 메시지로 처리
this.callbacks.onMessage?.(data)
break
+ case 'wordchain_start':
+ case 'WORDCHAIN_START':
+ console.log('[ChatWebSocket] Wordchain start received:', data)
+ this.callbacks.onWordchainStart?.(data)
+ break
+ case 'wordchain_correct':
+ case 'WORDCHAIN_CORRECT':
+ console.log('[ChatWebSocket] Wordchain correct received:', data)
+ this.callbacks.onWordchainCorrect?.(data)
+ break
+ case 'wordchain_wrong':
+ case 'WORDCHAIN_WRONG':
+ console.log('[ChatWebSocket] Wordchain wrong received:', data)
+ this.callbacks.onWordchainWrong?.(data)
+ break
+ case 'wordchain_timeout':
+ case 'WORDCHAIN_TIMEOUT':
+ console.log('[ChatWebSocket] Wordchain timeout received:', data)
+ this.callbacks.onWordchainTimeout?.(data)
+ break
+ case 'wordchain_end':
+ case 'WORDCHAIN_END':
+ console.log('[ChatWebSocket] Wordchain end received:', data)
+ this.callbacks.onWordchainEnd?.(data)
+ break
default:
console.log('[ChatWebSocket] Unknown message type:', type || messageType, data)
this.callbacks.onMessage?.(data)
diff --git a/src/domains/freetalk/types/chatCommandTypes.js b/src/domains/freetalk/types/chatCommandTypes.js
new file mode 100644
index 0000000..73d87c6
--- /dev/null
+++ b/src/domains/freetalk/types/chatCommandTypes.js
@@ -0,0 +1,219 @@
+/**
+ * 채팅 메시지 타입 상수
+ * @enum {string}
+ */
+export const MessageType = {
+ TEXT: 'TEXT',
+ IMAGE: 'IMAGE',
+ VOICE: 'VOICE',
+ SYSTEM_COMMAND: 'SYSTEM_COMMAND',
+ POLL_CREATE: 'POLL_CREATE',
+ POLL_VOTE: 'POLL_VOTE',
+ POLL_END: 'POLL_END',
+ CLEAR_CHAT: 'CLEAR_CHAT',
+ LEAVE_ROOM: 'LEAVE_ROOM',
+}
+
+/**
+ * 사용 가능한 채팅 명령어 목록
+ */
+export const COMMANDS = [
+ {
+ command: '/help',
+ description: '사용 가능한 명령어 목록 보기',
+ usage: '/help',
+ },
+ {
+ command: '/members',
+ description: '현재 참여 중인 멤버 목록 보기',
+ usage: '/members',
+ },
+ {
+ command: '/poll',
+ description: '투표 생성하기',
+ usage: '/poll [질문] [옵션1] [옵션2] ...',
+ },
+ {
+ command: '/vote',
+ description: '투표하기',
+ usage: '/vote [투표ID] [옵션번호]',
+ },
+ {
+ command: '/endpoll',
+ description: '투표 종료하기',
+ usage: '/endpoll [투표ID]',
+ },
+ {
+ command: '/clear',
+ description: '내 메시지 모두 삭제',
+ usage: '/clear',
+ },
+ {
+ command: '/leave',
+ description: '채팅방 나가기',
+ usage: '/leave',
+ },
+ {
+ command: '/dice',
+ description: '주사위 굴리기 (1-6)',
+ usage: '/dice',
+ },
+ {
+ command: '/coin',
+ description: '동전 던지기 (앞면/뒷면)',
+ usage: '/coin',
+ },
+ {
+ command: '/random',
+ description: '무작위 숫자 생성',
+ usage: '/random [최소값] [최대값]',
+ },
+]
+
+/**
+ * @typedef {Object} PollOption
+ * @property {number} optionId - 옵션 ID
+ * @property {string} text - 옵션 텍스트
+ * @property {number} voteCount - 투표 수
+ * @property {string[]} voters - 투표한 사용자 ID 목록
+ */
+
+/**
+ * @typedef {Object} PollCreateData
+ * @property {string} pollId - 투표 ID
+ * @property {string} question - 투표 질문
+ * @property {PollOption[]} options - 투표 옵션 목록
+ * @property {string} creatorId - 생성자 ID
+ * @property {string} createdAt - ISO-8601 생성 시간
+ * @property {boolean} isActive - 활성 상태
+ */
+
+/**
+ * @typedef {Object} PollVoteData
+ * @property {string} pollId - 투표 ID
+ * @property {number} optionId - 선택한 옵션 ID
+ * @property {string} userId - 투표한 사용자 ID
+ * @property {PollOption[]} updatedOptions - 업데이트된 옵션 목록
+ */
+
+/**
+ * @typedef {Object} PollEndData
+ * @property {string} pollId - 투표 ID
+ * @property {PollOption[]} finalResults - 최종 결과
+ * @property {string} endedBy - 종료한 사용자 ID
+ * @property {string} endedAt - ISO-8601 종료 시간
+ */
+
+/**
+ * @typedef {Object} SystemCommandData
+ * @property {string} commandType - 명령어 타입 (dice, coin, random, members, help)
+ * @property {string} userId - 명령어 실행 사용자 ID
+ * @property {Object} result - 명령어 실행 결과
+ * @property {string} displayText - 표시할 텍스트
+ */
+
+/**
+ * @typedef {Object} ClearChatData
+ * @property {string} userId - 삭제 요청 사용자 ID
+ * @property {string[]} messageIds - 삭제된 메시지 ID 목록
+ */
+
+/**
+ * @typedef {Object} LeaveRoomData
+ * @property {string} userId - 퇴장한 사용자 ID
+ * @property {string} roomId - 방 ID
+ * @property {string} leftAt - ISO-8601 퇴장 시간
+ */
+
+/**
+ * @typedef {Object} MembersListData
+ * @property {string[]} memberIds - 멤버 ID 목록
+ * @property {number} totalCount - 총 멤버 수
+ */
+
+/**
+ * 명령어 파싱 유틸리티
+ * @param {string} message - 입력된 메시지
+ * @returns {{isCommand: boolean, command: string, args: string[]}}
+ */
+export function parseCommand(message) {
+ const trimmed = message.trim()
+
+ if (!trimmed.startsWith('/')) {
+ return { isCommand: false, command: '', args: [] }
+ }
+
+ const parts = trimmed.split(/\s+/)
+ const command = parts[0].toLowerCase()
+ const args = parts.slice(1)
+
+ return {
+ isCommand: true,
+ command,
+ args,
+ }
+}
+
+/**
+ * 명령어가 유효한지 확인
+ * @param {string} command - 명령어 (예: '/help')
+ * @returns {boolean}
+ */
+export function isValidCommand(command) {
+ return COMMANDS.some(cmd => cmd.command === command.toLowerCase())
+}
+
+/**
+ * 명령어 검색 (자동완성용)
+ * @param {string} input - 사용자 입력
+ * @returns {Array} 일치하는 명령어 목록
+ */
+export function searchCommands(input) {
+ const lowerInput = input.toLowerCase()
+ return COMMANDS.filter(cmd => cmd.command.startsWith(lowerInput))
+}
+
+/**
+ * 투표 결과 계산
+ * @param {PollOption[]} options - 투표 옵션 목록
+ * @returns {{totalVotes: number, percentages: number[]}}
+ */
+export function calculatePollResults(options) {
+ const totalVotes = options.reduce((sum, opt) => sum + opt.voteCount, 0)
+ const percentages = options.map(opt =>
+ totalVotes > 0 ? Math.round((opt.voteCount / totalVotes) * 100) : 0
+ )
+
+ return { totalVotes, percentages }
+}
+
+/**
+ * 시스템 명령어 아이콘 및 색상 설정
+ */
+export const SystemCommandConfig = {
+ dice: {
+ icon: '🎲',
+ color: '#8b5cf6',
+ bgColor: '#f5f3ff',
+ },
+ coin: {
+ icon: '🪙',
+ color: '#f59e0b',
+ bgColor: '#fffbeb',
+ },
+ random: {
+ icon: '🔢',
+ color: '#06b6d4',
+ bgColor: '#ecfeff',
+ },
+ members: {
+ icon: '👥',
+ color: '#10b981',
+ bgColor: '#ecfdf5',
+ },
+ help: {
+ icon: '❓',
+ color: '#6366f1',
+ bgColor: '#eef2ff',
+ },
+}
diff --git a/src/domains/games/components/CreateGameRoomModal.jsx b/src/domains/games/components/CreateGameRoomModal.jsx
index 4545f24..fc03238 100644
--- a/src/domains/games/components/CreateGameRoomModal.jsx
+++ b/src/domains/games/components/CreateGameRoomModal.jsx
@@ -22,14 +22,17 @@ const levelOptions = [
{ value: 'ADVANCED', label: '고급', color: '#EF4444' },
]
-const CreateGameRoomModal = ({ open, onClose, onCreate, loading }) => {
+const CreateGameRoomModal = ({ open, onClose, onCreate, loading, gameType = 'CATCHMIND' }) => {
+ const isWordchain = gameType === 'WORDCHAIN'
+
const [formData, setFormData] = useState({
name: '',
description: '',
level: 'BEGINNER',
- maxParticipants: 4,
+ maxParticipants: isWordchain ? 6 : 4,
maxRounds: 5,
roundTimeLimit: 60,
+ turnTimeLimit: 15, // 끝말잇기용
})
const handleChange = (field, value) => {
@@ -46,9 +49,10 @@ const CreateGameRoomModal = ({ open, onClose, onCreate, loading }) => {
name: '',
description: '',
level: 'BEGINNER',
- maxParticipants: 4,
+ maxParticipants: isWordchain ? 6 : 4,
maxRounds: 5,
roundTimeLimit: 60,
+ turnTimeLimit: 15,
})
onClose?.()
}
@@ -190,59 +194,96 @@ const CreateGameRoomModal = ({ open, onClose, onCreate, loading }) => {
/>
- {/* 라운드 수 */}
-
-
-
- 라운드 수
-
-
- {formData.maxRounds}라운드
-
+ {/* 캐치마인드: 라운드 수 */}
+ {!isWordchain && (
+
+
+
+ 라운드 수
+
+
+ {formData.maxRounds}라운드
+
+
+ handleChange('maxRounds', value)}
+ min={3}
+ max={10}
+ step={1}
+ marks
+ sx={{
+ color: GAME_COLORS.primary,
+ }}
+ />
- handleChange('maxRounds', value)}
- min={3}
- max={10}
- step={1}
- marks
- sx={{
- color: GAME_COLORS.primary,
- }}
- />
-
+ )}
- {/* 라운드 시간 */}
-
-
-
- 라운드 시간
-
-
- {formData.roundTimeLimit}초
-
+ {/* 캐치마인드: 라운드 시간 */}
+ {!isWordchain && (
+
+
+
+ 라운드 시간
+
+
+ {formData.roundTimeLimit}초
+
+
+ handleChange('roundTimeLimit', value)}
+ min={30}
+ max={120}
+ step={15}
+ marks={[
+ { value: 30, label: '30초' },
+ { value: 60, label: '60초' },
+ { value: 90, label: '90초' },
+ { value: 120, label: '120초' },
+ ]}
+ sx={{
+ color: GAME_COLORS.primary,
+ '& .MuiSlider-markLabel': {
+ fontSize: '0.65rem',
+ },
+ }}
+ />
- handleChange('roundTimeLimit', value)}
- min={30}
- max={120}
- step={15}
- marks={[
- { value: 30, label: '30초' },
- { value: 60, label: '60초' },
- { value: 90, label: '90초' },
- { value: 120, label: '120초' },
- ]}
- sx={{
- color: GAME_COLORS.primary,
- '& .MuiSlider-markLabel': {
- fontSize: '0.65rem',
- },
- }}
- />
-
+ )}
+
+ {/* 끝말잇기: 턴 시간 */}
+ {isWordchain && (
+
+
+
+ 턴 시간
+
+
+ {formData.turnTimeLimit}초
+
+
+ handleChange('turnTimeLimit', value)}
+ min={10}
+ max={30}
+ step={5}
+ marks={[
+ { value: 10, label: '10초' },
+ { value: 15, label: '15초' },
+ { value: 20, label: '20초' },
+ { value: 30, label: '30초' },
+ ]}
+ sx={{
+ color: GAME_COLORS.primary,
+ '& .MuiSlider-markLabel': {
+ fontSize: '0.65rem',
+ },
+ }}
+ />
+
+ )}
diff --git a/src/domains/games/components/wordchain/GameEndModal.jsx b/src/domains/games/components/wordchain/GameEndModal.jsx
new file mode 100644
index 0000000..5b47a89
--- /dev/null
+++ b/src/domains/games/components/wordchain/GameEndModal.jsx
@@ -0,0 +1,179 @@
+import {
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ Button,
+ Box,
+ Typography,
+} from '@mui/material'
+import {
+ EmojiEvents as TrophyIcon,
+ ExitToApp as ExitIcon,
+ Replay as ReplayIcon,
+} from '@mui/icons-material'
+import { GAME_COLORS, GAME_TYPOGRAPHY } from '../../theme/gameTheme'
+
+/**
+ * GameEndModal - 게임 종료 모달
+ * @param {Object} winner - { id, nickname } or { userId, nickname }
+ * @param {Array} finalPlayers - 백엔드 ranking 배열 또는 기존 players 배열
+ * - 백엔드: [{ playerId, nickname, score, eliminated }]
+ * - 기존: [{ userId, nickname, isAlive, wordsSubmitted }]
+ */
+const GameEndModal = ({ open, winner, finalPlayers, ranking, onRestart, onExit, currentUserId }) => {
+ // 백엔드 ranking 또는 기존 finalPlayers 사용
+ const players = ranking || finalPlayers || []
+
+ // 데이터 정규화 - 백엔드 형식과 기존 형식 모두 지원
+ const sortedPlayers = [...players]
+ .map(player => ({
+ userId: player.playerId || player.userId,
+ nickname: player.nickname || player.userId || player.playerId,
+ score: player.score || 0,
+ isAlive: player.eliminated !== undefined ? !player.eliminated : player.isAlive,
+ wordsSubmitted: player.wordsSubmitted || player.score || 0,
+ }))
+ .sort((a, b) => {
+ // 생존자가 우선
+ if (a.isAlive && !b.isAlive) return -1
+ if (!a.isAlive && b.isAlive) return 1
+ // 점수로 정렬
+ return (b.score || 0) - (a.score || 0)
+ })
+
+ // winner 정규화
+ const normalizedWinner = winner ? {
+ userId: winner.id || winner.userId || winner.playerId,
+ nickname: winner.nickname || winner.id || winner.userId,
+ } : null
+
+ return (
+
+ )
+}
+
+export default GameEndModal
diff --git a/src/domains/games/components/wordchain/PlayerList.jsx b/src/domains/games/components/wordchain/PlayerList.jsx
new file mode 100644
index 0000000..2e31812
--- /dev/null
+++ b/src/domains/games/components/wordchain/PlayerList.jsx
@@ -0,0 +1,89 @@
+import { Box, Typography, Avatar } from '@mui/material'
+import { CheckCircle as CheckIcon } from '@mui/icons-material'
+import { GAME_COLORS } from '../../theme/gameTheme'
+import { useThemeMode } from '../../../../contexts/ThemeContext'
+
+/**
+ * PlayerList - 플레이어 목록 (턴 & 생존 상태)
+ */
+const PlayerList = ({ players, currentTurnUserId, currentUserId }) => {
+ const { mode } = useThemeMode()
+ const isDark = mode === 'dark'
+
+ return (
+
+
+ 플레이어
+
+
+ {players.map((player) => {
+ const isCurrentTurn = player.userId === currentTurnUserId
+ const isMe = player.userId === currentUserId
+ const isAlive = player.isAlive !== false
+
+ return (
+
+
+ {player.nickname?.[0] || player.userId?.[0] || '?'}
+
+
+
+ {player.nickname || player.userId}
+ {isMe && ' (나)'}
+
+ {isCurrentTurn && (
+
+ 턴 진행 중
+
+ )}
+
+ {!isAlive && (
+
+ 탈락
+
+ )}
+ {isAlive && !isCurrentTurn && player.hasAnswered && (
+
+ )}
+
+ )
+ })}
+
+
+ )
+}
+
+export default PlayerList
diff --git a/src/domains/games/components/wordchain/UsedWordsList.jsx b/src/domains/games/components/wordchain/UsedWordsList.jsx
new file mode 100644
index 0000000..c366ec4
--- /dev/null
+++ b/src/domains/games/components/wordchain/UsedWordsList.jsx
@@ -0,0 +1,49 @@
+import { Box, Typography, Chip } from '@mui/material'
+import { GAME_COLORS } from '../../theme/gameTheme'
+
+/**
+ * UsedWordsList - 사용된 단어 목록
+ */
+const UsedWordsList = ({ words }) => {
+ return (
+
+
+ 사용된 단어 ({words.length})
+
+
+ {words.length === 0 ? (
+
+ 아직 사용된 단어가 없습니다
+
+ ) : (
+ words.map((wordData, index) => (
+
+ ))
+ )}
+
+
+ )
+}
+
+export default UsedWordsList
diff --git a/src/domains/games/components/wordchain/WordDisplay.jsx b/src/domains/games/components/wordchain/WordDisplay.jsx
new file mode 100644
index 0000000..38a310e
--- /dev/null
+++ b/src/domains/games/components/wordchain/WordDisplay.jsx
@@ -0,0 +1,73 @@
+import { Box, Typography } from '@mui/material'
+import { GAME_COLORS, GAME_TYPOGRAPHY } from '../../theme/gameTheme'
+import { useThemeMode } from '../../../../contexts/ThemeContext'
+
+/**
+ * WordDisplay - 현재 단어 & 다음 글자 표시
+ */
+const WordDisplay = ({ currentWord, nextLetter, isMyTurn }) => {
+ const { mode } = useThemeMode()
+ const isDark = mode === 'dark'
+
+ return (
+
+ {currentWord ? (
+ <>
+
+ 현재 단어
+
+
+ {currentWord}
+
+
+
+ 다음 시작 글자:
+
+
+ {nextLetter}
+
+
+ >
+ ) : (
+
+ 첫 단어를 기다리는 중...
+
+ )}
+
+ )
+}
+
+export default WordDisplay
diff --git a/src/domains/games/components/wordchain/WordInput.jsx b/src/domains/games/components/wordchain/WordInput.jsx
new file mode 100644
index 0000000..c6d9587
--- /dev/null
+++ b/src/domains/games/components/wordchain/WordInput.jsx
@@ -0,0 +1,96 @@
+import { useState } from 'react'
+import { Box, TextField, Button, Typography } from '@mui/material'
+import { Send as SendIcon } from '@mui/icons-material'
+import { GAME_COLORS } from '../../theme/gameTheme'
+
+/**
+ * WordInput - 단어 입력 필드
+ */
+const WordInput = ({ onSubmit, disabled, nextLetter, isMyTurn }) => {
+ const [word, setWord] = useState('')
+ const [error, setError] = useState('')
+
+ const handleSubmit = (e) => {
+ e.preventDefault()
+
+ const trimmedWord = word.trim().toLowerCase()
+
+ // 유효성 검사
+ if (!trimmedWord) {
+ setError('단어를 입력하세요')
+ return
+ }
+
+ if (nextLetter && trimmedWord[0] !== nextLetter.toLowerCase()) {
+ setError(`단어는 '${nextLetter}'로 시작해야 합니다`)
+ return
+ }
+
+ // 알파벳만 허용
+ if (!/^[a-z]+$/.test(trimmedWord)) {
+ setError('영어 알파벳만 입력하세요')
+ return
+ }
+
+ setError('')
+ onSubmit(trimmedWord)
+ setWord('')
+ }
+
+ const handleChange = (e) => {
+ setWord(e.target.value)
+ setError('')
+ }
+
+ return (
+
+ {!isMyTurn && (
+
+ 다른 플레이어의 턴입니다
+
+ )}
+
+
+ }
+ >
+ 제출
+
+
+
+ )
+}
+
+export default WordInput
diff --git a/src/domains/games/components/wordchain/WordchainTimer.jsx b/src/domains/games/components/wordchain/WordchainTimer.jsx
new file mode 100644
index 0000000..aac71cf
--- /dev/null
+++ b/src/domains/games/components/wordchain/WordchainTimer.jsx
@@ -0,0 +1,59 @@
+import { Box, CircularProgress, Typography } from '@mui/material'
+import { GAME_COLORS, GAME_TYPOGRAPHY } from '../../theme/gameTheme'
+
+/**
+ * WordchainTimer - 원형 타이머
+ */
+const WordchainTimer = ({ timeLeft, timeLimit }) => {
+ const percentage = (timeLeft / timeLimit) * 100
+ const isDanger = timeLeft <= 5
+
+ return (
+
+
+
+
+ {timeLeft}
+
+
+
+ )
+}
+
+export default WordchainTimer
diff --git a/src/domains/games/pages/WordchainLobbyPage.jsx b/src/domains/games/pages/WordchainLobbyPage.jsx
new file mode 100644
index 0000000..1298b07
--- /dev/null
+++ b/src/domains/games/pages/WordchainLobbyPage.jsx
@@ -0,0 +1,310 @@
+import { useCallback, useEffect, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import {
+ Alert,
+ Box,
+ Button,
+ Chip,
+ CircularProgress,
+ Container,
+ FormControlLabel,
+ Grid,
+ IconButton,
+ MenuItem,
+ Select,
+ Switch,
+ Typography,
+} from '@mui/material'
+import {
+ Add as AddIcon,
+ Link as LinkIcon,
+ Refresh as RefreshIcon,
+} from '@mui/icons-material'
+import GameRoomCard from '../components/GameRoomCard'
+import CreateGameRoomModal from '../components/CreateGameRoomModal'
+import { gameService } from '../services/gameService'
+import { GAME_COLORS } from '../theme/gameTheme'
+import { useAuth } from '../../../contexts/AuthContext'
+
+const WordchainLobbyPage = () => {
+ const navigate = useNavigate()
+ const { user } = useAuth()
+
+ const [rooms, setRooms] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [createModalOpen, setCreateModalOpen] = useState(false)
+ const [creating, setCreating] = useState(false)
+
+ // 필터
+ const [filters, setFilters] = useState({
+ level: '',
+ waitingOnly: true,
+ })
+
+ // 방 목록 조회 - gameType을 WORDCHAIN으로 필터링
+ const fetchRooms = useCallback(async () => {
+ try {
+ setLoading(true)
+ setError(null)
+
+ const params = {
+ gameType: 'WORDCHAIN',
+ }
+ if (filters.level) params.level = filters.level
+ if (filters.waitingOnly) params.status = 'WAITING'
+
+ const response = await gameService.getRooms(params)
+ setRooms(response.data.rooms || [])
+ } catch (err) {
+ console.error('Failed to fetch rooms:', err)
+ setError('방 목록을 불러오는데 실패했습니다')
+ } finally {
+ setLoading(false)
+ }
+ }, [filters])
+
+ useEffect(() => {
+ fetchRooms()
+ }, [fetchRooms])
+
+ // 방 생성
+ const handleCreateRoom = async (data) => {
+ try {
+ setCreating(true)
+ // gameType을 WORDCHAIN으로 설정
+ const response = await gameService.createRoom({
+ ...data,
+ gameType: 'WORDCHAIN',
+ })
+ setCreateModalOpen(false)
+ // 생성된 방의 대기실로 이동
+ navigate(`/games/wordchain/${response.data.roomId}/waiting`)
+ } catch (err) {
+ console.error('Failed to create room:', err)
+ setError('방 생성에 실패했습니다')
+ } finally {
+ setCreating(false)
+ }
+ }
+
+ // 방 참가
+ const handleJoinRoom = async (room) => {
+ try {
+ const response = await gameService.joinRoom(room.roomId)
+ // roomToken을 sessionStorage에 저장 (WebSocket 연결 시 사용)
+ if (response.data?.roomToken) {
+ sessionStorage.setItem(`roomToken_${room.roomId}`, response.data.roomToken)
+ }
+ navigate(`/games/wordchain/${room.roomId}/waiting`)
+ } catch (err) {
+ console.error('Failed to join room:', err)
+ setError(err.message || '방 참가에 실패했습니다')
+ }
+ }
+
+ // 관전
+ const handleSpectateRoom = (room) => {
+ navigate(`/games/wordchain/${room.roomId}/play?spectate=true`)
+ }
+
+ return (
+
+ {/* 헤더 */}
+
+
+
+
+
+
+
+ 끝말잇기
+
+
+ 영어 단어로 끝말잇기를 즐겨보세요
+
+
+
+
+
+ {/* 필터 + 액션 */}
+
+ {/* 필터 */}
+
+
+
+ setFilters(prev => ({ ...prev, waitingOnly: e.target.checked }))}
+ sx={{
+ '& .MuiSwitch-switchBase.Mui-checked': {
+ color: GAME_COLORS.primary,
+ },
+ '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': {
+ bgcolor: GAME_COLORS.primary,
+ },
+ }}
+ />
+ }
+ label={대기중만}
+ />
+
+
+
+
+
+
+
+
+ {/* 방 만들기 버튼 */}
+ }
+ 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}
+ gameType="WORDCHAIN"
+ />
+
+ )
+}
+
+export default WordchainLobbyPage
diff --git a/src/domains/games/pages/WordchainPlayPage.jsx b/src/domains/games/pages/WordchainPlayPage.jsx
new file mode 100644
index 0000000..f248609
--- /dev/null
+++ b/src/domains/games/pages/WordchainPlayPage.jsx
@@ -0,0 +1,477 @@
+import { useCallback, useEffect, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import {
+ Alert,
+ Box,
+ Button,
+ Chip,
+ CircularProgress,
+ Container,
+ Grid,
+ IconButton,
+ Typography,
+} from '@mui/material'
+import {
+ ExitToApp as ExitIcon,
+ Stop as StopIcon,
+} from '@mui/icons-material'
+import WordDisplay from '../components/wordchain/WordDisplay'
+import WordchainTimer from '../components/wordchain/WordchainTimer'
+import PlayerList from '../components/wordchain/PlayerList'
+import UsedWordsList from '../components/wordchain/UsedWordsList'
+import WordInput from '../components/wordchain/WordInput'
+import GameEndModal from '../components/wordchain/GameEndModal'
+import { gameService } from '../services/gameService'
+import wordchainService from '../services/wordchainService'
+import { GAME_COLORS } from '../theme/gameTheme'
+import { useAuth } from '../../../contexts/AuthContext'
+import { useThemeMode } from '../../../contexts/ThemeContext'
+import { useChatWebSocket } from '../../freetalk/hooks/useChatWebSocket'
+
+const WordchainPlayPage = () => {
+ const { roomId } = useParams()
+ const navigate = useNavigate()
+ const { user } = useAuth()
+ const { mode } = useThemeMode()
+ const isDark = mode === 'dark'
+ const currentUserId = user?.userId || user?.username || user?.sub
+
+ // WebSocket 연결
+ const {
+ isConnected,
+ gameState: wsGameState,
+ error: wsError,
+ connect,
+ disconnect,
+ } = useChatWebSocket(roomId, currentUserId)
+
+ // 로컬 게임 상태
+ const [gameState, setGameState] = useState({
+ status: 'PLAYING',
+ currentWord: null,
+ nextLetter: null,
+ currentTurnUserId: null,
+ turnStartTime: Date.now(),
+ turnTimeLimit: 15,
+ players: [],
+ usedWords: [],
+ winner: null,
+ ranking: null,
+ finalScores: null,
+ })
+
+ const [room, setRoom] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [timeLeft, setTimeLeft] = useState(15)
+ const [showEndModal, setShowEndModal] = useState(false)
+ const [submitting, setSubmitting] = useState(false)
+ const [timeoutSent, setTimeoutSent] = useState(false) // 타임아웃 중복 방지
+
+ const isMyTurn = gameState.currentTurnUserId === currentUserId
+ console.log('[WordchainPlayPage] Turn check:', { currentUserId, currentTurnUserId: gameState.currentTurnUserId, isMyTurn })
+
+ // WebSocket에서 온 players 배열을 객체 배열로 변환 (nickname 매핑)
+ const mapPlayersWithNickname = (playerIds, existingPlayers, participants) => {
+ if (!playerIds || !Array.isArray(playerIds)) return existingPlayers
+
+ // playerIds가 이미 객체 배열이면 그대로 반환
+ if (playerIds.length > 0 && typeof playerIds[0] === 'object') {
+ return playerIds
+ }
+
+ // 문자열 배열이면 nickname 매핑
+ return playerIds.map(userId => {
+ // 기존 players에서 찾기
+ const existing = existingPlayers?.find(p => p.userId === userId)
+ if (existing) return existing
+
+ // room.participants에서 찾기
+ const participant = participants?.find(p => p.id === userId || p.participantId === userId || p.userId === userId)
+ if (participant) {
+ return {
+ userId,
+ nickname: participant.nickname || participant.name || userId.substring(0, 8),
+ isAlive: true,
+ }
+ }
+
+ // 못 찾으면 userId로 표시
+ return {
+ userId,
+ nickname: userId.substring(0, 8),
+ isAlive: true,
+ }
+ })
+ }
+
+ // WebSocket gameState 업데이트 반영
+ useEffect(() => {
+ if (wsGameState) {
+ console.log('[WordchainPlayPage] Received wsGameState:', wsGameState)
+
+ setGameState(prev => {
+ // 턴이 변경되면 타이머 리셋
+ if (wsGameState.currentTurnUserId && wsGameState.currentTurnUserId !== prev.currentTurnUserId) {
+ setTimeLeft(wsGameState.turnTimeLimit ?? prev.turnTimeLimit ?? 15)
+ }
+
+ // players 매핑
+ const mappedPlayers = wsGameState.players
+ ? mapPlayersWithNickname(wsGameState.players, prev.players, room?.participants)
+ : prev.players
+
+ return {
+ ...prev,
+ status: wsGameState.status ?? prev.status,
+ currentWord: wsGameState.currentWord ?? prev.currentWord,
+ nextLetter: wsGameState.nextLetter ?? prev.nextLetter,
+ currentTurnUserId: wsGameState.currentTurnUserId ?? prev.currentTurnUserId,
+ turnStartTime: wsGameState.turnStartTime ?? prev.turnStartTime,
+ turnTimeLimit: wsGameState.turnTimeLimit ?? prev.turnTimeLimit ?? 15,
+ players: mappedPlayers,
+ usedWords: wsGameState.usedWords ?? prev.usedWords,
+ winner: wsGameState.winner ?? prev.winner,
+ ranking: wsGameState.ranking ?? prev.ranking,
+ finalScores: wsGameState.finalScores ?? prev.finalScores,
+ }
+ })
+
+ // 게임 종료 처리
+ if (wsGameState.status === 'FINISHED' && !showEndModal) {
+ setShowEndModal(true)
+ }
+ }
+ }, [wsGameState, showEndModal, room])
+
+ // 초기 로드 및 WebSocket 연결
+ useEffect(() => {
+ const initGame = async () => {
+ try {
+ setLoading(true)
+
+ // 방 정보 조회
+ const roomResponse = await gameService.getRoom(roomId)
+ setRoom(roomResponse.data)
+
+ // sessionStorage에서 WORDCHAIN_START 데이터 확인
+ const savedState = sessionStorage.getItem(`wordchainState_${roomId}`)
+ let gameData = null
+
+ if (savedState) {
+ gameData = JSON.parse(savedState)
+ console.log('[WordchainPlayPage] Got saved wordchain state:', gameData)
+ // 페이지 이탈 시 삭제하도록 변경 (StrictMode 두 번 마운트 대응)
+ } else {
+ // sessionStorage에 없으면 API 조회 시도
+ try {
+ const statusResponse = await wordchainService.getStatus(roomId)
+ gameData = statusResponse.data || statusResponse
+ console.log('[WordchainPlayPage] Got game status:', gameData)
+ } catch (err) {
+ // 게임 상태 조회 실패 시 WebSocket 이벤트 대기
+ console.log('[WordchainPlayPage] Failed to get status, waiting for WebSocket:', err.message)
+ // 기본 상태로 시작하고 WebSocket에서 업데이트 받음
+ gameData = {
+ currentWord: null,
+ nextLetter: null,
+ currentTurnUserId: null,
+ turnTimeLimit: 15,
+ players: roomResponse.data.participants || [],
+ usedWords: [],
+ }
+ }
+ }
+
+ setGameState({
+ status: 'PLAYING',
+ currentWord: gameData.currentWord || null,
+ nextLetter: gameData.nextLetter || null,
+ currentTurnUserId: gameData.currentTurnUserId,
+ turnStartTime: gameData.turnStartTime || Date.now(),
+ turnTimeLimit: gameData.turnTimeLimit || 15,
+ players: gameData.players || roomResponse.data.participants || [],
+ usedWords: gameData.usedWords || [],
+ winner: null,
+ })
+
+ setTimeLeft(gameData.turnTimeLimit || 15)
+
+ // WebSocket 연결
+ console.log('[WordchainPlayPage] Connecting WebSocket...')
+ await connect()
+ console.log('[WordchainPlayPage] WebSocket connected')
+ } catch (err) {
+ console.error('Failed to init game:', err)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ initGame()
+
+ return () => {
+ console.log('[WordchainPlayPage] Disconnecting WebSocket...')
+ disconnect()
+ }
+ }, [roomId, currentUserId, connect, disconnect])
+
+ // 타이머
+ useEffect(() => {
+ if (gameState.status !== 'PLAYING') return
+
+ const interval = setInterval(() => {
+ setTimeLeft(prev => {
+ if (prev <= 1) {
+ return 0
+ }
+ return prev - 1
+ })
+ }, 1000)
+
+ return () => clearInterval(interval)
+ }, [gameState.status])
+
+ // 타임아웃 처리 (한 번만 전송)
+ useEffect(() => {
+ if (timeLeft === 0 && isMyTurn && isConnected && !timeoutSent) {
+ console.log('[WordchainPlayPage] Timer expired, sending timeout')
+ setTimeoutSent(true)
+ wordchainService.timeout(roomId).catch(err => {
+ console.error('Failed to send timeout:', err)
+ })
+ }
+ }, [timeLeft, isMyTurn, isConnected, timeoutSent, roomId])
+
+ // 턴 변경 시 타임아웃 플래그 리셋
+ useEffect(() => {
+ setTimeoutSent(false)
+ }, [gameState.currentTurnUserId])
+
+ // 단어 제출
+ const handleSubmitWord = async (word) => {
+ if (!isMyTurn || submitting) return
+
+ try {
+ setSubmitting(true)
+ await wordchainService.submit(roomId, word)
+ // 서버에서 WebSocket으로 결과 브로드캐스트
+ } catch (err) {
+ console.error('Failed to submit word:', err)
+ alert(err.response?.data?.message || err.message || '단어 제출에 실패했습니다')
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ // 게임 종료
+ const handleStopGame = async () => {
+ disconnect()
+ try {
+ await wordchainService.stop(roomId)
+ } catch (err) {
+ console.error('Failed to stop game:', err)
+ // 에러가 나도 무시하고 진행
+ }
+ sessionStorage.removeItem(`roomToken_${roomId}`)
+ sessionStorage.removeItem(`wordchainState_${roomId}`)
+ navigate('/games/wordchain')
+ }
+
+ // 재시작
+ const handleRestart = async () => {
+ try {
+ disconnect()
+ await gameService.restartGame(roomId)
+ setShowEndModal(false)
+ navigate(`/games/wordchain/${roomId}/waiting`)
+ } catch (err) {
+ console.error('Failed to restart:', err)
+ setShowEndModal(false)
+ navigate(`/games/wordchain/${roomId}/waiting`)
+ }
+ }
+
+ // 나가기
+ const handleLeave = async () => {
+ try {
+ disconnect()
+ await gameService.leaveRoom(roomId)
+ } catch (err) {
+ console.error('Failed to leave room:', err)
+ }
+ sessionStorage.removeItem(`roomToken_${roomId}`)
+ navigate('/games/wordchain')
+ }
+
+ if (loading) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+ {/* 헤더 */}
+
+
+
+
+
+ {room?.name || '끝말잇기'}
+
+
+
+
+
+
+
+
+
+
+
+ {/* WebSocket 에러 */}
+ {wsError && (
+
+
+ {wsError}
+
+
+ )}
+
+ {/* 메인 컨텐츠 */}
+
+
+ {/* 좌측: 게임 영역 */}
+
+ {/* 타이머 & 현재 턴 */}
+
+
+
+ 현재 턴
+
+
+ {gameState.players.find(p => p.userId === gameState.currentTurnUserId)?.nickname ||
+ gameState.currentTurnUserId ||
+ '대기 중'}
+
+ {isMyTurn && (
+
+ )}
+
+
+
+
+ {/* 현재 단어 & 다음 글자 */}
+
+
+
+
+ {/* 단어 입력 */}
+
+
+
+
+
+ {/* 우측: 플레이어 & 사용된 단어 */}
+
+ {/* 플레이어 목록 */}
+
+
+
+
+ {/* 사용된 단어 */}
+
+
+
+
+
+
+
+ {/* 게임 종료 모달 */}
+
+
+ )
+}
+
+export default WordchainPlayPage
diff --git a/src/domains/games/pages/WordchainWaitingPage.jsx b/src/domains/games/pages/WordchainWaitingPage.jsx
new file mode 100644
index 0000000..d8c8102
--- /dev/null
+++ b/src/domains/games/pages/WordchainWaitingPage.jsx
@@ -0,0 +1,394 @@
+import { useCallback, useEffect, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import {
+ Alert,
+ Box,
+ Button,
+ Card,
+ Chip,
+ CircularProgress,
+ Container,
+ IconButton,
+ Typography,
+} from '@mui/material'
+import {
+ ArrowBack as ArrowBackIcon,
+ PlayArrow as PlayIcon,
+ Settings as SettingsIcon,
+} from '@mui/icons-material'
+import ParticipantList from '../components/ParticipantList'
+import WaitingChat from '../components/WaitingChat'
+import { gameService } from '../services/gameService'
+import { wordchainService } from '../services/wordchainService'
+import { GAME_COLORS } from '../theme/gameTheme'
+import { useAuth } from '../../../contexts/AuthContext'
+import { useThemeMode } from '../../../contexts/ThemeContext'
+import { useChatWebSocket } from '../../freetalk/hooks/useChatWebSocket'
+
+const WordchainWaitingPage = () => {
+ const { roomId } = useParams()
+ const navigate = useNavigate()
+ const { user } = useAuth()
+ const { mode } = useThemeMode()
+ const isDark = mode === 'dark'
+ const currentUserId = user?.userId || user?.username || user?.sub
+
+ // WebSocket 연결
+ const {
+ isConnected,
+ messages: wsMessages,
+ gameState: wsGameState,
+ error: wsError,
+ connect,
+ disconnect,
+ sendMessage: wsSendMessage,
+ } = useChatWebSocket(roomId, currentUserId)
+
+ const [room, setRoom] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [starting, setStarting] = useState(false)
+
+ // 채팅 메시지 (WebSocket 연동)
+ const [messages, setMessages] = useState([
+ {
+ id: 'system-1',
+ content: '게임 대기실에 입장했습니다.',
+ isSystem: true,
+ createdAt: new Date().toISOString(),
+ },
+ ])
+
+ // WebSocket 메시지 동기화
+ useEffect(() => {
+ if (wsMessages && wsMessages.length > 0) {
+ setMessages(prev => {
+ const systemMessages = prev.filter(m => m.isSystem && m.id === 'system-1')
+ const existingIds = new Set(systemMessages.map(m => m.id))
+ const uniqueWsMessages = wsMessages.filter(m => !existingIds.has(m.id))
+ const allMessages = [...systemMessages, ...uniqueWsMessages]
+ const seen = new Set()
+ return allMessages.filter(m => {
+ if (seen.has(m.id)) return false
+ seen.add(m.id)
+ return true
+ })
+ })
+ }
+ }, [wsMessages])
+
+ // 게임 시작 감지 (WebSocket GAME_START 이벤트)
+ useEffect(() => {
+ if (wsGameState?.status === 'PLAYING') {
+ console.log('[WordchainWaitingPage] Game started via WebSocket, navigating to play page')
+ navigate(`/games/wordchain/${roomId}/play`)
+ }
+ }, [wsGameState?.status, roomId, navigate])
+
+ // 방 정보 조회
+ const fetchRoom = useCallback(async (showLoading = false) => {
+ try {
+ if (showLoading) {
+ setLoading(true)
+ }
+ const response = await gameService.getRoom(roomId)
+ setRoom(response.data)
+
+ // 게임이 시작되면 플레이 페이지로 이동
+ if (response.data.status === 'PLAYING') {
+ navigate(`/games/wordchain/${roomId}/play`)
+ }
+ } catch (err) {
+ console.error('Failed to fetch room:', err)
+ setError('방 정보를 불러오는데 실패했습니다')
+ } finally {
+ if (showLoading) {
+ setLoading(false)
+ }
+ }
+ }, [roomId, navigate])
+
+ // 초기 로딩 및 WebSocket 연결
+ useEffect(() => {
+ const init = async () => {
+ await fetchRoom(true)
+ console.log('[WordchainWaitingPage] Connecting WebSocket...')
+ try {
+ await connect()
+ console.log('[WordchainWaitingPage] WebSocket connected')
+ } catch (err) {
+ console.error('[WordchainWaitingPage] WebSocket connection failed:', err)
+ }
+ }
+ init()
+
+ return () => {
+ console.log('[WordchainWaitingPage] Disconnecting WebSocket...')
+ disconnect()
+ }
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
+
+ // 주기적 새로고침
+ useEffect(() => {
+ const interval = setInterval(() => fetchRoom(false), 3000)
+ return () => clearInterval(interval)
+ }, [fetchRoom])
+
+ // 게임 시작
+ const handleStartGame = async () => {
+ try {
+ setStarting(true)
+ await wordchainService.start(roomId)
+ navigate(`/games/wordchain/${roomId}/play`)
+ } catch (err) {
+ console.error('Failed to start game:', err)
+ const errorMessage = err.response?.data?.message || err.message || '게임 시작에 실패했습니다'
+ setError(errorMessage)
+ } finally {
+ setStarting(false)
+ }
+ }
+
+ // 방 나가기
+ const handleLeaveRoom = async () => {
+ try {
+ disconnect()
+ await gameService.leaveRoom(roomId)
+ sessionStorage.removeItem(`roomToken_${roomId}`)
+ navigate('/games/wordchain')
+ } catch (err) {
+ console.error('Failed to leave room:', err)
+ }
+ }
+
+ // 채팅 메시지 전송
+ const handleSendMessage = (content) => {
+ if (isConnected) {
+ console.log('[WordchainWaitingPage] Sending message via WebSocket:', content)
+ wsSendMessage(content, 'TEXT')
+ } else {
+ const newMessage = {
+ id: `msg-${Date.now()}`,
+ userId: currentUserId,
+ nickname: user?.nickname || user?.username || '플레이어',
+ content,
+ createdAt: new Date().toISOString(),
+ }
+ setMessages(prev => [...prev, newMessage])
+ }
+ }
+
+ const isHost = room?.hostId === currentUserId
+ const canStart = isHost && room?.currentParticipants >= 2
+
+ if (loading) {
+ return (
+
+
+
+ )
+ }
+
+ if (!room) {
+ return (
+
+
+ 방을 찾을 수 없습니다
+
+
+
+ )
+ }
+
+ return (
+
+ {/* 헤더 */}
+
+
+
+
+
+
+
+
+
+ {room.name}
+
+
+
+
+
+
+
+
+
+ {isHost && (
+
+
+
+ )}
+
+
+
+
+ {/* 에러 */}
+ {(error || wsError) && (
+
+ setError(null)} sx={{ borderRadius: '12px' }}>
+ {error || wsError}
+
+
+ )}
+
+ {/* 메인 컨텐츠 */}
+
+
+ {/* 좌측: 참가자 목록 */}
+
+
+
+
+ {/* 우측: 대기 채팅 */}
+
+
+
+ 대기 채팅
+
+
+
+
+
+
+
+
+ {/* 하단: 게임 설정 + 시작 버튼 */}
+
+
+
+
+ 턴 시간
+
+
+ {room.gameSettings?.turnTimeLimit || 15}초
+
+
+
+
+ 난이도
+
+
+ {room.level || 'INTERMEDIATE'}
+
+
+
+
+ {isHost ? (
+ }
+ 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 WordchainWaitingPage
diff --git a/src/domains/games/services/gameService.js b/src/domains/games/services/gameService.js
index 1c3958f..03844d4 100644
--- a/src/domains/games/services/gameService.js
+++ b/src/domains/games/services/gameService.js
@@ -20,7 +20,8 @@ export const gameRoomService = {
const params = new URLSearchParams()
// 게임방 필터
params.append('type', 'GAME')
- params.append('gameType', 'CATCHMIND')
+ const gameType = filters.gameType || 'CATCHMIND'
+ params.append('gameType', gameType)
// 백엔드는 소문자 level 값 사용
if (filters.status) params.append('status', filters.status)
@@ -35,11 +36,11 @@ export const gameRoomService = {
let data = response.data
if (data?.rooms) {
data.rooms = data.rooms.filter(room =>
- room.type === 'GAME' || room.gameType === 'CATCHMIND'
+ room.type === 'GAME' || room.gameType === gameType
)
} else if (Array.isArray(data)) {
data = data.filter(room =>
- room.type === 'GAME' || room.gameType === 'CATCHMIND'
+ room.type === 'GAME' || room.gameType === gameType
)
}
@@ -67,6 +68,19 @@ export const gameRoomService = {
* @param {Object} data.gameSettings - 게임 설정
*/
create: async (data) => {
+ const gameType = data.gameType || 'CATCHMIND'
+ const isWordchain = gameType === 'WORDCHAIN'
+
+ // 게임 타입별 gameSettings 설정
+ const gameSettings = isWordchain
+ ? {
+ turnTimeLimit: data.turnTimeLimit || 15,
+ }
+ : {
+ maxRounds: data.maxRounds || 5,
+ roundTimeLimit: data.roundTimeLimit || 60,
+ }
+
const payload = {
name: data.name,
description: data.description || '',
@@ -75,11 +89,8 @@ export const gameRoomService = {
isPrivate: data.isPrivate || false,
password: data.password,
type: 'GAME',
- gameType: 'CATCHMIND',
- gameSettings: {
- maxRounds: data.maxRounds || 5,
- roundTimeLimit: data.roundTimeLimit || 60,
- },
+ gameType: gameType,
+ gameSettings: gameSettings,
}
console.log('[gameService] create payload:', payload)
diff --git a/src/domains/games/services/wordchainService.js b/src/domains/games/services/wordchainService.js
new file mode 100644
index 0000000..b63a441
--- /dev/null
+++ b/src/domains/games/services/wordchainService.js
@@ -0,0 +1,58 @@
+/**
+ * Word Chain Service - 백엔드 API 연동
+ * 영어 끝말잇기 게임 관련 REST API 호출
+ */
+import chatApi from '../../../api/chatApi'
+
+/**
+ * 끝말잇기 게임 진행 관련 API
+ */
+export const wordchainService = {
+ /**
+ * 게임 시작
+ * @param {string} roomId
+ */
+ start: async (roomId) => {
+ const response = await chatApi.post(`/chat/rooms/${roomId}/wordchain/start`, {})
+ return response.data
+ },
+
+ /**
+ * 단어 제출
+ * @param {string} roomId
+ * @param {string} word
+ */
+ submit: async (roomId, word) => {
+ const response = await chatApi.post(`/chat/rooms/${roomId}/wordchain/submit`, { word })
+ return response.data
+ },
+
+ /**
+ * 타임아웃 처리
+ * @param {string} roomId
+ */
+ timeout: async (roomId) => {
+ const response = await chatApi.post(`/chat/rooms/${roomId}/wordchain/timeout`, {})
+ return response.data
+ },
+
+ /**
+ * 게임 중단
+ * @param {string} roomId
+ */
+ stop: async (roomId) => {
+ const response = await chatApi.post(`/chat/rooms/${roomId}/wordchain/stop`, {})
+ return response.data
+ },
+
+ /**
+ * 게임 상태 조회
+ * @param {string} roomId
+ */
+ getStatus: async (roomId) => {
+ const response = await chatApi.get(`/chat/rooms/${roomId}/wordchain/status`)
+ return response.data
+ },
+}
+
+export default wordchainService
diff --git a/src/domains/news/services/newsService.js b/src/domains/news/services/newsService.js
index a417fd8..4a30eee 100644
--- a/src/domains/news/services/newsService.js
+++ b/src/domains/news/services/newsService.js
@@ -3,28 +3,33 @@
* 뉴스 영어 학습 관련 API 호출
*/
-const API_URL = import.meta.env.VITE_API_URL
+import api from '../../../api/axios'
/**
- * API 요청 헬퍼
+ * API 요청 헬퍼 - 공통 axios 인스턴스 사용 (토큰 갱신 로직 포함)
*/
const fetchWithAuth = async (endpoint, options = {}) => {
- const token = localStorage.getItem('accessToken')
- const response = await fetch(`${API_URL}${endpoint}`, {
- ...options,
- headers: {
- 'Content-Type': 'application/json',
- ...(token && { Authorization: `Bearer ${token}` }),
- ...options.headers,
- },
- })
+ const { method = 'GET', body, ...restOptions } = options
+
+ const config = {
+ ...restOptions,
+ }
- if (!response.ok) {
- const error = await response.json().catch(() => ({}))
- throw new Error(error.message || 'API request failed')
+ if (method === 'GET') {
+ const response = await api.get(endpoint, config)
+ return response.data
+ } else if (method === 'POST') {
+ const response = await api.post(endpoint, body ? JSON.parse(body) : undefined, config)
+ return response.data
+ } else if (method === 'PUT') {
+ const response = await api.put(endpoint, body ? JSON.parse(body) : undefined, config)
+ return response.data
+ } else if (method === 'DELETE') {
+ const response = await api.delete(endpoint, config)
+ return response.data
}
- return response.json()
+ throw new Error(`Unsupported method: ${method}`)
}
/**
diff --git a/src/domains/profile/hooks/useProfile.js b/src/domains/profile/hooks/useProfile.js
new file mode 100644
index 0000000..3e965c4
--- /dev/null
+++ b/src/domains/profile/hooks/useProfile.js
@@ -0,0 +1,35 @@
+import { useEffect } from 'react'
+import { useDispatch, useSelector } from 'react-redux'
+import {
+ fetchMyProfile,
+ updateProfile,
+ uploadProfileImage,
+ clearError
+} from '../store/profileSlice'
+
+export const useProfile = () => {
+ const dispatch = useDispatch()
+ const { profile, loading, error, updateLoading, imageUploading } = useSelector(
+ (state) => state.profile
+ )
+
+ useEffect(() => {
+ if (!profile && !loading && !error) {
+ dispatch(fetchMyProfile())
+ }
+ }, [dispatch, profile, loading, error])
+
+ return {
+ profile,
+ loading,
+ error,
+ updateLoading,
+ imageUploading,
+ updateProfile: (data) => dispatch(updateProfile(data)).unwrap(),
+ uploadImage: (file) => dispatch(uploadProfileImage(file)).unwrap(),
+ clearError: () => dispatch(clearError()),
+ refetch: () => dispatch(fetchMyProfile())
+ }
+}
+
+export default useProfile
\ No newline at end of file
diff --git a/src/domains/profile/services/profileService.js b/src/domains/profile/services/profileService.js
new file mode 100644
index 0000000..c41fcdc
--- /dev/null
+++ b/src/domains/profile/services/profileService.js
@@ -0,0 +1,39 @@
+import api from '../../../api/axios'
+
+const profileService = {
+ // 내 프로필 조회
+ getMyProfile: async () => {
+ const response = await api.get('/users/profile/me')
+ return response.data
+ },
+
+ // 프로필 수정 (닉네임, 레벨)
+ updateProfile: async ({ nickname, level, profileUrl }) => {
+ const response = await api.put('/users/profile/me', {
+ nickname,
+ level,
+ profileUrl
+ })
+ return response.data
+ },
+
+ // 이미지 업로드 URL 발급
+ getImageUploadUrl: async (fileName, contentType) => {
+ const response = await api.post('/users/profile/me/image', {
+ fileName,
+ contentType
+ })
+ return response.data
+ },
+
+ // S3에 이미지 직접 업로드
+ uploadImageToS3: async (uploadUrl, file) => {
+ await fetch(uploadUrl, {
+ method: 'PUT',
+ headers: { 'Content-Type': file.type },
+ body: file
+ })
+ }
+}
+
+export default profileService
\ No newline at end of file
diff --git a/src/domains/profile/store/profileSlice.js b/src/domains/profile/store/profileSlice.js
new file mode 100644
index 0000000..76995ec
--- /dev/null
+++ b/src/domains/profile/store/profileSlice.js
@@ -0,0 +1,114 @@
+import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
+import profileService from '../services/profileService'
+
+// 프로필 조회
+export const fetchMyProfile = createAsyncThunk(
+ 'profile/fetchMyProfile',
+ async (_, { rejectWithValue }) => {
+ try {
+ const response = await profileService.getMyProfile()
+ return response.data || response
+ } catch (error) {
+ const status = error.response?.status
+ const message = error.response?.data?.error || error.message || '프로필 조회 실패'
+ return rejectWithValue({ message, status })
+ }
+ }
+)
+
+// 프로필 수정
+export const updateProfile = createAsyncThunk(
+ 'profile/updateProfile',
+ async (data, { rejectWithValue }) => {
+ try {
+ const response = await profileService.updateProfile(data)
+ return response.data
+ } catch (error) {
+ return rejectWithValue(error.response?.data?.error || '프로필 수정 실패')
+ }
+ }
+)
+
+// 이미지 업로드
+export const uploadProfileImage = createAsyncThunk(
+ 'profile/uploadImage',
+ async (file, { dispatch, rejectWithValue }) => {
+ try {
+ const urlResponse = await profileService.getImageUploadUrl(file.name, file.type)
+ const { uploadUrl, imageUrl } = urlResponse.data
+
+ await profileService.uploadImageToS3(uploadUrl, file)
+ await dispatch(updateProfile({ profileUrl: imageUrl }))
+
+ return imageUrl
+ } catch (error) {
+ return rejectWithValue('이미지 업로드 실패')
+ }
+ }
+)
+
+const profileSlice = createSlice({
+ name: 'profile',
+ initialState: {
+ profile: null,
+ loading: false,
+ error: null,
+ updateLoading: false,
+ imageUploading: false,
+ authError: false
+ },
+ reducers: {
+ clearError: (state) => { state.error = null },
+ clearProfile: (state) => { state.profile = null }
+ },
+ extraReducers: (builder) => {
+ builder
+ // fetchMyProfile
+ .addCase(fetchMyProfile.pending, (state) => {
+ state.loading = true
+ state.error = null
+ })
+ .addCase(fetchMyProfile.fulfilled, (state, action) => {
+ state.loading = false
+ state.profile = action.payload
+ })
+ .addCase(fetchMyProfile.rejected, (state, action) => {
+ state.loading = false
+ state.error = action.payload?.message || action.payload
+
+ const status = action.payload?.status
+ const message = String(action.payload?.message || action.payload || '')
+
+ if (status === 401 || message.includes('401') || message.includes('인증')) {
+ state.profile = null
+ state.authError = true
+ }
+ })
+ // updateProfile
+ .addCase(updateProfile.pending, (state) => {
+ state.updateLoading = true
+ })
+ .addCase(updateProfile.fulfilled, (state, action) => {
+ state.updateLoading = false
+ state.profile = action.payload
+ })
+ .addCase(updateProfile.rejected, (state, action) => {
+ state.updateLoading = false
+ state.error = action.payload
+ })
+ // uploadProfileImage
+ .addCase(uploadProfileImage.pending, (state) => {
+ state.imageUploading = true
+ })
+ .addCase(uploadProfileImage.fulfilled, (state) => {
+ state.imageUploading = false
+ })
+ .addCase(uploadProfileImage.rejected, (state, action) => {
+ state.imageUploading = false
+ state.error = action.payload
+ })
+ }
+})
+
+export const { clearError, clearProfile } = profileSlice.actions
+export default profileSlice.reducer
\ No newline at end of file
diff --git a/src/i18n/translations.js b/src/i18n/translations.js
index 5a8b7af..d5a1384 100644
--- a/src/i18n/translations.js
+++ b/src/i18n/translations.js
@@ -388,6 +388,8 @@ export const translations = {
description: '재미있는 게임으로 영어 실력을 향상하세요',
catchmindTitle: '캐치마인드',
catchmindDesc: '그림 맞추기 게임',
+ wordchainTitle: '끝말잇기',
+ wordchainDesc: '영어 끝말잇기',
},
// News
@@ -837,6 +839,8 @@ export const translations = {
description: 'Improve your English with fun games',
catchmindTitle: 'Catchmind',
catchmindDesc: 'Drawing guessing game',
+ wordchainTitle: 'Word Chain',
+ wordchainDesc: 'English word chain game',
},
// News
diff --git a/src/layouts/MainLayout/Header/index.jsx b/src/layouts/MainLayout/Header/index.jsx
index f52deba..d4c6583 100644
--- a/src/layouts/MainLayout/Header/index.jsx
+++ b/src/layouts/MainLayout/Header/index.jsx
@@ -1,5 +1,6 @@
-import {useState} from 'react'
-import {useNavigate} from 'react-router-dom'
+import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { useSelector } from 'react-redux'
import {
AppBar,
Avatar,
@@ -24,25 +25,26 @@ import {
Settings as SettingsIcon,
Translate as TranslateIcon,
} from '@mui/icons-material'
-import {useThemeMode} from '../../../contexts/ThemeContext'
-import {useSettings, useTranslation} from '../../../contexts/SettingsContext'
-import {LANGUAGE_LABELS, LANGUAGES} from '../../../i18n/translations'
-import {useAuth} from '../../../contexts/AuthContext'
-import {useNotificationContext, NotificationMenu} from '../../../domains/notification'
+import { useThemeMode } from '../../../contexts/ThemeContext'
+import { useSettings, useTranslation } from '../../../contexts/SettingsContext'
+import { LANGUAGE_LABELS, LANGUAGES } from '../../../i18n/translations'
+import { useAuth } from '../../../contexts/AuthContext'
+import { useNotificationContext, NotificationMenu } from '../../../domains/notification'
-const Header = ({onMenuClick, sidebarOpen}) => {
+const Header = ({ onMenuClick, sidebarOpen }) => {
const theme = useTheme()
const navigate = useNavigate()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
- const {mode, toggleTheme} = useThemeMode()
- const {setLanguage, language} = useSettings()
- const {t} = useTranslation()
+ const { mode, toggleTheme } = useThemeMode()
+ const { setLanguage, language } = useSettings()
+ const { t } = useTranslation()
+ const { profile } = useSelector((state) => state.profile)
const [anchorEl, setAnchorEl] = useState(null)
const [notificationAnchor, setNotificationAnchor] = useState(null)
const [langAnchor, setLangAnchor] = useState(null)
- const {logout} = useAuth()
- const {unreadCount} = useNotificationContext()
+ const { logout } = useAuth()
+ const { unreadCount } = useNotificationContext()
const handleProfileMenuOpen = (event) => {
setAnchorEl(event.currentTarget)
@@ -93,7 +95,7 @@ const Header = ({onMenuClick, sidebarOpen}) => {
borderColor: mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)',
}}
>
-
+
{/* Hamburger menu (mobile) */}
{isMobile && (
{
},
}}
>
-
+
)}
@@ -145,7 +147,7 @@ const Header = ({onMenuClick, sidebarOpen}) => {
L
-
+
{
-
+
{/* Right side icons */}
-
+
{/* Language selector */}
{
},
}}
>
-
+
{/* Dark mode toggle */}
@@ -192,7 +194,7 @@ const Header = ({onMenuClick, sidebarOpen}) => {
},
}}
>
- {mode === 'dark' ? : }
+ {mode === 'dark' ? : }
{/* Notifications */}
@@ -219,14 +221,14 @@ const Header = ({onMenuClick, sidebarOpen}) => {
},
}}
>
-
+
{/* Profile */}
{
fontSize: 14,
}}
>
- U
+ {profile?.nickname ? profile.nickname.substring(0, 1).toUpperCase() : 'U'}
@@ -255,15 +257,15 @@ const Header = ({onMenuClick, sidebarOpen}) => {
minWidth: 160,
},
}}
- transformOrigin={{horizontal: 'right', vertical: 'top'}}
- anchorOrigin={{horizontal: 'right', vertical: 'bottom'}}
+ transformOrigin={{ horizontal: 'right', vertical: 'top' }}
+ anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
>
-
+
{t('settings.language')}
-
+
{Object.entries(LANGUAGES).map(([key, value]) => (