diff --git a/src/App.jsx b/src/App.jsx
index ee0fc63..f8229b3 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -27,6 +27,7 @@ import {
School as LearnIcon,
Quiz as QuizIcon,
LibraryBooks as WordListIcon,
+ WavingHand as WaveIcon,
} from '@mui/icons-material'
import MainLayout from './layouts/MainLayout'
import FreetalkPeoplePage from './domains/freetalk/pages/FreetalkPeoplePage'
@@ -40,44 +41,48 @@ import StatsPage from './domains/vocab/pages/StatsPage'
import { useChat } from './contexts/ChatContext'
import { useSettings } from './contexts/SettingsContext'
-// 임시 대시보드 페이지
+// Dashboard Page
function Dashboard() {
const navigate = useNavigate()
const [expandedCard, setExpandedCard] = useState(null)
+ const { t } = useSettings()
const learningModes = [
{
id: 'speaking',
- title: '말하기연습',
- description: '오픽 연습과 AI 대화로 스피킹 실력 향상',
+ title: t('dashboard.speakingTitle'),
+ description: t('dashboard.speakingDesc'),
icon: SpeakingIcon,
- color: '#2196f3',
+ color: '#3b82f6',
+ bgColor: '#eff6ff',
children: [
- { id: 'opic', title: '오픽연습', icon: OpicIcon, path: '/opic', description: '레벨별 맞춤 연습' },
- { id: 'ai-talk', title: 'AI와 말해보기', icon: AiIcon, path: '/freetalk/ai', description: 'AI와 자유로운 대화' },
+ { id: 'opic', title: t('dashboard.opicTitle'), icon: OpicIcon, path: '/opic', description: t('dashboard.opicDesc') },
+ { id: 'ai-talk', title: t('dashboard.aiTalkTitle'), icon: AiIcon, path: '/freetalk/ai', description: t('dashboard.aiTalkDesc') },
],
},
{
id: 'writing',
- title: '쓰기연습',
- description: '채팅과 작문으로 라이팅 실력 향상',
+ title: t('dashboard.writingTitle'),
+ description: t('dashboard.writingDesc'),
icon: WritingCategoryIcon,
- color: '#4caf50',
+ color: '#10b981',
+ bgColor: '#ecfdf5',
children: [
- { id: 'chat-people', title: '사람들과 채팅하기', icon: PeopleIcon, path: '/freetalk/people', description: '다른 학습자와 대화' },
- { id: 'writing-practice', title: '작문연습', icon: WritingIcon, path: '/writing', description: '문법 교정 & 피드백' },
+ { id: 'chat-people', title: t('dashboard.chatTitle'), icon: PeopleIcon, path: '/freetalk/people', description: t('dashboard.chatDesc') },
+ { id: 'writing-practice', title: t('dashboard.compositionTitle'), icon: WritingIcon, path: '/writing', description: t('dashboard.compositionDesc') },
],
},
{
id: 'vocab',
- title: '단어 학습',
- description: '매일 55개 단어로 어휘력 향상',
+ title: t('dashboard.vocabTitle'),
+ description: t('dashboard.vocabDesc'),
icon: VocabIcon,
- color: '#9c27b0',
+ color: '#f97316',
+ bgColor: '#fff7ed',
children: [
- { id: 'vocab-daily', title: '단어 외우기', icon: LearnIcon, path: '/vocab', description: '매일 55개 단어 학습' },
- { id: 'vocab-test', title: '시험 보기', icon: QuizIcon, path: '/vocab/test', description: '4지선다 퀴즈' },
- { id: 'vocab-words', title: '단어장', icon: WordListIcon, path: '/vocab/words', description: '전체 단어 목록' },
+ { id: 'vocab-daily', title: t('dashboard.dailyWordsTitle'), icon: LearnIcon, path: '/vocab', description: t('dashboard.dailyWordsDesc') },
+ { id: 'vocab-test', title: t('dashboard.quizTitle'), icon: QuizIcon, path: '/vocab/test', description: t('dashboard.quizDesc') },
+ { id: 'vocab-words', title: t('dashboard.wordListTitle'), icon: WordListIcon, path: '/vocab/words', description: t('dashboard.wordListDesc') },
],
},
]
@@ -96,16 +101,36 @@ function Dashboard() {
}
return (
-
-
-
- 안녕하세요!
-
-
- 오늘은 어떤 학습을 해볼까요?
-
+
+ {/* Header */}
+
+
+
+
+
+
+
+ {t('dashboard.greeting')}
+
+
+ {t('dashboard.subtitle')}
+
+
+
+ {/* Learning Mode Cards */}
{learningModes.map((mode) => {
const Icon = mode.icon
@@ -113,43 +138,49 @@ function Dashboard() {
const hasChildren = mode.children && mode.children.length > 0
return (
-
+
handleCardHover(mode.id)}
onMouseLeave={handleCardLeave}
onClick={() => !hasChildren && mode.path && navigate(mode.path)}
sx={{
cursor: 'pointer',
- transition: 'all 0.3s ease',
- boxShadow: isExpanded ? 8 : 1,
- border: isExpanded ? `2px solid ${mode.color}` : '2px solid transparent',
+ transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
+ border: '2px solid transparent',
+ borderColor: isExpanded ? mode.color : 'transparent',
+ transform: isExpanded ? 'translateY(-4px)' : 'translateY(0)',
+ boxShadow: isExpanded
+ ? `0 20px 40px -12px ${mode.color}30`
+ : '0 1px 3px 0 rgb(0 0 0 / 0.1)',
'&:hover': {
- boxShadow: 6,
+ borderColor: mode.color,
},
+ height: 'auto',
+ minHeight: isExpanded ? 'auto' : 140,
}}
>
-
- {/* 메인 아이콘 */}
+
+ {/* Icon */}
-
+
- {/* 텍스트 */}
+ {/* Text */}
-
+
{mode.title}
{hasChildren && (
@@ -162,22 +193,23 @@ function Dashboard() {
/>
)}
-
+
{mode.description}
- {/* 하위 카테고리 - 애니메이션으로 펼쳐짐 */}
+ {/* Sub-items */}
{hasChildren && (
-
+
2 ? 'repeat(3, 1fr)' : 'repeat(2, 1fr)',
gap: 2,
mt: 3,
pt: 3,
- borderTop: 1,
+ borderTop: '1px solid',
borderColor: 'divider',
}}
>
@@ -188,32 +220,46 @@ function Dashboard() {
key={child.id}
onClick={(e) => handleSubItemClick(child.path, e)}
sx={{
- flex: 1,
p: 2,
- borderRadius: 2,
- backgroundColor: 'action.hover',
+ borderRadius: '14px',
+ backgroundColor: mode.bgColor,
cursor: 'pointer',
- transition: 'all 0.3s',
- transform: isExpanded ? 'translateX(0)' : 'translateX(-20px)',
+ transition: 'all 0.2s ease',
+ transform: isExpanded ? 'translateY(0)' : 'translateY(-8px)',
opacity: isExpanded ? 1 : 0,
- transitionDelay: `${index * 100}ms`,
+ transitionDelay: `${index * 50}ms`,
'&:hover': {
backgroundColor: `${mode.color}20`,
transform: 'scale(1.02)',
},
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ textAlign: 'center',
+ minHeight: 100,
}}
>
-
-
-
-
- {child.title}
-
-
- {child.description}
-
-
+
+
+
+ {child.title}
+
+
+ {child.description}
+
)
})}
@@ -227,16 +273,41 @@ function Dashboard() {
})}
- {/* 최근 학습 */}
+ {/* Recent Activity */}
-
- 최근 학습
+
+ {t('dashboard.recentActivity')}
-
-
- 아직 학습 기록이 없습니다. 학습을 시작해보세요!
+
+
+
+
+
+ {t('dashboard.noHistory')}
+
+
+ {t('dashboard.startLearning')}
+
@@ -244,100 +315,275 @@ function Dashboard() {
)
}
-// 임시 페이지들
+// Placeholder Pages
function OpicPage() {
return (
-
- OPIC 연습
- 레벨별 맞춤 연습
+
+ OPIC Practice
+ Level-based training
)
}
function FreetalkAiPage() {
return (
-
- 프리토킹 - AI와
- AI와 자유로운 대화
+
+ AI Conversation
+ Free conversation with AI
)
}
function WritingPage() {
return (
-
- 작문 연습
- 문법 교정 & 피드백
+
+ Writing Practice
+ Grammar correction & feedback
)
}
function ReportsPage() {
return (
-
- 내 리포트
- 학습 결과 분석
+
+ My Reports
+ Learning analytics
)
}
function SettingsPage() {
- const { settings, setTtsVoice } = useSettings()
+ const { settings, setTtsVoice, setLanguage, t } = useSettings()
+
+ const languageOptions = [
+ { value: 'ko', label: '한국어', flag: '🇰🇷' },
+ { value: 'en', label: 'English', flag: '🇺🇸' },
+ ]
return (
-
+
-
- 설정
-
-
- 앱 설정을 변경할 수 있습니다
-
+
+
+
+
+
+
+ {t('settings.title')}
+
+
+ {t('settings.subtitle')}
+
+
+
-
-
-
-
- TTS 음성 선택
-
-
- 채팅에서 메시지를 읽어줄 음성을 선택하세요
+
+ {/* Language Settings */}
+
+
+
+ {t('settings.language')}
- setTtsVoice(e.target.value)}
- >
- }
- label="여성 음성"
- />
- }
- label="남성 음성"
- />
-
-
-
-
+
+ {t('settings.languageDesc')}
+
+
+
+
+ {languageOptions.map((option) => (
+
+ setLanguage(option.value)}
+ sx={{
+ p: 2.5,
+ borderRadius: '16px',
+ border: '2px solid',
+ borderColor: settings.language === option.value ? '#3b82f6' : 'divider',
+ backgroundColor: settings.language === option.value ? '#eff6ff' : 'transparent',
+ cursor: 'pointer',
+ transition: 'all 0.2s ease',
+ textAlign: 'center',
+ '&:hover': {
+ borderColor: '#3b82f6',
+ backgroundColor: '#eff6ff',
+ },
+ }}
+ >
+
+ {option.flag}
+
+
+ {option.label}
+
+
+
+ ))}
+
+
+
+
+ {/* TTS Voice Settings */}
+
+
+
+ {t('settings.ttsVoice')}
+
+
+ {t('settings.ttsVoiceDesc')}
+
+
+
+
+
+ setTtsVoice('FEMALE')}
+ sx={{
+ p: 2.5,
+ borderRadius: '16px',
+ border: '2px solid',
+ borderColor: settings.ttsVoice === 'FEMALE' ? '#059669' : 'divider',
+ backgroundColor: settings.ttsVoice === 'FEMALE' ? '#ecfdf5' : 'transparent',
+ cursor: 'pointer',
+ transition: 'all 0.2s ease',
+ textAlign: 'center',
+ '&:hover': {
+ borderColor: '#059669',
+ backgroundColor: '#ecfdf5',
+ },
+ }}
+ >
+
+ 👩
+
+
+ {t('settings.femaleVoice')}
+
+
+
+
+ setTtsVoice('MALE')}
+ sx={{
+ p: 2.5,
+ borderRadius: '16px',
+ border: '2px solid',
+ borderColor: settings.ttsVoice === 'MALE' ? '#059669' : 'divider',
+ backgroundColor: settings.ttsVoice === 'MALE' ? '#ecfdf5' : 'transparent',
+ cursor: 'pointer',
+ transition: 'all 0.2s ease',
+ textAlign: 'center',
+ '&:hover': {
+ borderColor: '#059669',
+ backgroundColor: '#ecfdf5',
+ },
+ }}
+ >
+
+ 👨
+
+
+ {t('settings.maleVoice')}
+
+
+
+
+
+
+
)
}
function NotFound() {
+ const navigate = useNavigate()
+ const { t } = useSettings()
+
return (
-
-
-
+
+
+
404
-
- 페이지를 찾을 수 없습니다
+
+ {t('notFound.title')}
+
+
+ {t('notFound.message')}
-
@@ -348,16 +594,16 @@ function App() {
const { activeRoom, closeChatRoom } = useChat()
const handleRefreshRooms = () => {
- // 채팅방 퇴장 후 목록 새로고침 (페이지에서 처리)
+ // Refresh rooms list after leaving a room
}
return (
<>
- {/* 채팅방 페이지 (별도 레이아웃) */}
+ {/* Chat room page (separate layout) */}
} />
- {/* MainLayout 적용 라우트 */}
+ {/* MainLayout routes */}
}>
} />
} />
@@ -378,7 +624,7 @@ function App() {
} />
- {/* 전역 채팅 모달 */}
+ {/* Global chat modal */}
{
@@ -26,8 +28,39 @@ export const SettingsProvider = ({ children }) => {
updateSettings({ ttsVoice: voice })
}, [updateSettings])
+ const setLanguage = useCallback((lang) => {
+ updateSettings({ language: lang })
+ }, [updateSettings])
+
+ // Translation function
+ const t = useCallback((key) => {
+ const keys = key.split('.')
+ let value = translations[settings.language]
+
+ for (const k of keys) {
+ if (value && typeof value === 'object') {
+ value = value[k]
+ } else {
+ return key // Return key if translation not found
+ }
+ }
+
+ return value || key
+ }, [settings.language])
+
+ const value = useMemo(() => ({
+ settings,
+ updateSettings,
+ setTtsVoice,
+ setLanguage,
+ t,
+ language: settings.language,
+ isKorean: settings.language === LANGUAGES.KO,
+ isEnglish: settings.language === LANGUAGES.EN,
+ }), [settings, updateSettings, setTtsVoice, setLanguage, t])
+
return (
-
+
{children}
)
@@ -40,3 +73,9 @@ export const useSettings = () => {
}
return context
}
+
+// Convenience hook for translations
+export const useTranslation = () => {
+ const { t, language, isKorean, isEnglish } = useSettings()
+ return { t, language, isKorean, isEnglish }
+}
diff --git a/src/domains/chat/services/chatService.js b/src/domains/chat/services/chatService.js
index cdf9372..91eff88 100644
--- a/src/domains/chat/services/chatService.js
+++ b/src/domains/chat/services/chatService.js
@@ -2,82 +2,500 @@ import chatApi from '../../../api/chatApi'
const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1'
-// 채팅방 API
+// Mock 데이터 사용 여부 (true: 목 데이터 사용, false: 실제 API 호출)
+const USE_MOCK = true
+
+// ============================================
+// Mock 데이터 (백엔드 API 응답 형식과 동일)
+// ============================================
+
+const mockChatRooms = [
+ {
+ roomId: '550e8400-e29b-41d4-a716-446655440001',
+ name: 'English Practice Room',
+ description: 'Daily conversation practice for beginners',
+ level: 'beginner',
+ currentMembers: 5,
+ maxMembers: 10,
+ isPrivate: false,
+ createdBy: 'user1',
+ memberIds: ['user1', 'user2', 'user3', 'user4', 'user5'],
+ createdAt: new Date(Date.now() - 86400000 * 3).toISOString(),
+ lastMessageAt: new Date(Date.now() - 3600000).toISOString(),
+ },
+ {
+ roomId: '550e8400-e29b-41d4-a716-446655440002',
+ name: 'Business English',
+ description: 'Professional English for business situations',
+ level: 'intermediate',
+ currentMembers: 3,
+ maxMembers: 8,
+ isPrivate: false,
+ createdBy: 'user2',
+ memberIds: ['user2', 'user5', 'user6'],
+ createdAt: new Date(Date.now() - 86400000 * 2).toISOString(),
+ lastMessageAt: new Date(Date.now() - 7200000).toISOString(),
+ },
+ {
+ roomId: '550e8400-e29b-41d4-a716-446655440003',
+ name: 'OPIC 준비방',
+ description: 'OPIC 시험 준비를 위한 스터디 그룹',
+ level: 'intermediate',
+ currentMembers: 4,
+ maxMembers: 6,
+ isPrivate: true,
+ createdBy: 'user3',
+ memberIds: ['user1', 'user3', 'user7', 'user8'],
+ createdAt: new Date(Date.now() - 86400000).toISOString(),
+ lastMessageAt: new Date(Date.now() - 1800000).toISOString(),
+ },
+ {
+ roomId: '550e8400-e29b-41d4-a716-446655440004',
+ name: 'Advanced Discussion',
+ description: 'Deep discussions on various topics in English',
+ level: 'advanced',
+ currentMembers: 2,
+ maxMembers: 5,
+ isPrivate: false,
+ createdBy: 'user4',
+ memberIds: ['user4', 'user9'],
+ createdAt: new Date(Date.now() - 86400000 * 5).toISOString(),
+ lastMessageAt: new Date(Date.now() - 86400000).toISOString(),
+ },
+ {
+ roomId: '550e8400-e29b-41d4-a716-446655440005',
+ name: 'Free Talk Zone',
+ description: 'Casual conversations about anything',
+ level: 'beginner',
+ currentMembers: 8,
+ maxMembers: 15,
+ isPrivate: false,
+ createdBy: 'user5',
+ memberIds: ['user1', 'user2', 'user5', 'user6', 'user7', 'user8', 'user9', 'user10'],
+ createdAt: new Date(Date.now() - 3600000).toISOString(),
+ lastMessageAt: new Date(Date.now() - 400000).toISOString(),
+ },
+]
+
+const mockMessages = {
+ '550e8400-e29b-41d4-a716-446655440001': [
+ { messageId: 'msg-001', roomId: '550e8400-e29b-41d4-a716-446655440001', userId: 'user2', content: 'Hello everyone!', messageType: 'TEXT', createdAt: new Date(Date.now() - 3600000).toISOString() },
+ { messageId: 'msg-002', roomId: '550e8400-e29b-41d4-a716-446655440001', userId: 'user1', content: 'Hi! How are you today?', messageType: 'TEXT', createdAt: new Date(Date.now() - 3500000).toISOString() },
+ { messageId: 'msg-003', roomId: '550e8400-e29b-41d4-a716-446655440001', userId: 'user3', content: "I'm doing great, thanks for asking!", messageType: 'TEXT', createdAt: new Date(Date.now() - 3400000).toISOString() },
+ { messageId: 'msg-004', roomId: '550e8400-e29b-41d4-a716-446655440001', userId: 'user2', content: 'What topic should we practice today?', messageType: 'TEXT', createdAt: new Date(Date.now() - 3300000).toISOString() },
+ { messageId: 'msg-005', roomId: '550e8400-e29b-41d4-a716-446655440001', userId: 'user4', content: 'How about discussing weekend plans?', messageType: 'TEXT', createdAt: new Date(Date.now() - 3200000).toISOString() },
+ ],
+ '550e8400-e29b-41d4-a716-446655440002': [
+ { messageId: 'msg-006', roomId: '550e8400-e29b-41d4-a716-446655440002', userId: 'user2', content: "Let's practice business email writing.", messageType: 'TEXT', createdAt: new Date(Date.now() - 7200000).toISOString() },
+ { messageId: 'msg-007', roomId: '550e8400-e29b-41d4-a716-446655440002', userId: 'user5', content: 'That sounds like a great idea!', messageType: 'TEXT', createdAt: new Date(Date.now() - 7100000).toISOString() },
+ ],
+ '550e8400-e29b-41d4-a716-446655440003': [
+ { messageId: 'msg-008', roomId: '550e8400-e29b-41d4-a716-446655440003', userId: 'user3', content: 'OPIC 시험 일주일 남았어요!', messageType: 'TEXT', createdAt: new Date(Date.now() - 1800000).toISOString() },
+ { messageId: 'msg-009', roomId: '550e8400-e29b-41d4-a716-446655440003', userId: 'user1', content: "화이팅! Let's practice together.", messageType: 'TEXT', createdAt: new Date(Date.now() - 1700000).toISOString() },
+ ],
+ '550e8400-e29b-41d4-a716-446655440004': [
+ { messageId: 'msg-010', roomId: '550e8400-e29b-41d4-a716-446655440004', userId: 'user4', content: 'What are your thoughts on AI technology?', messageType: 'TEXT', createdAt: new Date(Date.now() - 86400000).toISOString() },
+ ],
+ '550e8400-e29b-41d4-a716-446655440005': [
+ { messageId: 'msg-011', roomId: '550e8400-e29b-41d4-a716-446655440005', userId: 'user5', content: 'Anyone watched any good movies lately?', messageType: 'TEXT', createdAt: new Date(Date.now() - 600000).toISOString() },
+ { messageId: 'msg-012', roomId: '550e8400-e29b-41d4-a716-446655440005', userId: 'user6', content: 'I just watched Inception again!', messageType: 'TEXT', createdAt: new Date(Date.now() - 500000).toISOString() },
+ { messageId: 'msg-013', roomId: '550e8400-e29b-41d4-a716-446655440005', userId: 'user7', content: 'That movie is a classic!', messageType: 'TEXT', createdAt: new Date(Date.now() - 400000).toISOString() },
+ ],
+}
+
+// ============================================
+// API with Mock fallback
+// ============================================
+
+const withMock = (apiCall, mockData) => {
+ if (USE_MOCK) {
+ // interceptor가 response.data를 반환하므로 동일한 형식으로 반환
+ // 실제 API: { success: true, message: ..., data: {...} }
+ return Promise.resolve({
+ success: true,
+ message: 'Mock data',
+ data: mockData
+ })
+ }
+ return apiCall().catch(() => ({
+ success: true,
+ message: 'Fallback mock data',
+ data: mockData
+ }))
+}
+
+/**
+ * 채팅방 API - Backend: POST /rooms, GET /rooms, GET /rooms/{roomId}, POST /rooms/{roomId}/join, POST /rooms/{roomId}/leave
+ */
export const chatRoomService = {
- // 채팅방 생성
+ // POST /rooms - 채팅방 생성
create: async (data) => {
- return chatApi.post('/chat/rooms', {
- ...data,
+ const newRoom = {
+ roomId: `room-${Date.now()}`,
+ name: data.name,
+ description: data.description || '',
+ level: data.level || 'beginner',
+ currentMembers: 1,
+ maxMembers: data.maxMembers || 6,
+ isPrivate: data.isPrivate || false,
createdBy: TEMP_USER_ID,
- })
+ memberIds: [TEMP_USER_ID],
+ createdAt: new Date().toISOString(),
+ lastMessageAt: new Date().toISOString(),
+ }
+ return withMock(
+ () => chatApi.post('/rooms', { ...data, createdBy: TEMP_USER_ID }),
+ newRoom
+ )
},
- // 채팅방 목록 조회
+ // GET /rooms - 채팅방 목록 조회
getList: async (params = {}) => {
const { limit = 10, level, joined, cursor } = params
- const queryParams = new URLSearchParams()
- queryParams.append('limit', limit)
- if (level) queryParams.append('level', level)
- if (joined) {
- queryParams.append('joined', 'true')
- queryParams.append('userId', TEMP_USER_ID)
- }
- if (cursor) queryParams.append('cursor', cursor)
-
- return chatApi.get(`/chat/rooms?${queryParams.toString()}`)
+ return withMock(
+ () => {
+ const queryParams = new URLSearchParams()
+ queryParams.append('limit', limit)
+ if (level) queryParams.append('level', level)
+ if (joined) {
+ queryParams.append('joined', 'true')
+ queryParams.append('userId', TEMP_USER_ID)
+ }
+ if (cursor) queryParams.append('cursor', cursor)
+ return chatApi.get(`/rooms?${queryParams.toString()}`)
+ },
+ {
+ rooms: mockChatRooms
+ .filter(room => {
+ if (level && room.level !== level.toLowerCase()) return false
+ if (joined && !room.memberIds.includes(TEMP_USER_ID)) return false
+ return true
+ })
+ .slice(0, limit),
+ nextCursor: null,
+ hasMore: false,
+ }
+ )
},
- // 채팅방 상세 조회
+ // GET /rooms/{roomId} - 채팅방 상세 조회
getDetail: async (roomId) => {
- return chatApi.get(`/chat/rooms/${roomId}`)
+ return withMock(
+ () => chatApi.get(`/rooms/${roomId}`),
+ mockChatRooms.find(r => r.roomId === roomId) || mockChatRooms[0]
+ )
},
- // 채팅방 입장
+ // POST /rooms/{roomId}/join - 채팅방 입장
join: async (roomId, password) => {
- return chatApi.post(`/chat/rooms/${roomId}/join`, {
- userId: TEMP_USER_ID,
- ...(password && { password }),
- })
+ const room = mockChatRooms.find(r => r.roomId === roomId)
+ return withMock(
+ () => chatApi.post(`/rooms/${roomId}/join`, {
+ userId: TEMP_USER_ID,
+ ...(password && { password }),
+ }),
+ {
+ room: {
+ roomId: room?.roomId || roomId,
+ name: room?.name || 'Chat Room',
+ currentMembers: (room?.currentMembers || 1) + 1,
+ },
+ roomToken: `token-${Date.now()}`,
+ tokenExpiresAt: Math.floor(Date.now() / 1000) + 300, // 5분 후
+ }
+ )
},
- // 채팅방 퇴장
+ // POST /rooms/{roomId}/leave - 채팅방 퇴장
leave: async (roomId) => {
- return chatApi.post(`/chat/rooms/${roomId}/leave`, {
- userId: TEMP_USER_ID,
- })
+ return withMock(
+ () => chatApi.post(`/rooms/${roomId}/leave`, { userId: TEMP_USER_ID }),
+ { roomId, currentMembers: 2 }
+ )
},
}
-// 메시지 API
+/**
+ * 메시지 API - Backend: WebSocket sendMessage, GET /rooms/{roomId}/messages (메시지 조회)
+ */
export const messageService = {
- // 메시지 전송
+ // 메시지 전송 (REST fallback - 실제로는 WebSocket 사용)
send: async (roomId, content, messageType = 'TEXT') => {
- return chatApi.post(`/chat/rooms/${roomId}/messages`, {
+ const newMessage = {
+ messageId: `msg-${Date.now()}`,
+ roomId,
userId: TEMP_USER_ID,
content,
messageType,
- })
+ createdAt: new Date().toISOString(),
+ }
+ return withMock(
+ () => chatApi.post(`/rooms/${roomId}/messages`, {
+ userId: TEMP_USER_ID,
+ content,
+ messageType,
+ }),
+ newMessage
+ )
},
- // 메시지 목록 조회
+ // GET /rooms/{roomId}/messages - 메시지 목록 조회
getList: async (roomId, params = {}) => {
const { limit = 20, cursor } = params
- const queryParams = new URLSearchParams()
-
- queryParams.append('limit', limit)
- if (cursor) queryParams.append('cursor', cursor)
- return chatApi.get(`/chat/rooms/${roomId}/messages?${queryParams.toString()}`)
+ return withMock(
+ () => {
+ const queryParams = new URLSearchParams()
+ queryParams.append('limit', limit)
+ if (cursor) queryParams.append('cursor', cursor)
+ return chatApi.get(`/rooms/${roomId}/messages?${queryParams.toString()}`)
+ },
+ {
+ messages: (mockMessages[roomId] || []).slice(0, limit),
+ nextCursor: null,
+ hasMore: false,
+ }
+ )
},
}
-// 음성 API
+/**
+ * 음성 API - Backend: POST /voice/synthesize
+ */
export const voiceService = {
- // TTS 변환
+ // POST /voice/synthesize - TTS 변환
synthesize: async (messageId, roomId, voice = 'FEMALE') => {
- return chatApi.post('/chat/voice/synthesize', { messageId, roomId, voice })
+ return withMock(
+ () => chatApi.post('/voice/synthesize', { messageId, roomId, voice }),
+ { audioUrl: null, cached: false }
+ )
+ },
+}
+
+// 캐치마인드 게임 Mock 상태
+let mockGameState = {
+ gameStatus: 'NONE', // NONE, PLAYING, FINISHED
+ currentRound: 0,
+ totalRounds: 5,
+ currentDrawerId: null,
+ currentWord: null,
+ roundStartTime: null,
+ roundTimeLimit: 60,
+ drawerOrder: [],
+ scores: {},
+ hintUsed: false,
+ correctGuessers: [],
+}
+
+const mockGameWords = [
+ { wordId: 'gw1', korean: '사과', english: 'apple' },
+ { wordId: 'gw2', korean: '바나나', english: 'banana' },
+ { wordId: 'gw3', korean: '컴퓨터', english: 'computer' },
+ { wordId: 'gw4', korean: '의자', english: 'chair' },
+ { wordId: 'gw5', korean: '책상', english: 'desk' },
+]
+
+/**
+ * 캐치마인드 게임 API - Backend: POST /rooms/{roomId}/game/start, stop, GET status, scores
+ */
+export const gameService = {
+ // POST /rooms/{roomId}/game/start - 게임 시작
+ start: async (roomId) => {
+ const mockDrawerOrder = ['user1', 'user2', 'user3']
+ const firstWord = mockGameWords[0]
+
+ mockGameState = {
+ gameStatus: 'PLAYING',
+ currentRound: 1,
+ totalRounds: 5,
+ currentDrawerId: TEMP_USER_ID,
+ currentWord: firstWord.korean,
+ currentWordEnglish: firstWord.english,
+ roundStartTime: Date.now(),
+ roundTimeLimit: 60,
+ drawerOrder: mockDrawerOrder,
+ scores: {},
+ hintUsed: false,
+ correctGuessers: [],
+ }
+
+ return withMock(
+ () => chatApi.post(`/rooms/${roomId}/game/start`, { userId: TEMP_USER_ID }),
+ {
+ gameStatus: mockGameState.gameStatus,
+ currentRound: mockGameState.currentRound,
+ totalRounds: mockGameState.totalRounds,
+ currentDrawerId: mockGameState.currentDrawerId,
+ roundStartTime: mockGameState.roundStartTime,
+ roundTimeLimit: mockGameState.roundTimeLimit,
+ drawerOrder: mockGameState.drawerOrder,
+ scores: mockGameState.scores,
+ // 출제자에게만 단어 제공
+ currentWord: mockGameState.currentWord,
+ currentWordEnglish: mockGameState.currentWordEnglish,
+ }
+ )
+ },
+
+ // POST /rooms/{roomId}/game/stop - 게임 중단
+ stop: async (roomId) => {
+ const finalScores = { ...mockGameState.scores }
+ mockGameState = {
+ gameStatus: 'NONE',
+ currentRound: 0,
+ totalRounds: 5,
+ currentDrawerId: null,
+ currentWord: null,
+ roundStartTime: null,
+ roundTimeLimit: 60,
+ drawerOrder: [],
+ scores: {},
+ hintUsed: false,
+ correctGuessers: [],
+ }
+
+ return withMock(
+ () => chatApi.post(`/rooms/${roomId}/game/stop`, { userId: TEMP_USER_ID }),
+ { message: '게임이 종료되었습니다.', scores: finalScores }
+ )
+ },
+
+ // GET /rooms/{roomId}/game/status - 게임 상태 조회
+ getStatus: async (roomId) => {
+ return withMock(
+ () => chatApi.get(`/rooms/${roomId}/game/status`),
+ {
+ gameStatus: mockGameState.gameStatus,
+ currentRound: mockGameState.currentRound,
+ totalRounds: mockGameState.totalRounds,
+ currentDrawerId: mockGameState.currentDrawerId,
+ roundStartTime: mockGameState.roundStartTime,
+ roundTimeLimit: mockGameState.roundTimeLimit,
+ drawerOrder: mockGameState.drawerOrder,
+ scores: mockGameState.scores,
+ hintUsed: mockGameState.hintUsed,
+ correctGuessers: mockGameState.correctGuessers,
+ // 출제자에게만 단어 제공 (실제로는 서버에서 userId 체크)
+ currentWord: mockGameState.currentWord,
+ }
+ )
+ },
+
+ // GET /rooms/{roomId}/game/scores - 점수 조회
+ getScores: async (roomId) => {
+ const sortedScores = Object.entries(mockGameState.scores)
+ .sort(([, a], [, b]) => b - a)
+ .map(([userId, score], index) => ({ rank: index + 1, userId, score }))
+
+ return withMock(
+ () => chatApi.get(`/rooms/${roomId}/game/scores`),
+ { scores: sortedScores, currentRound: mockGameState.currentRound, totalRounds: mockGameState.totalRounds }
+ )
+ },
+
+ // 정답 체크 (Mock에서 사용 - 실제로는 WebSocket으로 처리)
+ checkAnswer: async (roomId, answer) => {
+ const isCorrect = answer.trim().toLowerCase() === mockGameState.currentWord?.toLowerCase()
+
+ if (isCorrect && !mockGameState.correctGuessers.includes(TEMP_USER_ID)) {
+ const elapsedTime = Date.now() - mockGameState.roundStartTime
+ const timeBonus = Math.max(0, Math.floor((mockGameState.roundTimeLimit * 1000 - elapsedTime) / 1000 * 0.5))
+ const score = 10 + timeBonus
+
+ mockGameState.correctGuessers.push(TEMP_USER_ID)
+ mockGameState.scores[TEMP_USER_ID] = (mockGameState.scores[TEMP_USER_ID] || 0) + score
+
+ return withMock(
+ () => Promise.resolve({ data: { correct: true, score, elapsedTime } }),
+ { correct: true, score, elapsedTime, scores: mockGameState.scores }
+ )
+ }
+
+ return withMock(
+ () => Promise.resolve({ data: { correct: false } }),
+ { correct: false }
+ )
},
+
+ // 힌트 요청 (출제자만)
+ requestHint: async (roomId) => {
+ if (mockGameState.hintUsed) {
+ return withMock(
+ () => Promise.reject(new Error('이미 힌트를 사용했습니다.')),
+ { error: '이미 힌트를 사용했습니다.' }
+ )
+ }
+
+ const hint = mockGameState.currentWord?.charAt(0) + '○'.repeat((mockGameState.currentWord?.length || 1) - 1)
+ mockGameState.hintUsed = true
+
+ return withMock(
+ () => chatApi.post(`/rooms/${roomId}/game/hint`, { userId: TEMP_USER_ID }),
+ { hint, hintUsed: true }
+ )
+ },
+
+ // 라운드 스킵 (출제자만)
+ skipRound: async (roomId) => {
+ if (mockGameState.currentRound >= mockGameState.totalRounds) {
+ mockGameState.gameStatus = 'FINISHED'
+ return withMock(
+ () => chatApi.post(`/rooms/${roomId}/game/skip`, { userId: TEMP_USER_ID }),
+ { gameStatus: 'FINISHED', message: '게임이 종료되었습니다.' }
+ )
+ }
+
+ const nextRound = mockGameState.currentRound + 1
+ const nextWord = mockGameWords[nextRound - 1] || mockGameWords[0]
+ const nextDrawerIndex = (nextRound - 1) % mockGameState.drawerOrder.length
+
+ mockGameState = {
+ ...mockGameState,
+ currentRound: nextRound,
+ currentDrawerId: mockGameState.drawerOrder[nextDrawerIndex],
+ currentWord: nextWord.korean,
+ currentWordEnglish: nextWord.english,
+ roundStartTime: Date.now(),
+ hintUsed: false,
+ correctGuessers: [],
+ }
+
+ return withMock(
+ () => chatApi.post(`/rooms/${roomId}/game/skip`, { userId: TEMP_USER_ID }),
+ {
+ currentRound: mockGameState.currentRound,
+ currentDrawerId: mockGameState.currentDrawerId,
+ currentWord: mockGameState.currentWord,
+ message: `라운드 ${nextRound} 시작!`,
+ }
+ )
+ },
+}
+
+// 메시지 타입 상수
+export const MESSAGE_TYPES = {
+ TEXT: 'text',
+ IMAGE: 'image',
+ VOICE: 'voice',
+ AI_RESPONSE: 'ai_response',
+ GAME_START: 'game_start',
+ GAME_END: 'game_end',
+ ROUND_START: 'round_start',
+ ROUND_END: 'round_end',
+ DRAWING: 'drawing',
+ DRAWING_CLEAR: 'drawing_clear',
+ CORRECT_ANSWER: 'correct_answer',
+ SCORE_UPDATE: 'score_update',
+ SYSTEM_COMMAND: 'system_command',
+ HINT: 'hint',
+}
+
+// 게임 상태 상수
+export const GAME_STATUS = {
+ NONE: 'NONE',
+ PLAYING: 'PLAYING',
+ FINISHED: 'FINISHED',
}
export { TEMP_USER_ID }
diff --git a/src/domains/freetalk/components/ChatRoomModal.jsx b/src/domains/freetalk/components/ChatRoomModal.jsx
index ba971a9..87f9329 100644
--- a/src/domains/freetalk/components/ChatRoomModal.jsx
+++ b/src/domains/freetalk/components/ChatRoomModal.jsx
@@ -10,6 +10,9 @@ import {
Avatar,
Paper,
Fade,
+ Tabs,
+ Tab,
+ useTheme,
} from '@mui/material'
import {
Close as CloseIcon,
@@ -19,17 +22,17 @@ import {
ExitToApp as ExitToAppIcon,
Minimize as MinimizeIcon,
OpenInFull as MaximizeIcon,
+ SportsEsports as GameIcon,
+ Chat as ChatIcon,
} from '@mui/icons-material'
-import { chatRoomService, messageService, voiceService, TEMP_USER_ID } from '../../chat/services/chatService'
+import { chatRoomService, messageService, voiceService, gameService, TEMP_USER_ID, GAME_STATUS, MESSAGE_TYPES } from '../../chat/services/chatService'
import { useSettings } from '../../../contexts/SettingsContext'
-
-const levelColors = {
- beginner: { bg: '#e8f5e9', color: '#2e7d32', label: '초급' },
- intermediate: { bg: '#fff3e0', color: '#ef6c00', label: '중급' },
- advanced: { bg: '#fce4ec', color: '#c2185b', label: '고급' },
-}
+import { DESIGN_TOKENS, getLevelStyles, getChatStyles } from '../../../theme/theme'
+import GameModePanel from './GameModePanel'
const ChatRoomModal = ({ open, onClose, room, onLeave }) => {
+ const theme = useTheme()
+ const isDark = theme.palette.mode === 'dark'
const { settings } = useSettings()
const messagesEndRef = useRef(null)
const dragRef = useRef(null)
@@ -45,6 +48,8 @@ const ChatRoomModal = ({ open, onClose, room, onLeave }) => {
const [savedPosition, setSavedPosition] = useState({ x: 0, y: 0 })
const [isDragging, setIsDragging] = useState(false)
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 })
+ const [activeTab, setActiveTab] = useState(0) // 0: 채팅, 1: 게임
+ const [gameStatus, setGameStatus] = useState(GAME_STATUS.NONE)
// 메시지 목록 조회
const fetchMessages = useCallback(async () => {
if (!room?.id) return
@@ -67,15 +72,58 @@ const ChatRoomModal = ({ open, onClose, room, onLeave }) => {
}
}, [room?.id])
+ // 게임 상태 조회
+ const fetchGameStatus = useCallback(async () => {
+ if (!room?.id) return
+ try {
+ const response = await gameService.getStatus(room.id)
+ const data = response.data || response
+ setGameStatus(data.gameStatus || GAME_STATUS.NONE)
+ // 게임 중이면 게임 탭으로 전환
+ if (data.gameStatus === GAME_STATUS.PLAYING) {
+ setActiveTab(1)
+ }
+ } catch (err) {
+ console.error('Failed to fetch game status:', err)
+ }
+ }, [room?.id])
+
// 초기 로드
useEffect(() => {
if (open && room?.id) {
setLoading(true)
setMessages([])
setMinimized(false)
- fetchMessages().finally(() => setLoading(false))
+ setActiveTab(0)
+ Promise.all([
+ fetchMessages(),
+ fetchGameStatus(),
+ ]).finally(() => setLoading(false))
+ }
+ }, [open, room?.id, fetchMessages, fetchGameStatus])
+
+ // 게임 메시지 처리
+ const handleGameMessage = (gameMessage) => {
+ const systemMessage = {
+ id: `game-${Date.now()}`,
+ content: gameMessage.content,
+ userId: 'SYSTEM',
+ messageType: gameMessage.type,
+ createdAt: new Date(),
+ isOwn: false,
+ isSystem: true,
+ }
+ setMessages((prev) => [...prev, systemMessage])
+
+ // 게임 시작 시 게임 탭으로 전환
+ if (gameMessage.type === MESSAGE_TYPES.GAME_START) {
+ setGameStatus(GAME_STATUS.PLAYING)
+ setActiveTab(1)
+ } else if (gameMessage.type === MESSAGE_TYPES.GAME_END) {
+ setGameStatus(GAME_STATUS.NONE)
+ setActiveTab(0)
}
- }, [open, room?.id, fetchMessages])
+ }
// 스크롤 맨 아래로
const scrollToBottom = (instant = false) => {
@@ -243,12 +291,13 @@ const ChatRoomModal = ({ open, onClose, room, onLeave }) => {
top: position.y ? position.y : 'auto',
width: 380,
height: minimized ? 'auto' : 500,
- borderRadius: 2,
+ borderRadius: `${DESIGN_TOKENS.borderRadius.lg}px`,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
zIndex: 1300,
cursor: isDragging ? 'grabbing' : 'default',
+ border: isDark ? '1px solid rgba(255,255,255,0.1)' : 'none',
}}
>
{/* 헤더 - 드래그 가능 */}
@@ -271,7 +320,7 @@ const ChatRoomModal = ({ open, onClose, room, onLeave }) => {
{room?.level && (
{
{!minimized && (
<>
+ {/* 탭 (채팅/게임) */}
+ setActiveTab(v)}
+ variant="fullWidth"
+ sx={{
+ minHeight: 36,
+ '& .MuiTab-root': { minHeight: 36, py: 0.5 },
+ }}
+ >
+ }
+ iconPosition="start"
+ label="채팅"
+ sx={{ fontSize: '0.75rem' }}
+ />
+ }
+ iconPosition="start"
+ label="캐치마인드"
+ sx={{ fontSize: '0.75rem' }}
+ />
+
+
{/* 에러 메시지 */}
{error && (
setError(null)} sx={{ borderRadius: 0 }}>
@@ -307,132 +380,175 @@ const ChatRoomModal = ({ open, onClose, room, onLeave }) => {
)}
- {/* 메시지 영역 */}
- {loading ? (
-
-
+ {/* 게임 모드 */}
+ {activeTab === 1 && (
+
+
- ) : (
-
- {messages.length === 0 ? (
-
-
- 첫 메시지를 보내보세요!
-
-
- ) : (
- messages.map((message) => (
-
- {!message.isOwn && (
-
- {message.userId?.charAt(0)?.toUpperCase() || 'U'}
-
- )}
+ )}
+ {/* 채팅 모드 - 메시지 영역 */}
+ {activeTab === 0 && (
+ loading ? (
+
+
+
+ ) : (
+
+ {messages.length === 0 ? (
+
+
+ 첫 메시지를 보내보세요!
+
+
+ ) : (
+ messages.map((message) => (
- {!message.isOwn && (
-
- {message.userId}
-
- )}
-
-
- {message.isOwn && (
- <>
- handlePlayTTS(message.id)}
- disabled={playingTTS === message.id}
- sx={{ p: 0.25 }}
- >
- {playingTTS === message.id ? (
-
- ) : (
-
- )}
-
-
- {formatTime(message.createdAt)}
-
- >
- )}
-
-
-
- {message.content}
-
-
-
- {!message.isOwn && (
-
- handlePlayTTS(message.id)}
- disabled={playingTTS === message.id}
- sx={{ p: 0.25 }}
- >
- {playingTTS === message.id ? (
-
- ) : (
-
+ />
+ ) : (
+ <>
+ {!message.isOwn && (
+
+ {message.userId?.charAt(0)?.toUpperCase() || 'U'}
+
+ )}
+
+
+ {!message.isOwn && (
+
+ {message.userId}
+
+ )}
+
+
+ {message.isOwn && (
+ <>
+ handlePlayTTS(message.id)}
+ disabled={playingTTS === message.id}
+ sx={{ p: 0.25 }}
+ >
+ {playingTTS === message.id ? (
+
+ ) : (
+
+ )}
+
+
+ {formatTime(message.createdAt)}
+
+ >
)}
-
-
- {formatTime(message.createdAt)}
-
+
+
+
+ {message.content}
+
+
+
+ {!message.isOwn && (
+
+ handlePlayTTS(message.id)}
+ disabled={playingTTS === message.id}
+ sx={{ p: 0.25 }}
+ >
+ {playingTTS === message.id ? (
+
+ ) : (
+
+ )}
+
+
+ {formatTime(message.createdAt)}
+
+
+ )}
+
- )}
-
+ >
+ )}
-
- ))
- )}
-
-
+ ))
+ )}
+
+
+ )
)}
{/* 입력 영역 */}
diff --git a/src/domains/freetalk/components/GameModePanel.jsx b/src/domains/freetalk/components/GameModePanel.jsx
new file mode 100644
index 0000000..48135f1
--- /dev/null
+++ b/src/domains/freetalk/components/GameModePanel.jsx
@@ -0,0 +1,424 @@
+import { useState, useEffect, useRef, useCallback } from 'react'
+import {
+ Box,
+ Typography,
+ Button,
+ IconButton,
+ Paper,
+ Chip,
+ LinearProgress,
+ Tooltip,
+ Divider,
+ useTheme,
+} from '@mui/material'
+import {
+ PlayArrow as PlayIcon,
+ Stop as StopIcon,
+ SkipNext as SkipIcon,
+ Lightbulb as HintIcon,
+ Brush as BrushIcon,
+ Delete as ClearIcon,
+ EmojiEvents as TrophyIcon,
+} from '@mui/icons-material'
+import { gameService, GAME_STATUS, TEMP_USER_ID } from '../../chat/services/chatService'
+import { DESIGN_TOKENS } from '../../../theme/theme'
+
+const CANVAS_WIDTH = 340
+const CANVAS_HEIGHT = 200
+
+const GameModePanel = ({ roomId, onGameMessage }) => {
+ const theme = useTheme()
+ const isDark = theme.palette.mode === 'dark'
+ const canvasRef = useRef(null)
+ const [gameState, setGameState] = useState({
+ gameStatus: GAME_STATUS.NONE,
+ currentRound: 0,
+ totalRounds: 5,
+ currentDrawerId: null,
+ currentWord: null,
+ roundStartTime: null,
+ roundTimeLimit: 60,
+ scores: {},
+ hintUsed: false,
+ })
+ const [timeLeft, setTimeLeft] = useState(60)
+ const [isDrawing, setIsDrawing] = useState(false)
+ const [brushColor, setBrushColor] = useState('#000000')
+ const [brushSize, setBrushSize] = useState(3)
+ const [loading, setLoading] = useState(false)
+
+ const isDrawer = gameState.currentDrawerId === TEMP_USER_ID
+ const isGameActive = gameState.gameStatus === GAME_STATUS.PLAYING
+
+ // 게임 상태 조회
+ const fetchGameStatus = useCallback(async () => {
+ try {
+ const response = await gameService.getStatus(roomId)
+ const data = response.data || response
+ setGameState(data)
+ } catch (err) {
+ console.error('Failed to fetch game status:', err)
+ }
+ }, [roomId])
+
+ // 타이머
+ useEffect(() => {
+ if (!isGameActive || !gameState.roundStartTime) return
+
+ 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) {
+ // 시간 초과 처리
+ clearInterval(interval)
+ }
+ }, 1000)
+
+ 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)
+ }
+ }
+
+ // 게임 종료
+ 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)
+ }
+ }
+
+ // 라운드 스킵
+ const handleSkipRound = async () => {
+ try {
+ const response = await gameService.skipRound(roomId)
+ const data = response.data || response
+ if (data.gameStatus === GAME_STATUS.FINISHED) {
+ setGameState(prev => ({ ...prev, gameStatus: GAME_STATUS.FINISHED }))
+ } else {
+ setGameState(prev => ({
+ ...prev,
+ currentRound: data.currentRound,
+ currentDrawerId: data.currentDrawerId,
+ currentWord: data.currentWord,
+ roundStartTime: Date.now(),
+ hintUsed: false,
+ }))
+ }
+ onGameMessage?.({
+ type: 'round_end',
+ content: data.message,
+ })
+ } catch (err) {
+ console.error('Failed to skip round:', err)
+ }
+ }
+
+ // 힌트 제공
+ const handleHint = async () => {
+ try {
+ const response = await gameService.requestHint(roomId)
+ const data = response.data || response
+ if (data.hint) {
+ setGameState(prev => ({ ...prev, hintUsed: true }))
+ onGameMessage?.({
+ type: 'hint',
+ content: `💡 힌트: ${data.hint}`,
+ })
+ }
+ } catch (err) {
+ console.error('Failed to get hint:', err)
+ }
+ }
+
+ // 캔버스 초기화
+ const clearCanvas = () => {
+ const canvas = canvasRef.current
+ if (!canvas) return
+ const ctx = canvas.getContext('2d')
+ ctx.fillStyle = '#ffffff'
+ ctx.fillRect(0, 0, canvas.width, canvas.height)
+ }
+
+ // 캔버스 드로잉
+ const startDrawing = (e) => {
+ if (!isDrawer) return
+ setIsDrawing(true)
+ draw(e)
+ }
+
+ const stopDrawing = () => {
+ setIsDrawing(false)
+ const canvas = canvasRef.current
+ if (canvas) {
+ const ctx = canvas.getContext('2d')
+ ctx.beginPath()
+ }
+ }
+
+ const draw = (e) => {
+ if (!isDrawing || !isDrawer) return
+ const canvas = canvasRef.current
+ if (!canvas) return
+
+ const ctx = canvas.getContext('2d')
+ const rect = canvas.getBoundingClientRect()
+ const x = e.clientX - rect.left
+ const y = e.clientY - rect.top
+
+ ctx.lineWidth = brushSize
+ ctx.lineCap = 'round'
+ ctx.strokeStyle = brushColor
+ ctx.lineTo(x, y)
+ ctx.stroke()
+ ctx.beginPath()
+ ctx.moveTo(x, y)
+ }
+
+ // 캔버스 초기화
+ useEffect(() => {
+ if (canvasRef.current) {
+ clearCanvas()
+ }
+ }, [gameState.currentRound])
+
+ // 점수 정렬
+ const sortedScores = Object.entries(gameState.scores || {})
+ .sort(([, a], [, b]) => b - a)
+ .slice(0, 5)
+
+ if (gameState.gameStatus === GAME_STATUS.NONE) {
+ return (
+
+
+ 캐치마인드 게임을 시작해보세요!
+
+ }
+ onClick={handleStartGame}
+ disabled={loading}
+ size="small"
+ >
+ 게임 시작
+
+
+ 또는 채팅창에 /start 입력
+
+
+ )
+ }
+
+ return (
+
+ {/* 게임 헤더 */}
+
+
+
+
+ 출제자: {gameState.currentDrawerId}
+
+
+
+
+ {Math.floor(timeLeft / 60)}:{(timeLeft % 60).toString().padStart(2, '0')}
+
+
+
+
+ {/* 타이머 진행바 */}
+
+
+ {/* 출제어 (출제자만 보임) */}
+ {isDrawer && (
+
+
+ 제시어
+
+
+ {gameState.currentWord}
+
+
+ )}
+
+ {/* 캔버스 영역 */}
+
+
+
+ {/* 그리기 도구 (출제자만) */}
+ {isDrawer && (
+
+ {['#000000', '#ff0000', '#0000ff', '#00ff00', '#ffff00', '#ff00ff'].map((color) => (
+ setBrushColor(color)}
+ sx={{
+ width: 20,
+ height: 20,
+ bgcolor: color,
+ borderRadius: '50%',
+ border: brushColor === color
+ ? `2px solid ${isDark ? 'white' : '#333'}`
+ : `1px solid ${isDark ? 'rgba(255,255,255,0.3)' : '#ccc'}`,
+ cursor: 'pointer',
+ }}
+ />
+ ))}
+
+
+
+
+
+
+ )}
+
+
+ {/* 점수판 */}
+ {sortedScores.length > 0 && (
+
+
+
+
+ 점수
+
+
+
+ {sortedScores.map(([userId, score], idx) => (
+
+ ))}
+
+
+ )}
+
+ {/* 게임 컨트롤 */}
+
+ {isDrawer ? (
+ <>
+ }
+ onClick={handleHint}
+ disabled={gameState.hintUsed}
+ >
+ 힌트
+
+ }
+ onClick={handleSkipRound}
+ >
+ 스킵
+
+ >
+ ) : (
+
+ 정답을 채팅으로 입력하세요!
+
+ )}
+ }
+ onClick={handleStopGame}
+ disabled={loading}
+ >
+ 종료
+
+
+
+ )
+}
+
+export default GameModePanel
diff --git a/src/domains/vocab/components/FlashCard.jsx b/src/domains/vocab/components/FlashCard.jsx
index 37e9f72..500fece 100644
--- a/src/domains/vocab/components/FlashCard.jsx
+++ b/src/domains/vocab/components/FlashCard.jsx
@@ -1,18 +1,33 @@
import { Box, Typography, IconButton, Chip } from '@mui/material'
-import { VolumeUp as VolumeIcon } from '@mui/icons-material'
+import { VolumeUp as VolumeIcon, TouchApp as TapIcon } from '@mui/icons-material'
import { LEVEL_LABELS, LEVEL_COLORS, CATEGORY_LABELS } from '../constants/vocabConstants'
export default function FlashCard({ word, isFlipped, onFlip, onPlayTTS, isPlayingTTS }) {
if (!word) return null
+ const getLevelStyle = (level) => {
+ switch (level) {
+ case 'BEGINNER':
+ return { bg: '#ecfdf5', color: '#059669' }
+ case 'INTERMEDIATE':
+ return { bg: '#fff7ed', color: '#f97316' }
+ case 'ADVANCED':
+ return { bg: '#fef2f2', color: '#ef4444' }
+ default:
+ return { bg: '#f5f5f4', color: '#57534e' }
+ }
+ }
+
+ const levelStyle = getLevelStyle(word.level)
+
return (
- {/* 앞면 - 영어 */}
+ {/* Front - English */}
-
+ {/* Decorative corner */}
+
+
+ {/* Level badge */}
+
+
+ {LEVEL_LABELS[word.level]}
+
+
+
+
{word.english}
@@ -54,35 +112,59 @@ export default function FlashCard({ word, isFlipped, onFlip, onPlayTTS, isPlayin
onPlayTTS?.()
}}
disabled={isPlayingTTS}
- sx={{ mb: 2 }}
+ sx={{
+ width: 56,
+ height: 56,
+ backgroundColor: isPlayingTTS ? '#059669' : '#f5f5f4',
+ mb: 3,
+ transition: 'all 0.2s ease',
+ '&:hover': {
+ backgroundColor: isPlayingTTS ? '#047857' : '#e7e5e4',
+ transform: 'scale(1.05)',
+ },
+ }}
>
-
+
{word.example && (
"{word.example}"
)}
-
- 탭하여 뜻 보기
-
+
+
+ Tap to reveal meaning
+
+
- {/* 뒷면 - 한국어 */}
+ {/* Back - Korean */}
-
+ {/* Decorative elements */}
+
+
+
+
+ MEANING
+
+
+
{word.korean}
@@ -112,24 +239,39 @@ export default function FlashCard({ word, isFlipped, onFlip, onPlayTTS, isPlayin
sx={{
backgroundColor: 'rgba(255,255,255,0.2)',
color: 'white',
+ fontWeight: 600,
+ backdropFilter: 'blur(8px)',
}}
/>
-
+ {word.category && (
+
+ )}
-
- 탭하여 영어 보기
-
+
+
+ Tap to see word
+
+
diff --git a/src/domains/vocab/components/TestQuestion.jsx b/src/domains/vocab/components/TestQuestion.jsx
index 880d42f..b038197 100644
--- a/src/domains/vocab/components/TestQuestion.jsx
+++ b/src/domains/vocab/components/TestQuestion.jsx
@@ -9,114 +9,240 @@ export default function TestQuestion({
}) {
if (!question) return null
- const getOptionStyle = (option) => {
+ const getOptionStyle = (option, index) => {
+ const isSelected = selectedAnswer === option
+ const isCorrect = option === question.correctAnswer
+
if (!showResult) {
return {
- border: selectedAnswer === option ? '2px solid' : '1px solid',
- borderColor: selectedAnswer === option ? 'primary.main' : 'divider',
- backgroundColor: selectedAnswer === option ? 'primary.50' : 'background.paper',
+ border: isSelected ? '2px solid #059669' : '2px solid #e7e5e4',
+ backgroundColor: isSelected ? '#ecfdf5' : '#ffffff',
+ transform: isSelected ? 'scale(1.02)' : 'scale(1)',
}
}
- // 결과 표시 모드
- const isCorrect = option === question.correctAnswer
- const isSelected = option === selectedAnswer
-
+ // Result mode
if (isCorrect) {
return {
- border: '2px solid',
- borderColor: 'success.main',
- backgroundColor: 'success.50',
+ border: '2px solid #10b981',
+ backgroundColor: '#ecfdf5',
}
}
if (isSelected && !isCorrect) {
return {
- border: '2px solid',
- borderColor: 'error.main',
- backgroundColor: 'error.50',
+ border: '2px solid #ef4444',
+ backgroundColor: '#fef2f2',
}
}
return {
- border: '1px solid',
- borderColor: 'divider',
- backgroundColor: 'background.paper',
- opacity: 0.6,
+ border: '2px solid #e7e5e4',
+ backgroundColor: '#fafaf9',
+ opacity: 0.5,
}
}
+ const optionLabels = ['A', 'B', 'C', 'D']
+
return (
- {/* 문제 */}
+ {/* Question Card */}
-
- {question.question}
+ {/* Decorative circles */}
+
+
+
+
+ WHAT DOES THIS MEAN?
-
- {question.type === 'KOREAN_TO_ENGLISH'
- ? '다음 중 올바른 영어 단어는?'
- : '다음 중 올바른 한국어 뜻은?'}
+
+
+ {question.english || question.question}
+
+ {question.example && (
+
+ "{question.example}"
+
+ )}
- {/* 선택지 */}
+ {/* Options */}
!disabled && onSelect(e.target.value)}
>
-
- {question.options.map((option, index) => (
- !disabled && onSelect(option)}
- sx={{
- p: 2,
- cursor: disabled ? 'default' : 'pointer',
- transition: 'all 0.2s',
- ...getOptionStyle(option),
- '&:hover': disabled
- ? {}
- : {
- borderColor: 'primary.main',
- transform: 'translateX(4px)',
- },
- }}
- >
-
+ {question.options.map((option, index) => {
+ const isSelected = selectedAnswer === option
+ const isCorrect = showResult && option === question.correctAnswer
+ const isWrong = showResult && isSelected && !isCorrect
+
+ return (
+ !disabled && onSelect(option)}
+ sx={{
+ p: 0,
+ cursor: disabled ? 'default' : 'pointer',
+ transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
+ borderRadius: '16px',
+ ...getOptionStyle(option, index),
+ '&:hover': disabled
+ ? {}
+ : {
+ borderColor: '#059669',
+ transform: 'translateX(4px)',
+ boxShadow: '0 4px 12px -4px rgba(5, 150, 105, 0.2)',
+ },
+ }}
+ >
+
+ {/* Option Label */}
+
- }
- label={
+ >
+
+ {optionLabels[index]}
+
+
+
+ {/* Option Text */}
- {`${['①', '②', '③', '④'][index]} ${option}`}
+ {option}
- }
- sx={{ m: 0, width: '100%' }}
- />
-
- ))}
+
+ {/* Result Icon */}
+ {showResult && isCorrect && (
+
+ ✓
+
+ )}
+ {showResult && isWrong && (
+
+ ✕
+
+ )}
+
+
+ )
+ })}
diff --git a/src/domains/vocab/components/WordDetailModal.jsx b/src/domains/vocab/components/WordDetailModal.jsx
index c4927bc..60822a4 100644
--- a/src/domains/vocab/components/WordDetailModal.jsx
+++ b/src/domains/vocab/components/WordDetailModal.jsx
@@ -11,6 +11,7 @@ import {
ToggleButton,
ToggleButtonGroup,
Divider,
+ Paper,
} from '@mui/material'
import {
Close as CloseIcon,
@@ -19,6 +20,8 @@ import {
StarBorder as StarBorderIcon,
Favorite as FavoriteIcon,
FavoriteBorder as FavoriteBorderIcon,
+ CheckCircle as CheckIcon,
+ Cancel as CancelIcon,
} from '@mui/icons-material'
import {
LEVEL_LABELS,
@@ -30,6 +33,7 @@ import {
DIFFICULTY_LABELS,
VOICE_TYPES,
} from '../constants/vocabConstants'
+import { useTranslation } from '../../../contexts/SettingsContext'
export default function WordDetailModal({
open,
@@ -42,32 +46,197 @@ export default function WordDetailModal({
onSetDifficulty,
isPlayingTTS,
}) {
+ const { t } = useTranslation()
const [selectedVoice, setSelectedVoice] = useState(VOICE_TYPES.FEMALE)
if (!word) return null
+ const wordData = word
+ const userData = userWord || word
+
const handlePlayTTS = () => {
onPlayTTS?.(selectedVoice)
}
+ const getLevelStyle = (level) => {
+ switch (level) {
+ case 'BEGINNER':
+ return { bg: '#ecfdf5', color: '#059669' }
+ case 'INTERMEDIATE':
+ return { bg: '#fff7ed', color: '#f97316' }
+ case 'ADVANCED':
+ return { bg: '#fef2f2', color: '#ef4444' }
+ default:
+ return { bg: '#f5f5f4', color: '#57534e' }
+ }
+ }
+
+ const getStatusStyle = (status) => {
+ switch (status) {
+ case 'MASTERED':
+ return { bg: '#ecfdf5', color: '#059669' }
+ case 'REVIEWING':
+ return { bg: '#eff6ff', color: '#3b82f6' }
+ case 'LEARNING':
+ return { bg: '#fff7ed', color: '#f97316' }
+ default:
+ return { bg: '#f5f5f4', color: '#57534e' }
+ }
+ }
+
+ const levelStyle = getLevelStyle(wordData.level)
+ const statusStyle = getStatusStyle(userData?.status)
+
+ const totalAttempts = (userData?.correctCount || 0) + (userData?.incorrectCount || 0)
+ const accuracy = totalAttempts > 0
+ ? ((userData?.correctCount || 0) / totalAttempts * 100).toFixed(0)
+ : 0
+
return (
-