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 ( + + + 캐치마인드 게임을 시작해보세요! + + + + 또는 채팅창에 /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 ? ( + <> + + + + ) : ( + + 정답을 채팅으로 입력하세요! + + )} + + + + ) +} + +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 ( - - - - - {word.english} - - + + {/* 헤더 */} + + {/* 장식 요소 */} + + + + + + + {wordData.english} + + + {wordData.level && ( + + )} + {wordData.category && ( + + )} + + + - + + + + {/* 뜻 */} + + + {wordData.korean} + + {userData?.status && ( + + )} + + + {/* 예문 */} + {wordData.example && ( + + + {t('wordDetail.example')} + + + "{wordData.example}" + + + )} - {/* TTS */} - - - 발음 듣기 + + + {t('wordDetail.pronunciation')} val && setSelectedVoice(val)} size="small" + sx={{ + '& .MuiToggleButton-root': { + px: 2, + '&.Mui-selected': { + backgroundColor: '#059669', + color: 'white', + '&:hover': { backgroundColor: '#047857' }, + }, + }, + }} > - 여성 - 남성 + {t('wordDetail.voiceFemale')} + {t('wordDetail.voiceMale')} - {/* 뜻 */} - - - {word.korean} - - - - - - - - {/* 예문 */} - {word.example && ( - - - 예문 - - - "{word.example}" - - - )} - - - {/* 학습 현황 */} - {userWord && ( - - - 학습 현황 + {userData && (userData.correctCount > 0 || userData.incorrectCount > 0) && ( + + + {t('wordDetail.learningStatus')} - - - 정답 - - {userWord.correctCount || 0}회 - - - - 오답 - - {userWord.incorrectCount || 0}회 - - - - 정확도 - - {userWord.correctCount + userWord.incorrectCount > 0 - ? ( - (userWord.correctCount / - (userWord.correctCount + userWord.incorrectCount)) * - 100 - ).toFixed(1) - : 0} - % - - - - 상태 - + + + + + + + {userData.correctCount || 0} + + {t('wordDetail.correctCount')} + + + + + + + {userData.incorrectCount || 0} + + {t('wordDetail.incorrectCount')} + + + = 80 ? '#ecfdf5' : accuracy >= 50 ? '#fff7ed' : '#fef2f2', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + mx: 'auto', + mb: 1, + }} + > + = 80 ? '#10b981' : accuracy >= 50 ? '#f97316' : '#ef4444', + }} + > + % + + + = 80 ? '#10b981' : accuracy >= 50 ? '#f97316' : '#ef4444', + }} + > + {accuracy} + + {t('wordDetail.accuracyLabel')} + - {userWord.nextReviewAt && ( - - 다음 복습 + + {userData.lastReviewedAt && ( + + {t('wordDetail.lastReviewed')} - {new Date(userWord.nextReviewAt).toLocaleDateString()} + {new Date(userData.lastReviewedAt).toLocaleDateString('ko-KR')} )} - + {userData.nextReviewAt && ( + + {t('wordDetail.nextReview')} + + {new Date(userData.nextReviewAt).toLocaleDateString('ko-KR')} + + + )} + )} + + {/* 액션 */} - - {userWord?.bookmarked ? ( - + + {userData?.bookmarked ? ( + ) : ( - + )} - - {userWord?.favorite ? ( - + + {userData?.favorite ? ( + ) : ( - + )} - - - 난이도 + + + {t('wordDetail.difficulty')} val && onSetDifficulty?.(val)} size="small" + sx={{ + '& .MuiToggleButton-root': { + px: 1.5, + fontSize: 12, + '&.Mui-selected': { + backgroundColor: '#059669', + color: 'white', + '&:hover': { backgroundColor: '#047857' }, + }, + }, + }} > {Object.entries(DIFFICULTY_LABELS).map(([key, label]) => ( diff --git a/src/domains/vocab/pages/DailyLearning.jsx b/src/domains/vocab/pages/DailyLearning.jsx index 7100655..c2130bf 100644 --- a/src/domains/vocab/pages/DailyLearning.jsx +++ b/src/domains/vocab/pages/DailyLearning.jsx @@ -13,8 +13,9 @@ import { Paper, Switch, FormControlLabel, - ToggleButton, - ToggleButtonGroup, + Card, + CardActionArea, + CardContent, } from '@mui/material' import { ArrowBack as BackIcon, @@ -25,16 +26,124 @@ import { Check as CheckIcon, Close as CloseIcon, Celebration as CelebrationIcon, + School as SchoolIcon, + AutoAwesome as SparkleIcon, } from '@mui/icons-material' import FlashCard from '../components/FlashCard' import { dailyService, userWordService, voiceService } from '../services/vocabService' -import { DIFFICULTY, DIFFICULTY_LABELS } from '../constants/vocabConstants' +import { LEVELS, LEVEL_LABELS } from '../constants/vocabConstants' +import { useTranslation } from '../../../contexts/SettingsContext' const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1' +// Level Selection Screen +function LevelSelect({ onSelect, loading, t, isKorean }) { + const levelConfig = { + [LEVELS.BEGINNER]: { + description: isKorean ? '기초 어휘 학습' : 'Basic vocabulary', + color: '#059669', + bgColor: '#ecfdf5', + icon: '🌱', + }, + [LEVELS.INTERMEDIATE]: { + description: isKorean ? '실용 어휘 확장' : 'Intermediate vocabulary', + color: '#f97316', + bgColor: '#fff7ed', + icon: '🌿', + }, + [LEVELS.ADVANCED]: { + description: isKorean ? '전문 어휘 마스터' : 'Advanced vocabulary', + color: '#ef4444', + bgColor: '#fef2f2', + icon: '🌳', + }, + } + + return ( + + + + + + + {t('dailyLearning.selectLevel')} + + + {isKorean ? '난이도를 선택하여 학습을 시작하세요' : 'Select a difficulty to start learning'} + + + + + {Object.entries(LEVEL_LABELS).map(([level, label]) => { + const config = levelConfig[level] + return ( + + !loading && onSelect(level)} disabled={loading}> + + + + {config.icon} + + + + {label} + + + {config.description} + + + {loading && } + + + + + ) + })} + + + ) +} + export default function DailyLearning() { const navigate = useNavigate() - const [loading, setLoading] = useState(true) + const { t, isKorean } = useTranslation() + const [phase, setPhase] = useState('loading') + const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [words, setWords] = useState([]) const [currentIndex, setCurrentIndex] = useState(0) @@ -42,43 +151,66 @@ export default function DailyLearning() { const [learnedIds, setLearnedIds] = useState(new Set()) const [autoPlayTTS, setAutoPlayTTS] = useState(false) const [isPlayingTTS, setIsPlayingTTS] = useState(false) - const [isCompleted, setIsCompleted] = useState(false) const [results, setResults] = useState({ correct: 0, incorrect: 0 }) + const [swipeDirection, setSwipeDirection] = useState(null) + const [isEntering, setIsEntering] = useState(false) useEffect(() => { fetchDailyWords() }, []) - const fetchDailyWords = async () => { + const fetchDailyWords = async (level = null) => { try { setLoading(true) setError(null) - const response = await dailyService.getWords(TEMP_USER_ID) + + const response = await dailyService.getWords(TEMP_USER_ID, level) + const dailyData = response?.data || response + const allWords = [ - ...(response?.data?.newWords || []), - ...(response?.data?.reviewWords || []), + ...(dailyData?.newWords || []), + ...(dailyData?.reviewWords || []), ] + + if (allWords.length === 0) { + setError('No words to learn.') + setPhase('select') + return + } + setWords(allWords) - // 이미 학습한 단어 체크 - const learnedCount = response?.data?.learnedCount || 0 + const learnedCount = dailyData?.learnedCount || 0 if (learnedCount > 0 && learnedCount < allWords.length) { const learned = new Set(allWords.slice(0, learnedCount).map(w => w.wordId)) setLearnedIds(learned) setCurrentIndex(learnedCount) } - if (response?.data?.isCompleted) { - setIsCompleted(true) + if (dailyData?.isCompleted) { + setPhase('complete') + } else { + setPhase('learning') } } catch (err) { console.error('Fetch daily words error:', err) - setError('단어를 불러오는데 실패했습니다.') + const errorMsg = err.response?.data?.message || err.message || '' + + if (errorMsg.includes('level') || err.response?.status === 400) { + setPhase('select') + } else { + setError('Failed to load words.') + setPhase('select') + } } finally { setLoading(false) } } + const handleLevelSelect = (level) => { + fetchDailyWords(level) + } + const currentWord = words[currentIndex] const progress = words.length > 0 ? (learnedIds.size / words.length) * 100 : 0 @@ -87,8 +219,8 @@ export default function DailyLearning() { try { setIsPlayingTTS(true) const response = await voiceService.synthesize(word.wordId, word.english) - if (response?.data?.audioUrl) { - const audio = new Audio(response.data.audioUrl) + if (response?.audioUrl) { + const audio = new Audio(response.audioUrl) audio.onended = () => setIsPlayingTTS(false) audio.onerror = () => setIsPlayingTTS(false) await audio.play() @@ -102,40 +234,39 @@ export default function DailyLearning() { }, [isPlayingTTS]) useEffect(() => { - if (autoPlayTTS && currentWord && !isFlipped) { + if (autoPlayTTS && currentWord && !isFlipped && phase === 'learning') { playTTS(currentWord) } - }, [currentIndex, autoPlayTTS]) + }, [currentIndex, autoPlayTTS, phase]) const handleFlip = () => { setIsFlipped(!isFlipped) } const handleAnswer = async (isCorrect) => { - if (!currentWord) return + if (!currentWord || swipeDirection) return + + setSwipeDirection(isCorrect ? 'right' : 'left') try { - // API 호출 await userWordService.update(TEMP_USER_ID, currentWord.wordId, isCorrect) - // 결과 업데이트 setResults(prev => ({ ...prev, [isCorrect ? 'correct' : 'incorrect']: prev[isCorrect ? 'correct' : 'incorrect'] + 1 })) - // 학습 완료 표시 setLearnedIds(prev => new Set([...prev, currentWord.wordId])) - - // 다음 카드로 이동 - moveToNext() } catch (err) { console.error('Answer update error:', err) } - } - const handleSkip = () => { - moveToNext() + setTimeout(() => { + setSwipeDirection(null) + setIsEntering(true) + moveToNext() + setTimeout(() => setIsEntering(false), 200) + }, 250) } const moveToNext = () => { @@ -143,7 +274,7 @@ export default function DailyLearning() { if (currentIndex < words.length - 1) { setCurrentIndex(prev => prev + 1) } else { - setIsCompleted(true) + setPhase('complete') } } @@ -154,7 +285,6 @@ export default function DailyLearning() { await userWordService.updateTag(TEMP_USER_ID, currentWord.wordId, { bookmarked: newBookmarked, }) - // 로컬 상태 업데이트 setWords(prev => prev.map(w => w.wordId === currentWord.wordId ? { ...w, bookmarked: newBookmarked } : w @@ -165,100 +295,143 @@ export default function DailyLearning() { } } - const handleSetDifficulty = async (difficulty) => { - if (!currentWord || !difficulty) return - try { - await userWordService.updateTag(TEMP_USER_ID, currentWord.wordId, { - difficulty, - }) - setWords(prev => - prev.map(w => - w.wordId === currentWord.wordId ? { ...w, difficulty } : w - ) - ) - } catch (err) { - console.error('Difficulty error:', err) - } - } - const handleRestart = () => { setCurrentIndex(0) setLearnedIds(new Set()) setIsFlipped(false) - setIsCompleted(false) + setPhase('learning') setResults({ correct: 0, incorrect: 0 }) } - if (loading) { + // Loading Screen + if (phase === 'loading') { return ( - + ) } - if (error) { + // Level Selection Screen + if (phase === 'select') { return ( - - {error} - + + navigate('/vocab')}> + + + + {t('dailyLearning.title')} + + + {error && ( + setError(null)}> + {error} + + )} + + ) } - // 학습 완료 화면 - if (isCompleted) { + // Completion Screen + if (phase === 'complete') { const totalAnswered = results.correct + results.incorrect const accuracy = totalAnswered > 0 ? (results.correct / totalAnswered) * 100 : 0 return ( - - - 학습 완료! + + + + + + {t('dailyLearning.greatJob')} - 오늘의 학습을 완료했습니다 + {t('dailyLearning.completedSession')} - - 학습 결과 - + + - + {results.correct} - 정답 + + {t('dailyLearning.correct')} + - + {results.incorrect} - 오답 + + {t('dailyLearning.incorrect')} + - - 정확도: {accuracy.toFixed(1)}% - + = 80 ? '#ecfdf5' : accuracy >= 50 ? '#fff7ed' : '#fef2f2', + }} + > + = 80 ? '#059669' : accuracy >= 50 ? '#f97316' : '#ef4444' }} /> + = 80 ? '#059669' : accuracy >= 50 ? '#f97316' : '#ef4444' }} + > + {accuracy.toFixed(0)}% {t('dailyLearning.accuracy')} + + @@ -266,9 +439,10 @@ export default function DailyLearning() { ) } + // Learning Screen return ( - - {/* 헤더 */} + + {/* Header */} navigate('/vocab')}> - - 오늘의 학습 ({currentIndex + 1}/{words.length}) - + + + {currentIndex + 1} / {words.length} + + setAutoPlayTTS(e.target.checked)} size="small" + sx={{ + '& .MuiSwitch-switchBase.Mui-checked': { + color: '#059669', + }, + '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { + backgroundColor: '#059669', + }, + }} /> } - label={} + label={} /> - {/* 진행률 바 */} + {/* Progress Bar */} - - 진행률 + + {t('dailyLearning.progress')} - + {Math.round(progress)}% - {/* 플래시카드 */} - + {/* FlashCard */} + - {/* 정답/오답 버튼 */} + {/* Answer Buttons */} - {/* 액션 바 */} - - - + {/* Navigation */} + + + + + {currentWord?.bookmarked ? ( - + ) : ( - + )} - handleSetDifficulty(val)} - size="small" + ) diff --git a/src/domains/vocab/pages/StatsPage.jsx b/src/domains/vocab/pages/StatsPage.jsx index 66d5984..e2fb7a2 100644 --- a/src/domains/vocab/pages/StatsPage.jsx +++ b/src/domains/vocab/pages/StatsPage.jsx @@ -31,6 +31,7 @@ import { DIFFICULTY_LABELS, VOICE_TYPES, } from '../constants/vocabConstants' +import { useTranslation } from '../../../contexts/SettingsContext' const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1' @@ -307,6 +308,7 @@ function StatCard({ title, value, subtitle, icon: Icon, color }) { export default function StatsPage() { const navigate = useNavigate() + const { t } = useTranslation() const [tab, setTab] = useState(0) // 0: 일간, 1: 주간, 2: 월간 const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -376,8 +378,8 @@ export default function StatsPage() { voiceType: VOICE_TYPES.FEMALE, }) - if (response?.data?.audioUrl) { - const audio = new Audio(response.data.audioUrl) + if (response?.audioUrl) { + const audio = new Audio(response.audioUrl) audio.onended = () => setPlayingWordId(null) audio.onerror = () => setPlayingWordId(null) await audio.play() @@ -401,14 +403,14 @@ export default function StatsPage() { } return ( - + {/* 헤더 */} navigate('/vocab')}> - 학습 통계 + {t('stats.title')} @@ -425,25 +427,25 @@ export default function StatsPage() { sx={{ mb: 3 }} variant="fullWidth" > - - - + + + {/* 요약 카드 */} @@ -471,7 +473,7 @@ export default function StatsPage() { {/* 학습 캘린더 */} - 학습 기록 + {t('stats.learningHistory')} @@ -479,7 +481,7 @@ export default function StatsPage() { {/* 레벨별 진행률 */} - 레벨별 진행률 + {t('stats.levelProgress')} @@ -487,7 +489,7 @@ export default function StatsPage() { {/* 난이도 분포 */} - 난이도 분포 + {t('stats.difficultyDist')} @@ -496,10 +498,10 @@ export default function StatsPage() { - 취약 단어 TOP 10 + {t('stats.weakWordsTop10')} { - onStart({ wordCount, level, type }) - } - +// Test Setup Screen +function TestSetup({ onStart, recentResults, loading, t }) { return ( - - - 시험 설정 - - - {/* 문제 수 */} - - - 문제 수 - - val && setWordCount(val)} - fullWidth - > - 10개 - 20개 - 30개 - - + + {/* Decorative elements */} + + - {/* 레벨 */} - - - 레벨 - - setLevel(val)} - fullWidth - > - 전체 - {Object.entries(LEVEL_LABELS).map(([key, label]) => ( - - {label} - - ))} - + + - {/* 출제 유형 */} - - - - 출제 유형 - - setType(e.target.value)} - > - {Object.entries(TEST_TYPE_LABELS).map(([key, label]) => ( - } - label={label} - /> - ))} - - - + + {t('test.title')} + + + {t('test.subtitle')} + - {/* 최근 시험 기록 */} + {/* Recent Results */} {recentResults.length > 0 && ( - - 최근 시험 기록 + + {t('test.recentResults')} - {recentResults.map((result, index) => ( - - - - - - {new Date(result.completedAt).toLocaleDateString()} - - - {result.totalQuestions}문제 - + + {recentResults.map((result, index) => ( + + + + + + {result.completedAt ? new Date(result.completedAt).toLocaleDateString() : '-'} + + + {result.totalQuestions || 0} {t('test.question')} + + + = 80 + ? '#ecfdf5' + : result.successRate >= 60 + ? '#fff7ed' + : '#fef2f2', + }} + > + = 80 + ? '#059669' + : result.successRate >= 60 + ? '#f97316' + : '#ef4444', + }} + > + {result.successRate?.toFixed(0) || 0}% + + - = 80 ? 'success' : result.successRate >= 60 ? 'warning' : 'error'} - /> - - - - ))} + + + ))} + )} ) } -// 시험 진행 화면 +// Test In Progress Screen function TestInProgress({ questions, currentIndex, @@ -165,94 +183,130 @@ function TestInProgress({ onNext, onPrev, onSubmit, + t, }) { const currentQuestion = questions[currentIndex] const progress = ((currentIndex + 1) / questions.length) * 100 - const minutes = Math.floor(timeRemaining / 60) - const seconds = timeRemaining % 60 + const timerProgress = (timeRemaining / QUESTION_TIME_LIMIT) * 100 + + if (!currentQuestion) return null return ( - {/* 헤더 */} - - - 문제 {currentIndex + 1} / {questions.length} + {/* Header */} + + + {currentIndex + 1} / {questions.length} - } - label={`${minutes}:${seconds.toString().padStart(2, '0')}`} - color={timeRemaining < 60 ? 'error' : 'default'} - /> + + + + {timeRemaining} + + - {/* 진행률 */} + {/* Timer Progress */} + + + {/* Overall Progress */} - {/* 문제 */} + {/* Question */} onAnswer(currentQuestion.questionId, answer)} + selectedAnswer={answers[currentQuestion.wordId]} + onSelect={(answer) => onAnswer(currentQuestion.wordId, answer)} /> - {/* 네비게이션 */} + {/* Navigation */} {currentIndex === questions.length - 1 ? ( - ) : ( - )} - {/* 문제 번호 표시 */} - + {/* Question Indicators */} + {questions.map((q, idx) => ( onNext(idx - currentIndex)} > - {idx + 1} + {answers[q.wordId] ? '✓' : idx + 1} ))} @@ -260,61 +314,136 @@ function TestInProgress({ ) } -// 결과 화면 -function TestResult({ result, onRetry, onHome }) { - const navigate = useNavigate() +// Result Screen +function TestResult({ result, onRetry, onHome, t }) { + const score = result.successRate || 0 + const isGreat = score >= 80 + const isGood = score >= 60 return ( - - {result.successRate?.toFixed(0)}점 + + + + + + {score.toFixed(0)}{t('test.score')} - - {result.correctCount} / {result.totalQuestions} 정답 + + {result.totalQuestions || 0} {t('test.question')} / {result.correctCount || 0} {t('test.correct')} - - + + - - {result.correctCount} + + {result.correctCount || 0} + + + {t('test.correct')} - 정답 - - {result.incorrectCount} + + {result.incorrectCount || 0} + + + {t('test.incorrect')} - 오답 + + + + + {isGreat ? t('test.excellent') : isGood ? t('test.good') : t('test.needPractice')} + + - {/* 틀린 문제 */} - {result.results?.filter(r => !r.isCorrect).length > 0 && ( + {/* Wrong Answers */} + {result.results?.filter((r) => !r.isCorrect).length > 0 && ( - - 틀린 문제 + + {t('test.reviewWrong')} - {result.results.filter(r => !r.isCorrect).map((r, idx) => ( - - - 내 답: {r.userAnswer} - - - 정답: {r.correctAnswer} - - - ))} + + {result.results + .filter((r) => !r.isCorrect) + .map((r, idx) => ( + + + {r.english} + + + + + {t('test.myAnswer')} + + + {r.userAnswer || t('test.noAnswer')} + + + + + {t('test.correctAnswer')} + + + {r.correctAnswer} + + + + + ))} + )} - - @@ -323,12 +452,12 @@ function TestResult({ result, onRetry, onHome }) { export default function TestPage() { const navigate = useNavigate() - const [phase, setPhase] = useState('setup') // setup, testing, result + const { t } = useTranslation() + const [phase, setPhase] = useState('setup') const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [recentResults, setRecentResults] = useState([]) - // 시험 상태 const [testId, setTestId] = useState(null) const [questions, setQuestions] = useState([]) const [currentIndex, setCurrentIndex] = useState(0) @@ -340,84 +469,92 @@ export default function TestPage() { fetchRecentResults() }, []) - // 타이머 useEffect(() => { - if (phase !== 'testing' || timeRemaining <= 0) return + if (phase !== 'testing' || questions.length === 0) return + + setTimeRemaining(QUESTION_TIME_LIMIT) const timer = setInterval(() => { - setTimeRemaining(prev => { + setTimeRemaining((prev) => { if (prev <= 1) { - handleSubmit() - return 0 + if (currentIndex < questions.length - 1) { + setCurrentIndex((idx) => idx + 1) + } else { + handleSubmit() + } + return QUESTION_TIME_LIMIT } return prev - 1 }) }, 1000) return () => clearInterval(timer) - }, [phase, timeRemaining]) + }, [phase, currentIndex, questions.length]) const fetchRecentResults = async () => { try { const response = await testService.getResults(TEMP_USER_ID, { limit: 5 }) - setRecentResults(response?.data?.testResults || []) + setRecentResults(response?.testResults || []) } catch (err) { console.error('Fetch results error:', err) } } - const handleStart = async (options) => { + const handleStart = async () => { try { setLoading(true) setError(null) - const response = await testService.start(TEMP_USER_ID, options) + const response = await testService.start(TEMP_USER_ID, 'DAILY') - if (response?.data) { - setTestId(response.data.testId) - setQuestions(response.data.questions || []) - setTimeRemaining(options.wordCount * 30) // 문제당 30초 + const testData = response?.data || response + if (testData?.testId) { + setTestId(testData.testId) + setQuestions(testData.questions || []) + setTimeRemaining(QUESTION_TIME_LIMIT) setAnswers({}) setCurrentIndex(0) setPhase('testing') + } else { + setError('시험 데이터를 불러오지 못했습니다.') } } catch (err) { console.error('Start test error:', err) - setError('시험을 시작할 수 없습니다.') + const errorMsg = err.response?.data?.message || '시험을 시작할 수 없습니다.' + setError(errorMsg) } finally { setLoading(false) } } - const handleAnswer = (questionId, answer) => { - setAnswers(prev => ({ ...prev, [questionId]: answer })) + const handleAnswer = (wordId, answer) => { + setAnswers((prev) => ({ ...prev, [wordId]: answer })) } - const handleNext = (offset = 1) => { - const newIndex = currentIndex + offset - if (newIndex >= 0 && newIndex < questions.length) { - setCurrentIndex(newIndex) + const handleNext = () => { + if (currentIndex < questions.length - 1) { + setCurrentIndex((prev) => prev + 1) } } const handlePrev = () => { if (currentIndex > 0) { - setCurrentIndex(currentIndex - 1) + setCurrentIndex((prev) => prev - 1) } } const handleSubmit = async () => { try { setLoading(true) - const answersArray = questions.map(q => ({ - questionId: q.questionId, + const answersArray = questions.map((q) => ({ wordId: q.wordId, - answer: answers[q.questionId] || '', + answer: answers[q.wordId] || '', })) const response = await testService.submit(TEMP_USER_ID, testId, answersArray) - if (response?.data) { - setResult(response.data) + const resultData = response?.data || response + if (resultData) { + setResult(resultData) setPhase('result') } } catch (err) { @@ -438,50 +575,41 @@ export default function TestPage() { } return ( - - {/* 헤더 */} - + + {/* Header */} + navigate('/vocab')}> - 단어 시험 + {t('test.title')} {error && ( - setError(null)}> + setError(null)}> {error} )} - {phase === 'setup' && ( - - )} + {phase === 'setup' && } - {phase === 'testing' && ( + {phase === 'testing' && questions.length > 0 && ( handleNext(1)} + onNext={handleNext} onPrev={handlePrev} onSubmit={handleSubmit} + t={t} /> )} {phase === 'result' && result && ( - navigate('/vocab')} - /> + navigate('/vocab')} t={t} /> )} ) diff --git a/src/domains/vocab/pages/VocabDashboard.jsx b/src/domains/vocab/pages/VocabDashboard.jsx index eb7e2d7..de89632 100644 --- a/src/domains/vocab/pages/VocabDashboard.jsx +++ b/src/domains/vocab/pages/VocabDashboard.jsx @@ -27,6 +27,9 @@ import { StarBorder as StarBorderIcon, CheckCircle as CheckIcon, RadioButtonUnchecked as UncheckedIcon, + LocalFireDepartment as FireIcon, + TrendingUp as TrendingIcon, + EmojiEvents as TrophyIcon, } from '@mui/icons-material' import { dailyService, statsService, userWordService, voiceService } from '../services/vocabService' import { @@ -35,11 +38,13 @@ import { CATEGORY_LABELS, DAILY_GOAL, } from '../constants/vocabConstants' +import { useTranslation } from '../../../contexts/SettingsContext' const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1' export default function VocabDashboard() { const navigate = useNavigate() + const { t, isKorean } = useTranslation() const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [dailyData, setDailyData] = useState(null) @@ -64,13 +69,13 @@ export default function VocabDashboard() { statsService.getWeakness(TEMP_USER_ID).catch(() => null), ]) - setDailyData(daily?.data) - setStatsData(stats?.data) - setWeeklyStats(weekly?.data?.dailyStats || []) - setWeakWords(weakness?.data?.weakestWords?.slice(0, 5) || []) + setDailyData(daily) + setStatsData(stats) + setWeeklyStats(weekly?.dailyStats || []) + setWeakWords(weakness?.weakestWords?.slice(0, 5) || []) } catch (err) { console.error('Dashboard fetch error:', err) - setError('데이터를 불러오는데 실패했습니다.') + setError('Failed to load data.') } finally { setLoading(false) } @@ -80,8 +85,8 @@ export default function VocabDashboard() { try { setPlayingTTS(word.wordId) const response = await voiceService.synthesize(word.wordId, word.english) - if (response?.data?.audioUrl) { - const audio = new Audio(response.data.audioUrl) + if (response?.audioUrl) { + const audio = new Audio(response.audioUrl) audio.onended = () => setPlayingTTS(null) audio.onerror = () => setPlayingTTS(null) await audio.play() @@ -97,7 +102,6 @@ export default function VocabDashboard() { await userWordService.updateTag(TEMP_USER_ID, word.wordId, { bookmarked: !word.bookmarked, }) - // 리스트 업데이트 setWeakWords((prev) => prev.map((w) => w.wordId === word.wordId ? { ...w, bookmarked: !w.bookmarked } : w @@ -112,7 +116,7 @@ export default function VocabDashboard() { return ( - + ) @@ -124,19 +128,37 @@ export default function VocabDashboard() { const newWordsCount = dailyData?.newWords?.length || 0 const reviewWordsCount = dailyData?.reviewWords?.length || 0 + // Calculate streak from weekly stats + const streak = weeklyStats.filter(s => s?.isCompleted).length + return ( - - {/* 헤더 */} - - - - - 단어 학습 - + + {/* Header */} + + + + + + + + {t('vocabDash.title')} + + + {t('vocabDash.subtitle')} + + - - 매일 55개 단어로 영어 실력을 키워보세요 - {error && ( @@ -145,52 +167,115 @@ export default function VocabDashboard() { )} - {/* 오늘의 학습 진행률 카드 */} - - - - 오늘의 학습 진행률 - + {/* Hero Progress Card */} + + {/* Decorative Elements */} + + - - - - {learnedCount} / {totalWords} 단어 + + + + + {t('vocabDash.todayProgress')} - + {Math.round(progress)}% + + {streak > 0 && ( + + + + + {streak} + + + {t('vocabDash.days')} + + + + )} + + + + + + {learnedCount} / {totalWords} {t('vocabDash.wordsLearned')} + + - + - - 새 단어 + + {isKorean ? '새 단어' : 'New Words'} - - {newWordsCount} / {DAILY_GOAL.NEW_WORDS} + + {newWordsCount} / {DAILY_GOAL.NEW_WORDS} - - 복습 단어 + + {isKorean ? '복습' : 'Review'} - - {reviewWordsCount} / {DAILY_GOAL.REVIEW_WORDS} + + {reviewWordsCount} / {DAILY_GOAL.REVIEW_WORDS} @@ -202,108 +287,218 @@ export default function VocabDashboard() { onClick={() => navigate('/vocab/daily')} sx={{ backgroundColor: 'white', - color: '#667eea', - fontWeight: 600, + color: '#059669', + fontWeight: 700, + px: 4, + py: 1.5, '&:hover': { - backgroundColor: 'rgba(255,255,255,0.9)', + backgroundColor: 'rgba(255,255,255,0.95)', + transform: 'translateY(-2px)', }, }} > - {dailyData?.isCompleted ? '복습하기' : '학습 계속하기'} + {dailyData?.isCompleted ? (isKorean ? '다시 복습' : 'Review Again') : t('vocabDash.startDailyLearning')} - {/* 퀵 액션 카드 */} + {/* Quick Actions */} - - navigate('/vocab/stats')}> - - - - 전체 통계 - - - - {statsData?.totalWords || 0}개 - - - 학습한 단어 - - - - 정확도 {statsData?.accuracy?.toFixed(1) || 0}% - - - + navigate('/vocab/stats')} + > + + + + + + {t('vocabDash.viewStats')} + + + {statsData?.totalWords || 0} + + + {t('vocabDash.wordsLearned')} + + + - - navigate('/vocab/test')}> - - - - 시험 보기 - - - - {statsData?.avgSuccessRate?.toFixed(1) || 0}% - - - 평균 성적 - - - - {statsData?.testCount || 0}회 응시 - - - + navigate('/vocab/test')} + > + + + + + + {t('vocabDash.takeQuiz')} + + + {statsData?.avgSuccessRate?.toFixed(0) || 0}% + + + {isKorean ? '평균 점수' : 'average score'} + + + - - navigate('/vocab/words')}> - - - - 단어장 - - - - {statsData?.wordStatusCounts?.MASTERED || 0}개 - - - 암기 완료 - - - - 북마크 {statsData?.bookmarkedCount || 0}개 - - - + navigate('/vocab/words')} + > + + + + + + {t('vocabDash.viewWordList')} + + + {statsData?.wordStatusCounts?.MASTERED || 0} + + + {isKorean ? '마스터' : 'mastered'} + + + - {/* 주간 학습 현황 */} + {/* Weekly Progress */} - - - 주간 학습 현황 + + + {t('vocabDash.weeklyProgress')} - - {['월', '화', '수', '목', '금', '토', '일'].map((day, index) => { + + {(isKorean ? ['월', '화', '수', '목', '금', '토', '일'] : ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']).map((day, index) => { const stat = weeklyStats[index] const isCompleted = stat?.isCompleted const hasProgress = stat?.learnedCount > 0 + const isToday = index === new Date().getDay() - 1 || (new Date().getDay() === 0 && index === 6) return ( - - + + {day} {isCompleted ? ( - + + + ) : hasProgress ? ( + > + + {stat?.learnedCount} + + ) : ( - + )} - - {stat?.learnedCount || '-'} - ) })} @@ -338,70 +557,93 @@ export default function VocabDashboard() { - {/* 약점 단어 TOP 5 */} + {/* Weak Words */} {weakWords.length > 0 && ( - - - 약점 단어 TOP 5 - - - 자주 틀리는 단어들을 집중 학습해보세요 + + + + {t('vocabDash.focusWords')} + + + + + {isKorean ? '추가 연습이 필요한 단어입니다' : 'These words need extra attention'} - {weakWords.map((word) => ( + {weakWords.map((word, index) => ( - - {word.english} + + + {word.english} + - + {word.korean} - + handlePlayTTS(word)} disabled={playingTTS === word.wordId} + sx={{ + backgroundColor: playingTTS === word.wordId ? 'primary.main' : 'transparent', + '&:hover': { backgroundColor: 'rgba(5, 150, 105, 0.1)' }, + }} > - + handleToggleBookmark(word)}> {word.bookmarked ? ( - + ) : ( - + )} diff --git a/src/domains/vocab/pages/WordListPage.jsx b/src/domains/vocab/pages/WordListPage.jsx index 88d4aab..d8bc99c 100644 --- a/src/domains/vocab/pages/WordListPage.jsx +++ b/src/domains/vocab/pages/WordListPage.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useRef } from 'react' -import { useNavigate } from 'react-router-dom' +import { useNavigate, useSearchParams } from 'react-router-dom' import { Container, Box, @@ -8,34 +8,29 @@ import { InputAdornment, IconButton, Chip, - List, CircularProgress, Alert, Paper, ToggleButton, ToggleButtonGroup, - Menu, - MenuItem, } from '@mui/material' import { ArrowBack as BackIcon, Search as SearchIcon, Clear as ClearIcon, - FilterList as FilterIcon, Star as StarIcon, + StarBorder as StarBorderIcon, + VolumeUp as VolumeIcon, + ErrorOutline as ErrorIcon, + LibraryBooks as WordListIcon, } from '@mui/icons-material' -import WordListItem from '../components/WordListItem' import WordDetailModal from '../components/WordDetailModal' -import { wordService, userWordService, voiceService } from '../services/vocabService' +import { myWordService, wordService, voiceService } from '../services/vocabService' import { - LEVELS, LEVEL_LABELS, - CATEGORIES, - CATEGORY_LABELS, - WORD_STATUS, WORD_STATUS_LABELS, - VOICE_TYPES, } from '../constants/vocabConstants' +import { useTranslation } from '../../../contexts/SettingsContext' const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1' const PAGE_SIZE = 20 @@ -54,39 +49,30 @@ function useDebounce(value, delay) { export default function WordListPage() { const navigate = useNavigate() + const { t } = useTranslation() + const [searchParams] = useSearchParams() const observerRef = useRef(null) const loadMoreRef = useRef(null) - // 검색 & 필터 + const initialFilter = searchParams.get('filter') + + const [filterMode, setFilterMode] = useState(initialFilter || 'all') const [searchText, setSearchText] = useState('') - const [levelFilter, setLevelFilter] = useState(null) - const [categoryFilter, setCategoryFilter] = useState(null) - const [statusFilter, setStatusFilter] = useState(null) - const [bookmarkedOnly, setBookmarkedOnly] = useState(false) - - // 필터 메뉴 - const [categoryAnchor, setCategoryAnchor] = useState(null) - const [statusAnchor, setStatusAnchor] = useState(null) - - // 단어 데이터 - const [words, setWords] = useState([]) - const [userWords, setUserWords] = useState({}) + + const [userWords, setUserWords] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [hasMore, setHasMore] = useState(true) - const [page, setPage] = useState(0) + const [cursor, setCursor] = useState(null) - // 상세 모달 const [selectedWord, setSelectedWord] = useState(null) const [modalOpen, setModalOpen] = useState(false) - // TTS const [playingWordId, setPlayingWordId] = useState(null) const debouncedSearch = useDebounce(searchText, 300) - // 단어 목록 조회 - const fetchWords = useCallback(async (pageNum, reset = false) => { + const fetchUserWords = useCallback(async (reset = false) => { if (loading) return try { @@ -94,65 +80,45 @@ export default function WordListPage() { setError(null) const params = { - page: pageNum, - size: PAGE_SIZE, + limit: PAGE_SIZE, + cursor: reset ? undefined : cursor, } - if (debouncedSearch) params.keyword = debouncedSearch - if (levelFilter) params.level = levelFilter - if (categoryFilter) params.category = categoryFilter - - const response = await wordService.getWords(params) - const newWords = response?.data?.words || [] - - // 사용자 단어 정보 조회 - const wordIds = newWords.map(w => w.wordId) - if (wordIds.length > 0) { - try { - const userWordResponse = await userWordService.getUserWords(TEMP_USER_ID, { - wordIds: wordIds.join(','), - }) - const userWordMap = {} - ;(userWordResponse?.data?.userWords || []).forEach(uw => { - userWordMap[uw.wordId] = uw - }) - setUserWords(prev => reset ? userWordMap : { ...prev, ...userWordMap }) - } catch (err) { - console.error('User words fetch error:', err) - } + if (filterMode === 'bookmarked') { + params.bookmarked = true + } else if (filterMode === 'incorrect') { + params.incorrectOnly = true } - // 필터링 (bookmarked, status는 클라이언트에서 처리) - let filteredWords = newWords + const response = await myWordService.getList(TEMP_USER_ID, params) + const data = response?.data || response + const newWords = data?.userWords || [] - setWords(prev => reset ? filteredWords : [...prev, ...filteredWords]) - setHasMore(newWords.length === PAGE_SIZE) - setPage(pageNum) + setUserWords(prev => reset ? newWords : [...prev, ...newWords]) + setHasMore(data?.hasMore || false) + setCursor(data?.nextCursor || null) } catch (err) { - console.error('Fetch words error:', err) + console.error('Fetch user words error:', err) setError('단어 목록을 불러오는데 실패했습니다.') } finally { setLoading(false) } - }, [loading, debouncedSearch, levelFilter, categoryFilter]) + }, [loading, cursor, filterMode]) - // 필터 변경시 리셋 useEffect(() => { - setWords([]) - setUserWords({}) - setPage(0) + setUserWords([]) + setCursor(null) setHasMore(true) - fetchWords(0, true) - }, [debouncedSearch, levelFilter, categoryFilter]) + fetchUserWords(true) + }, [filterMode]) - // 무한 스크롤 useEffect(() => { if (loading || !hasMore) return const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && hasMore && !loading) { - fetchWords(page + 1) + fetchUserWords(false) } }, { threshold: 0.1 } @@ -169,31 +135,26 @@ export default function WordListPage() { observerRef.current.disconnect() } } - }, [hasMore, loading, page, fetchWords]) - - // 클라이언트 필터링 (북마크, 상태) - const filteredWords = words.filter(word => { - const userWord = userWords[word.wordId] - - if (bookmarkedOnly && !userWord?.bookmarked) return false - if (statusFilter && userWord?.status !== statusFilter) return false - - return true + }, [hasMore, loading, fetchUserWords]) + + const filteredWords = userWords.filter(word => { + if (!debouncedSearch) return true + const search = debouncedSearch.toLowerCase() + return ( + word.english?.toLowerCase().includes(search) || + word.korean?.toLowerCase().includes(search) + ) }) - // TTS 재생 - const handlePlayTTS = async (word, voice = VOICE_TYPES.FEMALE) => { + const handlePlayTTS = async (word) => { if (playingWordId) return try { setPlayingWordId(word.wordId) - const response = await voiceService.synthesize({ - text: word.english, - voiceType: voice, - }) + const response = await voiceService.synthesize(word.wordId, word.english) - if (response?.data?.audioUrl) { - const audio = new Audio(response.data.audioUrl) + if (response?.audioUrl) { + const audio = new Audio(response.audioUrl) audio.onended = () => setPlayingWordId(null) audio.onerror = () => setPlayingWordId(null) await audio.play() @@ -206,112 +167,104 @@ export default function WordListPage() { } } - // 북마크 토글 const handleToggleBookmark = async (word) => { - const userWord = userWords[word.wordId] - const newBookmarked = !userWord?.bookmarked + const newBookmarked = !word.bookmarked try { - await userWordService.updateUserWord(TEMP_USER_ID, word.wordId, { - bookmarked: newBookmarked, - }) - - setUserWords(prev => ({ - ...prev, - [word.wordId]: { - ...prev[word.wordId], - bookmarked: newBookmarked, - }, - })) - } catch (err) { - console.error('Bookmark toggle error:', err) - } - } + await myWordService.toggleBookmark(TEMP_USER_ID, word.wordId, newBookmarked) - // 즐겨찾기 토글 - const handleToggleFavorite = async (word) => { - const userWord = userWords[word.wordId] - const newFavorite = !userWord?.favorite + setUserWords(prev => + prev.map(w => + w.wordId === word.wordId ? { ...w, bookmarked: newBookmarked } : w + ) + ) - try { - await userWordService.updateUserWord(TEMP_USER_ID, word.wordId, { - favorite: newFavorite, - }) - - setUserWords(prev => ({ - ...prev, - [word.wordId]: { - ...prev[word.wordId], - favorite: newFavorite, - }, - })) - } catch (err) { - console.error('Favorite toggle error:', err) - } - } - - // 난이도 설정 - const handleSetDifficulty = async (word, difficulty) => { - try { - await userWordService.updateUserWord(TEMP_USER_ID, word.wordId, { - difficulty, - }) - - setUserWords(prev => ({ - ...prev, - [word.wordId]: { - ...prev[word.wordId], - difficulty, - }, - })) + if (filterMode === 'bookmarked' && !newBookmarked) { + setUserWords(prev => prev.filter(w => w.wordId !== word.wordId)) + } } catch (err) { - console.error('Set difficulty error:', err) + console.error('Bookmark toggle error:', err) } } - // 단어 상세 열기 const handleWordClick = (word) => { setSelectedWord(word) setModalOpen(true) } - // 검색 초기화 const handleClearSearch = () => { setSearchText('') } - // 필터 초기화 - const handleClearFilters = () => { - setLevelFilter(null) - setCategoryFilter(null) - setStatusFilter(null) - setBookmarkedOnly(false) + 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 hasActiveFilters = levelFilter || categoryFilter || statusFilter || bookmarkedOnly + 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' } + } + } return ( - + {/* 헤더 */} - - navigate('/vocab')}> - - - - 단어장 - + + + navigate('/vocab')} sx={{ ml: -1 }}> + + + + + + + + {t('wordList.title')} + + + {filteredWords.length} {t('wordList.wordsCount')} + + + {/* 검색 */} setSearchText(e.target.value)} InputProps={{ startAdornment: ( - + ), endAdornment: searchText && ( @@ -322,149 +275,220 @@ export default function WordListPage() { ), }} - sx={{ mb: 2 }} + sx={{ + mb: 3, + '& .MuiOutlinedInput-root': { + backgroundColor: 'white', + }, + }} /> - {/* 필터 */} - - {/* 레벨 필터 */} - - - 레벨 - - setLevelFilter(val)} - size="small" - fullWidth - > - 전체 - {Object.entries(LEVEL_LABELS).map(([key, label]) => ( - - {label} - - ))} - - - - {/* 추가 필터 */} - - {/* 카테고리 */} - setCategoryAnchor(e.currentTarget)} - onDelete={categoryFilter ? () => setCategoryFilter(null) : undefined} - variant={categoryFilter ? 'filled' : 'outlined'} - size="small" - /> - setCategoryAnchor(null)} - > - { setCategoryFilter(null); setCategoryAnchor(null) }}> - 전체 - - {Object.entries(CATEGORY_LABELS).map(([key, label]) => ( - { setCategoryFilter(key); setCategoryAnchor(null) }} - selected={categoryFilter === key} - > - {label} - - ))} - - - {/* 학습 상태 */} - setStatusAnchor(e.currentTarget)} - onDelete={statusFilter ? () => setStatusFilter(null) : undefined} - variant={statusFilter ? 'filled' : 'outlined'} - size="small" - /> - setStatusAnchor(null)} - > - { setStatusFilter(null); setStatusAnchor(null) }}> - 전체 - - {Object.entries(WORD_STATUS_LABELS).map(([key, label]) => ( - { setStatusFilter(key); setStatusAnchor(null) }} - selected={statusFilter === key} - > - {label} - - ))} - - - {/* 북마크 */} - } - label="북마크" - onClick={() => setBookmarkedOnly(!bookmarkedOnly)} - color={bookmarkedOnly ? 'warning' : 'default'} - variant={bookmarkedOnly ? 'filled' : 'outlined'} - size="small" - /> - - {/* 필터 초기화 */} - {hasActiveFilters && ( - - )} - - - - {/* 결과 카운트 */} - - {filteredWords.length}개의 단어 - + {/* 필터 탭 */} + + val && setFilterMode(val)} + size="small" + fullWidth + sx={{ + '& .MuiToggleButton-root': { + flex: 1, + py: 1.5, + fontWeight: 600, + '&.Mui-selected': { + backgroundColor: '#059669', + color: 'white', + '&:hover': { + backgroundColor: '#047857', + }, + }, + }, + }} + > + + {t('wordList.filterAll')} + + + + {t('wordList.filterBookmarked')} + + + + {t('wordList.filterIncorrect')} + + + {error && ( - setError(null)}> + setError(null)}> {error} )} {/* 단어 목록 */} - - {filteredWords.map((word) => ( - handlePlayTTS(word)} - onToggleBookmark={() => handleToggleBookmark(word)} - onClick={() => handleWordClick(word)} - isPlayingTTS={playingWordId === word.wordId} - /> - ))} - + + {filteredWords.map((word) => { + const levelStyle = getLevelStyle(word.level) + const statusStyle = getStatusStyle(word.status) + + return ( + handleWordClick(word)} + sx={{ + p: 2.5, + cursor: 'pointer', + border: '2px solid transparent', + borderRadius: '16px', + transition: 'all 0.2s ease', + '&:hover': { + borderColor: '#059669', + boxShadow: '0 4px 12px -4px rgba(5, 150, 105, 0.2)', + }, + }} + > + + + + + {word.english} + + {word.level && ( + + )} + {word.status && ( + + )} + + + + {word.korean} + + + {(word.correctCount > 0 || word.incorrectCount > 0) && ( + + + {t('wordList.correct')} {word.correctCount || 0} + + + {t('wordList.incorrect')} {word.incorrectCount || 0} + + + )} + + + + { + e.stopPropagation() + handlePlayTTS(word) + }} + disabled={playingWordId === word.wordId} + sx={{ + width: 40, + height: 40, + backgroundColor: playingWordId === word.wordId ? '#059669' : '#f5f5f4', + '&:hover': { backgroundColor: playingWordId === word.wordId ? '#047857' : '#e7e5e4' }, + }} + > + + + { + e.stopPropagation() + handleToggleBookmark(word) + }} + sx={{ + width: 40, + height: 40, + backgroundColor: word.bookmarked ? '#fef3c7' : '#f5f5f4', + '&:hover': { backgroundColor: word.bookmarked ? '#fde68a' : '#e7e5e4' }, + }} + > + {word.bookmarked ? ( + + ) : ( + + )} + + + + + ) + })} + {/* 로딩 & 더보기 트리거 */} - - {loading && } + + {loading && } {!loading && !hasMore && filteredWords.length > 0 && ( - 모든 단어를 불러왔습니다 + {t('wordList.loadedAll')} )} {!loading && filteredWords.length === 0 && !error && ( - - 검색 결과가 없습니다 - + + + + + + {filterMode === 'bookmarked' + ? t('wordList.noBookmarks') + : filterMode === 'incorrect' + ? t('wordList.noIncorrect') + : t('wordList.noWords')} + + navigate('/vocab/daily')} + sx={{ + mt: 1, + backgroundColor: '#059669', + color: 'white', + fontWeight: 600, + '&:hover': { backgroundColor: '#047857' }, + }} + /> + )} @@ -473,11 +497,9 @@ export default function WordListPage() { open={modalOpen} onClose={() => setModalOpen(false)} word={selectedWord} - userWord={selectedWord ? userWords[selectedWord.wordId] : null} - onPlayTTS={(voice) => selectedWord && handlePlayTTS(selectedWord, voice)} + userWord={selectedWord} + onPlayTTS={() => selectedWord && handlePlayTTS(selectedWord)} onToggleBookmark={() => selectedWord && handleToggleBookmark(selectedWord)} - onToggleFavorite={() => selectedWord && handleToggleFavorite(selectedWord)} - onSetDifficulty={(diff) => selectedWord && handleSetDifficulty(selectedWord, diff)} isPlayingTTS={playingWordId === selectedWord?.wordId} /> diff --git a/src/domains/vocab/services/vocabService.js b/src/domains/vocab/services/vocabService.js index 1014b98..92efdf6 100644 --- a/src/domains/vocab/services/vocabService.js +++ b/src/domains/vocab/services/vocabService.js @@ -1,91 +1,379 @@ import vocabApi from '../../../api/vocabApi' +// Mock 데이터 사용 여부 (true: 목 데이터 사용, false: 실제 API 호출) +const USE_MOCK = true + +// ============================================ +// Mock 데이터 +// ============================================ + +const mockWords = [ + { wordId: 'w1', english: 'apple', korean: '사과', level: 'BEGINNER', category: 'DAILY', example: 'I eat an apple every day.' }, + { wordId: 'w2', english: 'beautiful', korean: '아름다운', level: 'BEGINNER', category: 'DAILY', example: 'The sunset is beautiful.' }, + { wordId: 'w3', english: 'computer', korean: '컴퓨터', level: 'BEGINNER', category: 'DAILY', example: 'I use a computer for work.' }, + { wordId: 'w4', english: 'delicious', korean: '맛있는', level: 'BEGINNER', category: 'DAILY', example: 'This pizza is delicious.' }, + { wordId: 'w5', english: 'environment', korean: '환경', level: 'INTERMEDIATE', category: 'ACADEMIC', example: 'We must protect the environment.' }, + { wordId: 'w6', english: 'fundamental', korean: '기본적인', level: 'INTERMEDIATE', category: 'ACADEMIC', example: 'This is a fundamental concept.' }, + { wordId: 'w7', english: 'generate', korean: '생성하다', level: 'INTERMEDIATE', category: 'BUSINESS', example: 'The company generates revenue.' }, + { wordId: 'w8', english: 'hypothesis', korean: '가설', level: 'ADVANCED', category: 'ACADEMIC', example: 'We need to test this hypothesis.' }, + { wordId: 'w9', english: 'implement', korean: '구현하다', level: 'INTERMEDIATE', category: 'BUSINESS', example: 'We will implement the new system.' }, + { wordId: 'w10', english: 'jurisdiction', korean: '관할권', level: 'ADVANCED', category: 'BUSINESS', example: 'This falls under federal jurisdiction.' }, + { wordId: 'w11', english: 'knowledge', korean: '지식', level: 'BEGINNER', category: 'DAILY', example: 'Knowledge is power.' }, + { wordId: 'w12', english: 'legitimate', korean: '합법적인', level: 'ADVANCED', category: 'BUSINESS', example: 'Is this a legitimate business?' }, + { wordId: 'w13', english: 'magnificent', korean: '웅장한', level: 'INTERMEDIATE', category: 'DAILY', example: 'The castle is magnificent.' }, + { wordId: 'w14', english: 'negotiate', korean: '협상하다', level: 'INTERMEDIATE', category: 'BUSINESS', example: 'They will negotiate the contract.' }, + { wordId: 'w15', english: 'opportunity', korean: '기회', level: 'BEGINNER', category: 'DAILY', example: 'This is a great opportunity.' }, + { wordId: 'w16', english: 'perseverance', korean: '인내', level: 'ADVANCED', category: 'DAILY', example: 'Success requires perseverance.' }, + { wordId: 'w17', english: 'question', korean: '질문', level: 'BEGINNER', category: 'DAILY', example: 'Do you have any questions?' }, + { wordId: 'w18', english: 'responsibility', korean: '책임', level: 'INTERMEDIATE', category: 'BUSINESS', example: 'Take responsibility for your actions.' }, + { wordId: 'w19', english: 'sophisticated', korean: '정교한', level: 'ADVANCED', category: 'ACADEMIC', example: 'This is a sophisticated algorithm.' }, + { wordId: 'w20', english: 'technology', korean: '기술', level: 'BEGINNER', category: 'DAILY', example: 'Technology is advancing rapidly.' }, +] + +const mockUserWords = mockWords.map((word, idx) => ({ + ...word, + status: idx < 5 ? 'MASTERED' : idx < 12 ? 'REVIEWING' : idx < 17 ? 'LEARNING' : 'NEW', + correctCount: Math.floor(Math.random() * 10) + 1, + incorrectCount: Math.floor(Math.random() * 5), + bookmarked: idx % 4 === 0, + favorite: idx % 5 === 0, + difficulty: ['EASY', 'NORMAL', 'HARD'][idx % 3], + lastReviewedAt: new Date(Date.now() - idx * 86400000).toISOString(), + nextReviewAt: new Date(Date.now() + (idx + 1) * 86400000).toISOString(), +})) + +const generateDailyStats = () => { + const stats = [] + for (let i = 0; i < 84; i++) { + const date = new Date() + date.setDate(date.getDate() - i) + stats.push({ + date: date.toISOString().split('T')[0], + learnedCount: Math.random() > 0.3 ? Math.floor(Math.random() * 55) + 5 : 0, + wordsStudied: Math.floor(Math.random() * 30) + 5, + successRate: Math.floor(Math.random() * 40) + 60, + correctCount: Math.floor(Math.random() * 40) + 10, + incorrectCount: Math.floor(Math.random() * 15), + }) + } + return stats +} + +const mockTestResults = [ + { testId: 't1', testType: 'DAILY', totalQuestions: 20, correctAnswers: 18, successRate: 90, completedAt: new Date(Date.now() - 86400000).toISOString() }, + { testId: 't2', testType: 'DAILY', totalQuestions: 20, correctAnswers: 15, successRate: 75, completedAt: new Date(Date.now() - 172800000).toISOString() }, + { testId: 't3', testType: 'DAILY', totalQuestions: 20, correctAnswers: 12, successRate: 60, completedAt: new Date(Date.now() - 259200000).toISOString() }, +] + +// ============================================ +// API with Mock fallback +// ============================================ + +const withMock = (apiCall, mockData) => { + if (USE_MOCK) { + // interceptor가 response.data를 반환하므로 mockData를 직접 반환 + return Promise.resolve(mockData) + } + return apiCall().catch(() => mockData) +} + /** - * 단어 관리 API (#65) + * 단어 관리 API - Backend: GET /words, GET /words/search */ export const wordService = { - // 단어 목록 조회 + // GET /words - 단어 목록 조회 getList: ({ level, category, limit = 20, cursor } = {}) => - vocabApi.get('/vocab/words', { params: { level, category, limit, cursor } }), + withMock( + () => vocabApi.get('/words', { params: { level, category, limit, cursor } }), + { + words: mockWords.filter(w => (!level || w.level === level) && (!category || w.category === category)).slice(0, limit), + hasMore: false, + nextCursor: null, + } + ), + + // GET /words - 단어 목록 조회 (별칭) + getWords: (params) => + withMock( + () => vocabApi.get('/words', { params }), + { words: mockWords, hasMore: false } + ), - // 단어 검색 + // GET /words/search - 단어 검색 search: ({ q, limit = 20, cursor } = {}) => - vocabApi.get('/vocab/words/search', { params: { q, limit, cursor } }), + withMock( + () => vocabApi.get('/words/search', { params: { q, limit, cursor } }), + { + words: mockWords.filter(w => + w.english.toLowerCase().includes(q?.toLowerCase() || '') || + w.korean.includes(q || '') + ).slice(0, limit), + query: q, + hasMore: false, + } + ), - // 단어 상세 조회 + // GET /words/{wordId} - 단어 상세 조회 (백엔드 문서에 없지만 필요시) getDetail: (wordId) => - vocabApi.get(`/vocab/words/${wordId}`), + withMock( + () => vocabApi.get(`/words/${wordId}`), + mockWords.find(w => w.wordId === wordId) || mockWords[0] + ), + + // POST /words/batch - 배치 단어 생성 + createBatch: (words) => + withMock( + () => vocabApi.post('/words/batch', { words }), + { successCount: words.length, failCount: 0, totalRequested: words.length } + ), + + // POST /words/batch/get - 배치 단어 조회 + getBatch: (wordIds) => + withMock( + () => vocabApi.post('/words/batch/get', { wordIds }), + { + words: mockWords.filter(w => wordIds.includes(w.wordId)), + requestedCount: wordIds.length, + retrievedCount: wordIds.length, + } + ), } /** - * 일일 학습 API (#66) + * 일일 학습 API - Backend: POST /daily-study/record, GET /user-words/review */ export const dailyService = { - // 오늘의 학습 단어 조회 - getWords: (userId) => - vocabApi.get(`/vocab/daily/${userId}`), + // 일일 학습용 단어 조회 (새 단어 + 복습 단어) + getWords: (userId, level) => + withMock( + () => vocabApi.get('/user-words/review', { params: { userId, ...(level ? { level } : {}) } }), + { + newWords: mockWords.filter(w => !level || w.level === level).slice(0, 10), + reviewWords: mockUserWords.filter(w => w.status === 'REVIEWING').slice(0, 5), + learnedCount: 0, + isCompleted: false, + } + ), - // 단어 학습 완료 표시 - markLearned: (userId, wordId, isCorrect) => - vocabApi.post(`/vocab/daily/${userId}/words/${wordId}/learned`, { isCorrect }), + // POST /daily-study/record - 일일 학습 기록 + markLearned: (userId, wordId, isCorrect, studyType = 'REVIEW') => + withMock( + () => vocabApi.post('/daily-study/record', { userId, wordId, isCorrect, studyType }), + { + userId, + date: new Date().toISOString().split('T')[0], + wordsStudied: 1, + correctCount: isCorrect ? 1 : 0, + incorrectCount: isCorrect ? 0 : 1, + } + ), } /** - * 사용자 단어 학습 상태 API (#67) + * 사용자 단어 학습 상태 API - Backend: POST /user-words/{wordId}/review, PATCH /user-words/{wordId}/tag */ export const userWordService = { - // 학습 상태 조회 - getList: (userId, { status, limit = 20, cursor } = {}) => - vocabApi.get(`/vocab/users/${userId}/words`, { params: { status, limit, cursor } }), + // GET /user-words/review - 복습 예정 단어 조회 + getList: (userId, { status, limit = 20, cursor, date } = {}) => + withMock( + () => vocabApi.get('/user-words/review', { params: { userId, status, limit, cursor, date } }), + { + userWords: mockUserWords.filter(w => !status || w.status === status).slice(0, limit), + hasMore: false, + nextCursor: null, + } + ), + + // GET /user-words/review - 사용자 단어 조회 (별칭) + getUserWords: (userId, params) => + withMock( + () => vocabApi.get('/user-words/review', { params: { userId, ...params } }), + { words: mockUserWords, hasMore: false } + ), - // 학습 결과 업데이트 (정답/오답) + // POST /user-words/{wordId}/review - 사용자 단어 학습 업데이트 update: (userId, wordId, isCorrect) => - vocabApi.put(`/vocab/users/${userId}/words/${wordId}`, { isCorrect }), + withMock( + () => vocabApi.post(`/user-words/${wordId}/review`, { userId, isCorrect }), + { + userId, + wordId, + status: isCorrect ? 'REVIEWING' : 'LEARNING', + interval: isCorrect ? 6 : 1, + easeFactor: isCorrect ? 2.5 : 2.3, + repetitions: isCorrect ? 2 : 0, + nextReviewAt: new Date(Date.now() + (isCorrect ? 6 : 1) * 86400000).toISOString().split('T')[0], + lastReviewedAt: new Date().toISOString(), + correctCount: isCorrect ? 5 : 4, + incorrectCount: isCorrect ? 1 : 2, + } + ), - // 단어 태그 변경 (북마크/즐겨찾기/난이도) + // PATCH /user-words/{wordId}/tag - 사용자 단어 태그 업데이트 updateTag: (userId, wordId, { bookmarked, favorite, difficulty }) => - vocabApi.put(`/vocab/users/${userId}/words/${wordId}/tag`, { bookmarked, favorite, difficulty }), + withMock( + () => vocabApi.patch(`/user-words/${wordId}/tag`, { userId, bookmarked, favorite, difficulty }), + { success: true, userId, wordId, bookmarked, favorite, difficulty } + ), + + // PATCH /user-words/{wordId}/tag - 사용자 단어 업데이트 (별칭) + updateUserWord: (userId, wordId, data) => + withMock( + () => vocabApi.patch(`/user-words/${wordId}/tag`, { userId, ...data }), + { success: true, ...data } + ), +} + +/** + * 나의 단어장 API - 북마크/오답 필터링 + */ +export const myWordService = { + // GET /user-words/review - 나의 단어 목록 (필터링) + getList: (userId, { bookmarked, incorrectOnly, limit = 20, cursor } = {}) => + withMock( + () => vocabApi.get('/user-words/review', { + params: { userId, bookmarked, incorrectOnly, limit, cursor } + }), + { + userWords: mockUserWords + .filter(w => (!bookmarked || w.bookmarked) && (!incorrectOnly || w.incorrectCount > 0)) + .slice(0, limit), + hasMore: false, + } + ), + + // 북마크된 단어 조회 + getBookmarked: (userId, { limit = 20, cursor } = {}) => + withMock( + () => vocabApi.get('/user-words/review', { params: { userId, bookmarked: true, limit, cursor } }), + { userWords: mockUserWords.filter(w => w.bookmarked).slice(0, limit), hasMore: false } + ), + + // 오답 단어 조회 + getIncorrect: (userId, { limit = 20, cursor } = {}) => + withMock( + () => vocabApi.get('/user-words/review', { params: { userId, incorrectOnly: true, limit, cursor } }), + { userWords: mockUserWords.filter(w => w.incorrectCount > 0).slice(0, limit), hasMore: false } + ), + + // PATCH /user-words/{wordId}/tag - 북마크 토글 + toggleBookmark: (userId, wordId, bookmarked) => + withMock( + () => vocabApi.patch(`/user-words/${wordId}/tag`, { userId, bookmarked }), + { success: true, wordId, bookmarked } + ), } /** - * 시험 API (#68) + * 시험 API - Backend: POST /tests/start, POST /tests/{testId}/submit */ export const testService = { - // 시험 시작 - start: (userId, { wordCount = 20, level, type = 'ENGLISH_TO_KOREAN' } = {}) => - vocabApi.post(`/vocab/test/${userId}/start`, { wordCount, level, type }), + // POST /tests/start - 시험 시작 + start: (userId, testType = 'DAILY', wordCount = 20, level) => + withMock( + () => vocabApi.post('/tests/start', { userId, testType, wordCount, level }), + { + testId: `test-${Date.now()}`, + testType, + words: mockWords.slice(0, wordCount || 10).map(w => ({ + wordId: w.wordId, + english: w.english, + options: [w.korean, '다른뜻1', '다른뜻2', '다른뜻3'].sort(() => Math.random() - 0.5), + })), + startedAt: new Date().toISOString(), + } + ), - // 답안 제출 + // POST /tests/{testId}/submit - 시험 제출 submit: (userId, testId, answers) => - vocabApi.post(`/vocab/test/${userId}/submit`, { testId, answers }), + withMock( + () => vocabApi.post(`/tests/${testId}/submit`, { userId, answers }), + { + testId, + totalQuestions: answers.length, + correctAnswers: Math.floor(answers.length * 0.8), + incorrectAnswers: Math.ceil(answers.length * 0.2), + successRate: 80, + incorrectWordIds: answers.slice(Math.floor(answers.length * 0.8)).map(a => a.wordId), + completedAt: new Date().toISOString(), + } + ), - // 시험 결과 조회 + // 시험 결과 조회 (프론트엔드 전용 - 백엔드에서 미구현) getResults: (userId, { limit = 20, cursor } = {}) => - vocabApi.get(`/vocab/test/${userId}/results`, { params: { limit, cursor } }), + withMock( + () => vocabApi.get('/tests/results', { params: { userId, limit, cursor } }), + { testResults: mockTestResults.slice(0, limit), hasMore: false } + ), } /** - * 통계 API (#69) + * 통계 API - Backend: GET /statistics */ export const statsService = { - // 전체 학습 통계 - getOverall: (userId) => - vocabApi.get(`/vocab/stats/${userId}`), + // GET /statistics - 학습 통계 조회 + getOverall: (userId, period = 'ALL') => + withMock( + () => vocabApi.get('/statistics', { params: { userId, period } }), + { + totalWords: mockWords.length, + totalLearned: 15, + masteredWords: 5, + learningWords: 8, + newWords: 7, + averageSuccessRate: 78.5, + averageAccuracy: 78.5, + studyStreak: 7, + streakDays: 7, + dailyStats: generateDailyStats().slice(0, 7), + levelProgress: { + BEGINNER: { total: 8, learned: 6 }, + INTERMEDIATE: { total: 7, learned: 5 }, + ADVANCED: { total: 5, learned: 2 }, + }, + difficultyDistribution: { + EASY: 6, + NORMAL: 9, + HARD: 5, + }, + } + ), - // 일별 학습 통계 - getDaily: (userId, { limit = 30 } = {}) => - vocabApi.get(`/vocab/stats/${userId}/daily`, { params: { limit } }), + // GET /statistics - 기간별 통계 (프론트엔드 래핑) + getDaily: (userId, { limit = 30, period = 'MONTH' } = {}) => + withMock( + () => vocabApi.get('/statistics', { params: { userId, period } }), + { dailyStats: generateDailyStats().slice(0, limit) } + ), - // 약점 분석 + // 취약 단어 조회 (프론트엔드 전용 - 백엔드에서 미구현) getWeakness: (userId) => - vocabApi.get(`/vocab/stats/${userId}/weakness`), + withMock( + () => vocabApi.get('/statistics', { params: { userId, includeWeak: true } }), + { + weakWords: mockUserWords + .filter(w => w.incorrectCount > 0) + .sort((a, b) => (a.correctCount / (a.correctCount + a.incorrectCount)) - (b.correctCount / (b.correctCount + b.incorrectCount))) + .slice(0, 10) + .map(w => ({ + ...w, + accuracy: Math.round((w.correctCount / (w.correctCount + w.incorrectCount)) * 100), + })), + } + ), } /** - * 음성 API (TTS) (#70) + * 음성 API (TTS) - Backend: POST /voice/synthesize */ export const voiceService = { - // 단어 발음 합성 - synthesize: (wordId, text, voice = 'FEMALE') => - vocabApi.post('/vocab/voice/synthesize', { wordId, text, voice }), + // POST /voice/synthesize - 음성 합성 + synthesize: (wordId, text, voice = 'female', type = 'word') => + withMock( + () => vocabApi.post('/voice/synthesize', { wordId, text, voice, type }), + { + audioUrl: null, // Mock에서는 실제 오디오 없음 + cached: false, + } + ), } diff --git a/src/i18n/translations.js b/src/i18n/translations.js new file mode 100644 index 0000000..e4b4f87 --- /dev/null +++ b/src/i18n/translations.js @@ -0,0 +1,606 @@ +/** + * Multi-language support for the application + * Supports: Korean (ko), English (en) + */ + +export const translations = { + ko: { + // Common + common: { + hello: '안녕하세요', + loading: '로딩 중...', + error: '오류가 발생했습니다', + retry: '다시 시도', + cancel: '취소', + confirm: '확인', + save: '저장', + delete: '삭제', + edit: '수정', + close: '닫기', + back: '뒤로', + next: '다음', + previous: '이전', + start: '시작', + finish: '완료', + submit: '제출', + search: '검색', + all: '전체', + none: '없음', + }, + + // Navigation + nav: { + dashboard: '대시보드', + learningMode: '학습 모드', + reports: '리포트', + settings: '설정', + profile: '내 프로필', + logout: '로그아웃', + notifications: '알림', + }, + + // Sidebar categories + sidebar: { + learningMode: '학습 모드', + other: '기타', + speaking: '말하기 연습', + writing: '쓰기 연습', + vocab: '단어 학습', + opicPractice: '오픽 연습', + opicDesc: '레벨별 맞춤 연습', + aiTalk: 'AI와 대화하기', + aiTalkDesc: 'AI와 자유로운 대화', + chatPeople: '사람들과 채팅', + chatPeopleDesc: '다른 학습자와 대화', + writingPractice: '작문 연습', + writingPracticeDesc: '문법 교정 & 피드백', + vocabLearn: '단어 외우기', + vocabLearnDesc: '매일 55개 단어 학습', + vocabTest: '시험 보기', + vocabTestDesc: '4지선다 퀴즈', + vocabWords: '단어장', + vocabWordsDesc: '전체 단어 목록', + dashboardDesc: '학습 현황 요약', + reportsDesc: '학습 결과 분석', + settingsDesc: '계정 및 앱 설정', + todayStudyTime: '오늘의 학습 시간', + }, + + // Header + header: { + appName: 'AI 언어 학습', + darkMode: '다크 모드로 전환', + lightMode: '라이트 모드로 전환', + notifications: '알림', + user: '사용자님', + }, + + // Dashboard + dashboard: { + greeting: '안녕하세요!', + subtitle: '오늘은 무엇을 배워볼까요?', + recentActivity: '최근 활동', + noHistory: '아직 학습 기록이 없습니다', + startLearning: '학습을 시작해서 진도를 확인하세요', + startButton: '학습 시작하기', + speakingTitle: '말하기 연습', + speakingDesc: 'OPIC과 AI 대화로 스피킹 실력 향상', + writingTitle: '쓰기 연습', + writingDesc: '채팅과 작문으로 쓰기 실력 향상', + vocabTitle: '단어 학습', + vocabDesc: '매일 55개 단어로 어휘력 마스터', + opicTitle: 'OPIC 연습', + opicDesc: '레벨별 맞춤 훈련', + aiTalkTitle: 'AI와 대화', + aiTalkDesc: '자유로운 회화', + chatTitle: '사람들과 채팅', + chatDesc: '학습자와 함께', + compositionTitle: '작문', + compositionDesc: '문법 & 피드백', + dailyWordsTitle: '오늘의 단어', + dailyWordsDesc: '매일 55단어', + quizTitle: '퀴즈 풀기', + quizDesc: '4지선다 테스트', + wordListTitle: '단어장', + wordListDesc: '내 모든 단어', + }, + + // Vocab Dashboard + vocabDash: { + title: '단어 학습', + subtitle: '오늘의 학습 현황', + todayProgress: '오늘의 학습', + wordsLearned: '학습한 단어', + accuracy: '정확도', + streak: '연속 학습', + days: '일', + weeklyProgress: '주간 학습 현황', + quickActions: '빠른 시작', + startDailyLearning: '오늘의 학습 시작', + takeQuiz: '퀴즈 풀기', + viewWordList: '단어장 보기', + viewStats: '통계 보기', + focusWords: '집중 학습 단어', + weakWords: '약점 단어', + noWeakWords: '약점 단어가 없습니다', + }, + + // Daily Learning + dailyLearning: { + title: '오늘의 단어', + selectLevel: '레벨 선택', + beginnerTitle: '초급', + beginnerDesc: '기초 어휘 학습', + intermediateTitle: '중급', + intermediateDesc: '실용 어휘 확장', + advancedTitle: '고급', + advancedDesc: '전문 어휘 마스터', + wordsCount: '단어', + progress: '진행률', + tapToFlip: '카드를 탭하여 뒤집기', + knew: '알아요', + didntKnow: '몰라요', + complete: '학습 완료!', + totalWords: '학습한 단어', + correctCount: '정답', + accuracy: '정확도', + goBack: '돌아가기', + continueStudy: '계속 학습', + takeQuiz: '퀴즈 풀기', + greatJob: '잘했어요!', + completedSession: '오늘의 학습을 완료했습니다', + correct: '정답', + incorrect: '오답', + practiceAgain: '다시 연습', + backToDashboard: '대시보드로', + dontKnow: '몰라요', + knowIt: '알아요', + previous: '이전', + skip: '건너뛰기', + finish: '완료', + bookmark: '북마크', + removeBookmark: '북마크 해제', + }, + + // Test Page + test: { + title: '단어 시험', + subtitle: '4지선다 문제로 실력을 테스트해보세요', + startButton: '시험 시작', + recentResults: '최근 결과', + noResults: '아직 시험 기록이 없습니다', + startFirst: '첫 시험을 시작해보세요!', + question: '문제', + of: '/', + timeLeft: '남은 시간', + seconds: '초', + selectAnswer: '정답을 선택하세요', + results: '시험 결과', + score: '점', + correct: '정답', + incorrect: '오답', + time: '소요 시간', + retake: '다시 풀기', + reviewWrong: '오답 확인', + finish: '완료', + excellent: '훌륭해요!', + good: '잘했어요!', + needPractice: '더 연습해보세요!', + myAnswer: '내 답', + correctAnswer: '정답', + noAnswer: '미응답', + toDashboard: '대시보드로', + }, + + // Word List + wordList: { + title: '나의 단어장', + wordsCount: '개의 단어', + searchPlaceholder: '단어 검색...', + filterAll: '전체', + filterBookmarked: '북마크', + filterIncorrect: '틀린 단어', + noBookmarks: '북마크한 단어가 없습니다', + noIncorrect: '틀린 단어가 없습니다', + noWords: '학습한 단어가 없습니다', + startLearning: '학습 시작하기', + loadedAll: '모든 단어를 불러왔습니다', + correct: '정답', + incorrect: '오답', + }, + + // Word Detail Modal + wordDetail: { + example: '예문', + pronunciation: '발음 듣기', + voiceFemale: '여성', + voiceMale: '남성', + listen: '듣기', + playing: '재생 중...', + learningStatus: '학습 현황', + correctCount: '정답', + incorrectCount: '오답', + accuracyLabel: '정확도', + lastReviewed: '마지막 학습', + nextReview: '다음 복습', + difficulty: '난이도', + }, + + // Settings + settings: { + title: '설정', + subtitle: '앱 환경을 설정하세요', + language: '언어', + languageDesc: '앱에서 사용할 언어를 선택하세요', + korean: '한국어', + english: 'English', + ttsVoice: 'TTS 음성', + ttsVoiceDesc: '텍스트 음성 변환에 사용할 음성을 선택하세요', + femaleVoice: '여성 음성', + maleVoice: '남성 음성', + theme: '테마', + themeDesc: '앱 테마를 선택하세요', + lightTheme: '라이트 모드', + darkTheme: '다크 모드', + systemTheme: '시스템 설정', + }, + + // Levels + levels: { + BEGINNER: '초급', + INTERMEDIATE: '중급', + ADVANCED: '고급', + }, + + // Word Status + wordStatus: { + NEW: '새 단어', + LEARNING: '학습 중', + REVIEWING: '복습 중', + MASTERED: '완료', + }, + + // Statistics Page + stats: { + title: '학습 통계', + daily: '일간', + weekly: '주간', + monthly: '월간', + totalLearned: '총 학습 단어', + outOf: '전체', + avgAccuracy: '평균 정답률', + streak: '연속 학습', + days: '일', + weakWords: '취약 단어', + needReview: '복습이 필요해요', + learningHistory: '학습 기록', + levelProgress: '레벨별 진행률', + difficultyDist: '난이도 분포', + weakWordsTop10: '취약 단어 TOP 10', + review: '복습하기', + noWeakWords: '취약 단어가 없습니다', + less: '적음', + more: '많음', + }, + + // Footer + footer: { + copyright: '© 2026 AI 언어 학습. All rights reserved.', + terms: '이용약관', + privacy: '개인정보처리방침', + contact: '고객센터', + }, + + // 404 Page + notFound: { + title: '페이지를 찾을 수 없습니다', + message: '요청하신 페이지가 존재하지 않거나 이동되었습니다.', + backHome: '홈으로 돌아가기', + }, + }, + + en: { + // Common + common: { + hello: 'Hello', + loading: 'Loading...', + error: 'An error occurred', + retry: 'Retry', + cancel: 'Cancel', + confirm: 'Confirm', + save: 'Save', + delete: 'Delete', + edit: 'Edit', + close: 'Close', + back: 'Back', + next: 'Next', + previous: 'Previous', + start: 'Start', + finish: 'Finish', + submit: 'Submit', + search: 'Search', + all: 'All', + none: 'None', + }, + + // Navigation + nav: { + dashboard: 'Dashboard', + learningMode: 'Learning Mode', + reports: 'Reports', + settings: 'Settings', + profile: 'My Profile', + logout: 'Logout', + notifications: 'Notifications', + }, + + // Sidebar categories + sidebar: { + learningMode: 'Learning Mode', + other: 'Other', + speaking: 'Speaking Practice', + writing: 'Writing Practice', + vocab: 'Vocabulary', + opicPractice: 'OPIC Practice', + opicDesc: 'Level-based training', + aiTalk: 'Talk with AI', + aiTalkDesc: 'Free conversation with AI', + chatPeople: 'Chat with People', + chatPeopleDesc: 'Practice with learners', + writingPractice: 'Composition', + writingPracticeDesc: 'Grammar & feedback', + vocabLearn: 'Learn Words', + vocabLearnDesc: '55 words per day', + vocabTest: 'Take Quiz', + vocabTestDesc: 'Multiple choice test', + vocabWords: 'Word List', + vocabWordsDesc: 'All your words', + dashboardDesc: 'Learning overview', + reportsDesc: 'Learning analytics', + settingsDesc: 'Account & app settings', + todayStudyTime: "Today's Study Time", + }, + + // Header + header: { + appName: 'AI Language Learning', + darkMode: 'Switch to dark mode', + lightMode: 'Switch to light mode', + notifications: 'Notifications', + user: 'User', + }, + + // Dashboard + dashboard: { + greeting: 'Hello!', + subtitle: 'What would you like to learn today?', + recentActivity: 'Recent Activity', + noHistory: 'No learning history yet', + startLearning: 'Start learning to see your progress here', + startButton: 'Start Learning', + speakingTitle: 'Speaking Practice', + speakingDesc: 'Improve speaking with OPIC and AI conversation', + writingTitle: 'Writing Practice', + writingDesc: 'Enhance writing through chat and composition', + vocabTitle: 'Vocabulary', + vocabDesc: 'Master 55 words daily for fluency', + opicTitle: 'OPIC Practice', + opicDesc: 'Level-based training', + aiTalkTitle: 'Talk with AI', + aiTalkDesc: 'Free conversation', + chatTitle: 'Chat with People', + chatDesc: 'Practice with learners', + compositionTitle: 'Composition', + compositionDesc: 'Grammar & feedback', + dailyWordsTitle: 'Daily Words', + dailyWordsDesc: '55 words per day', + quizTitle: 'Take Quiz', + quizDesc: 'Multiple choice test', + wordListTitle: 'Word List', + wordListDesc: 'All your words', + }, + + // Vocab Dashboard + vocabDash: { + title: 'Vocabulary', + subtitle: "Today's learning progress", + todayProgress: "Today's Progress", + wordsLearned: 'Words Learned', + accuracy: 'Accuracy', + streak: 'Streak', + days: 'days', + weeklyProgress: 'Weekly Progress', + quickActions: 'Quick Start', + startDailyLearning: 'Start Daily Learning', + takeQuiz: 'Take Quiz', + viewWordList: 'View Word List', + viewStats: 'View Statistics', + focusWords: 'Focus Words', + weakWords: 'Weak Words', + noWeakWords: 'No weak words', + }, + + // Daily Learning + dailyLearning: { + title: "Today's Words", + selectLevel: 'Select Level', + beginnerTitle: 'Beginner', + beginnerDesc: 'Basic vocabulary', + intermediateTitle: 'Intermediate', + intermediateDesc: 'Practical vocabulary', + advancedTitle: 'Advanced', + advancedDesc: 'Professional vocabulary', + wordsCount: 'words', + progress: 'Progress', + tapToFlip: 'Tap card to flip', + knew: 'Knew it', + didntKnow: "Didn't know", + complete: 'Learning Complete!', + totalWords: 'Words Learned', + correctCount: 'Correct', + accuracy: 'Accuracy', + goBack: 'Go Back', + continueStudy: 'Continue', + takeQuiz: 'Take Quiz', + greatJob: 'Great Job!', + completedSession: "You've completed today's learning", + correct: 'Correct', + incorrect: 'Incorrect', + practiceAgain: 'Practice Again', + backToDashboard: 'Dashboard', + dontKnow: "Don't Know", + knowIt: 'Know It', + previous: 'Previous', + skip: 'Skip', + finish: 'Finish', + bookmark: 'Bookmark', + removeBookmark: 'Remove Bookmark', + }, + + // Test Page + test: { + title: 'Vocabulary Quiz', + subtitle: 'Test your vocabulary with multiple choice', + startButton: 'Start Quiz', + recentResults: 'Recent Results', + noResults: 'No quiz history yet', + startFirst: 'Take your first quiz!', + question: 'questions', + of: '/', + timeLeft: 'Time Left', + seconds: 'sec', + selectAnswer: 'Select your answer', + results: 'Quiz Results', + score: ' pts', + correct: 'Correct', + incorrect: 'Incorrect', + time: 'Time', + retake: 'Retake', + reviewWrong: 'Review Incorrect', + finish: 'Finish', + excellent: 'Excellent!', + good: 'Good job!', + needPractice: 'Keep practicing!', + myAnswer: 'Your Answer', + correctAnswer: 'Correct Answer', + noAnswer: 'No answer', + toDashboard: 'Dashboard', + }, + + // Word List + wordList: { + title: 'My Word List', + wordsCount: 'words', + searchPlaceholder: 'Search words...', + filterAll: 'All', + filterBookmarked: 'Bookmarked', + filterIncorrect: 'Incorrect', + noBookmarks: 'No bookmarked words', + noIncorrect: 'No incorrect words', + noWords: 'No learned words yet', + startLearning: 'Start Learning', + loadedAll: 'All words loaded', + correct: 'Correct', + incorrect: 'Incorrect', + }, + + // Word Detail Modal + wordDetail: { + example: 'Example', + pronunciation: 'Pronunciation', + voiceFemale: 'Female', + voiceMale: 'Male', + listen: 'Listen', + playing: 'Playing...', + learningStatus: 'Learning Status', + correctCount: 'Correct', + incorrectCount: 'Incorrect', + accuracyLabel: 'Accuracy', + lastReviewed: 'Last Reviewed', + nextReview: 'Next Review', + difficulty: 'Difficulty', + }, + + // Settings + settings: { + title: 'Settings', + subtitle: 'Customize your app preferences', + language: 'Language', + languageDesc: 'Select your preferred language', + korean: '한국어', + english: 'English', + ttsVoice: 'TTS Voice', + ttsVoiceDesc: 'Select the voice for text-to-speech', + femaleVoice: 'Female Voice', + maleVoice: 'Male Voice', + theme: 'Theme', + themeDesc: 'Select app theme', + lightTheme: 'Light Mode', + darkTheme: 'Dark Mode', + systemTheme: 'System Default', + }, + + // Levels + levels: { + BEGINNER: 'Beginner', + INTERMEDIATE: 'Intermediate', + ADVANCED: 'Advanced', + }, + + // Word Status + wordStatus: { + NEW: 'New', + LEARNING: 'Learning', + REVIEWING: 'Reviewing', + MASTERED: 'Mastered', + }, + + // Statistics Page + stats: { + title: 'Learning Statistics', + daily: 'Daily', + weekly: 'Weekly', + monthly: 'Monthly', + totalLearned: 'Total Learned', + outOf: 'out of', + avgAccuracy: 'Average Accuracy', + streak: 'Streak', + days: ' days', + weakWords: 'Weak Words', + needReview: 'Need review', + learningHistory: 'Learning History', + levelProgress: 'Level Progress', + difficultyDist: 'Difficulty Distribution', + weakWordsTop10: 'Weak Words TOP 10', + review: 'Review', + noWeakWords: 'No weak words', + less: 'Less', + more: 'More', + }, + + // Footer + footer: { + copyright: '© 2026 AI Language Learning. All rights reserved.', + terms: 'Terms of Service', + privacy: 'Privacy Policy', + contact: 'Contact Us', + }, + + // 404 Page + notFound: { + title: 'Page Not Found', + message: "The page you're looking for doesn't exist or has been moved.", + backHome: 'Back to Home', + }, + }, +} + +export const LANGUAGES = { + KO: 'ko', + EN: 'en', +} + +export const LANGUAGE_LABELS = { + ko: '한국어', + en: 'English', +} diff --git a/src/index.css b/src/index.css index cc31903..02b1ee7 100644 --- a/src/index.css +++ b/src/index.css @@ -1,3 +1,84 @@ +/* Fresh Editorial Design System */ +@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap'); + +/* CSS Custom Properties - Design Tokens */ +:root { + /* Primary - Fresh Emerald */ + --color-primary-50: #ecfdf5; + --color-primary-100: #d1fae5; + --color-primary-200: #a7f3d0; + --color-primary-300: #6ee7b7; + --color-primary-400: #34d399; + --color-primary-500: #10b981; + --color-primary-600: #059669; + --color-primary-700: #047857; + --color-primary-800: #065f46; + --color-primary-900: #064e3b; + + /* Accent - Warm Coral */ + --color-accent-50: #fff7ed; + --color-accent-100: #ffedd5; + --color-accent-200: #fed7aa; + --color-accent-300: #fdba74; + --color-accent-400: #fb923c; + --color-accent-500: #f97316; + --color-accent-600: #ea580c; + --color-accent-700: #c2410c; + + /* Semantic Colors */ + --color-success: #10b981; + --color-warning: #f59e0b; + --color-error: #ef4444; + --color-info: #3b82f6; + + /* Neutrals - Warm Gray */ + --color-gray-50: #fafaf9; + --color-gray-100: #f5f5f4; + --color-gray-200: #e7e5e4; + --color-gray-300: #d6d3d1; + --color-gray-400: #a8a29e; + --color-gray-500: #78716c; + --color-gray-600: #57534e; + --color-gray-700: #44403c; + --color-gray-800: #292524; + --color-gray-900: #1c1917; + + /* Typography Scale */ + --font-display: 'Outfit', system-ui, sans-serif; + --font-body: 'DM Sans', system-ui, sans-serif; + + /* Spacing */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-12: 3rem; + --space-16: 4rem; + + /* Shadows */ + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); + --shadow-glow: 0 0 40px -10px var(--color-primary-400); + + /* Border Radius */ + --radius-sm: 0.375rem; + --radius-md: 0.5rem; + --radius-lg: 0.75rem; + --radius-xl: 1rem; + --radius-2xl: 1.5rem; + --radius-full: 9999px; + + /* Transitions */ + --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-bounce: 500ms cubic-bezier(0.34, 1.56, 0.64, 1); +} + * { margin: 0; padding: 0; @@ -5,9 +86,12 @@ } body { - font-family: 'Roboto', 'Noto Sans KR', sans-serif; + font-family: var(--font-body); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + background-color: var(--color-gray-50); + color: var(--color-gray-900); + line-height: 1.6; } #root { @@ -18,3 +102,125 @@ a { text-decoration: none; color: inherit; } + +/* Headings use display font */ +h1, h2, h3, h4, h5, h6 { + font-family: var(--font-display); + line-height: 1.2; +} + +/* Custom Scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--color-gray-100); + border-radius: var(--radius-full); +} + +::-webkit-scrollbar-thumb { + background: var(--color-gray-300); + border-radius: var(--radius-full); +} + +::-webkit-scrollbar-thumb:hover { + background: var(--color-gray-400); +} + +/* Selection */ +::selection { + background-color: var(--color-primary-200); + color: var(--color-primary-900); +} + +/* Focus Styles */ +:focus-visible { + outline: 2px solid var(--color-primary-500); + outline-offset: 2px; +} + +/* Animations */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +@keyframes float { + 0%, 100% { transform: translateY(0px); } + 50% { transform: translateY(-6px); } +} + +@keyframes celebrate { + 0% { transform: scale(1) rotate(0deg); } + 25% { transform: scale(1.1) rotate(-5deg); } + 50% { transform: scale(1.2) rotate(5deg); } + 75% { transform: scale(1.1) rotate(-3deg); } + 100% { transform: scale(1) rotate(0deg); } +} + +/* Utility Classes */ +.animate-fadeIn { animation: fadeIn var(--transition-base) ease-out; } +.animate-slideUp { animation: slideUp var(--transition-slow) ease-out; } +.animate-scaleIn { animation: scaleIn var(--transition-base) ease-out; } +.animate-pulse { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; } +.animate-float { animation: float 3s ease-in-out infinite; } +.animate-celebrate { animation: celebrate 0.6s ease-in-out; } + +/* Gradient Text */ +.gradient-text { + background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-accent-500) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* Glass Effect */ +.glass { + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +/* Card Hover Effect */ +.card-hover { + transition: transform var(--transition-base), box-shadow var(--transition-base); +} + +.card-hover:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-xl); +} diff --git a/src/layouts/MainLayout/Footer/index.jsx b/src/layouts/MainLayout/Footer/index.jsx index 5a54f86..7e78215 100644 --- a/src/layouts/MainLayout/Footer/index.jsx +++ b/src/layouts/MainLayout/Footer/index.jsx @@ -1,6 +1,9 @@ -import { Box, Typography, Link, Container, Divider } from '@mui/material' +import { Box, Typography, Link, Container } from '@mui/material' +import { useTranslation } from '../../../contexts/SettingsContext' const Footer = () => { + const { t } = useTranslation() + return ( { gap: 2, }} > - {/* 저작권 */} + {/* Copyright */} - © 2026 AI Language Learning. All rights reserved. + {t('footer.copyright')} - {/* 링크 */} + {/* Links */} { underline="hover" variant="body2" > - 이용약관 + {t('footer.terms')} { underline="hover" variant="body2" > - 개인정보처리방침 + {t('footer.privacy')} { underline="hover" variant="body2" > - 고객센터 + {t('footer.contact')} diff --git a/src/layouts/MainLayout/Header/index.jsx b/src/layouts/MainLayout/Header/index.jsx index 90f60d6..3e37464 100644 --- a/src/layouts/MainLayout/Header/index.jsx +++ b/src/layouts/MainLayout/Header/index.jsx @@ -6,7 +6,6 @@ import { Typography, IconButton, Box, - Button, Avatar, Menu, MenuItem, @@ -14,6 +13,7 @@ import { Badge, useMediaQuery, useTheme, + Chip, } from '@mui/material' import { Menu as MenuIcon, @@ -21,20 +21,25 @@ import { Person as PersonIcon, Settings as SettingsIcon, Logout as LogoutIcon, - School as SchoolIcon, DarkMode as DarkModeIcon, LightMode as LightModeIcon, + Translate as TranslateIcon, } from '@mui/icons-material' import { useThemeMode } from '../../../contexts/ThemeContext' +import { useSettings, useTranslation } from '../../../contexts/SettingsContext' +import { LANGUAGES, LANGUAGE_LABELS } from '../../../i18n/translations' 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 [anchorEl, setAnchorEl] = useState(null) const [notificationAnchor, setNotificationAnchor] = useState(null) + const [langAnchor, setLangAnchor] = useState(null) const handleProfileMenuOpen = (event) => { setAnchorEl(event.currentTarget) @@ -52,116 +57,179 @@ const Header = ({ onMenuClick, sidebarOpen }) => { setNotificationAnchor(null) } + const handleLangOpen = (event) => { + setLangAnchor(event.currentTarget) + } + + const handleLangClose = () => { + setLangAnchor(null) + } + + const handleLanguageChange = (lang) => { + setLanguage(lang) + handleLangClose() + } + const handleLogout = () => { handleProfileMenuClose() - // TODO: 로그아웃 로직 navigate('/login') } return ( - - {/* 햄버거 메뉴 (모바일/태블릿) */} + + {/* Hamburger menu (mobile) */} {isMobile && ( )} - {/* 로고 & 서비스명 */} + {/* Logo */} navigate('/')} > - - - AI 언어 학습 - - - - {/* 중앙 네비게이션 (데스크톱) */} - {!isMobile && ( - - - - + {t('header.appName')} + - )} + - {/* 우측 아이콘들 */} - - {/* 다크모드 토글 */} + {/* Right side icons */} + + {/* Language selector */} + + + + + {/* Dark mode toggle */} - {mode === 'dark' ? : } + {mode === 'dark' ? : } - {/* 알림 */} + {/* Notifications */} - - + + - {/* 프로필 */} + {/* Profile */} U @@ -169,83 +237,171 @@ const Header = ({ onMenuClick, sidebarOpen }) => { - {/* 알림 메뉴 */} + {/* Language Menu */} + + + + {t('settings.language')} + + + + {Object.entries(LANGUAGES).map(([key, value]) => ( + handleLanguageChange(value)} + selected={language === value} + sx={{ + py: 1.5, + px: 2, + '&.Mui-selected': { + backgroundColor: 'rgba(5, 150, 105, 0.08)', + '&:hover': { + backgroundColor: 'rgba(5, 150, 105, 0.12)', + }, + }, + }} + > + + + {LANGUAGE_LABELS[value]} + + {language === value && ( + + )} + + + ))} + + + {/* Notification Menu */} - - - 알림 + + + {t('nav.notifications')} + - - - - 면접 연습 세션이 완료되었습니다. - - - 10분 전 - - - - - - - OPIC 모의고사 결과가 도착했습니다. - - - 1시간 전 - - - - - - - 새로운 학습 리포트가 생성되었습니다. - - - 어제 - - - + {[ + { text: language === 'ko' ? '면접 연습 세션이 완료되었습니다.' : 'Interview session completed.', time: language === 'ko' ? '10분 전' : '10 min ago' }, + { text: language === 'ko' ? 'OPIC 모의고사 결과가 도착했습니다.' : 'OPIC test results arrived.', time: language === 'ko' ? '1시간 전' : '1 hour ago' }, + { text: language === 'ko' ? '새로운 학습 리포트가 생성되었습니다.' : 'New learning report generated.', time: language === 'ko' ? '어제' : 'Yesterday' }, + ].map((item, index) => ( + + + + {item.text} + + + {item.time} + + + + ))} - {/* 프로필 메뉴 */} + {/* Profile Menu */} - - - 사용자님 + + + {t('header.user')} user@example.com - { handleProfileMenuClose(); navigate('/profile'); }}> - - 내 프로필 + { handleProfileMenuClose(); navigate('/profile'); }} + sx={{ py: 1.5, px: 2.5 }} + > + + {t('nav.profile')} - { handleProfileMenuClose(); navigate('/settings'); }}> - - 설정 + { handleProfileMenuClose(); navigate('/settings'); }} + sx={{ py: 1.5, px: 2.5 }} + > + + {t('nav.settings')} - - - 로그아웃 + + + {t('nav.logout')} diff --git a/src/layouts/MainLayout/HorizontalNav/index.jsx b/src/layouts/MainLayout/HorizontalNav/index.jsx new file mode 100644 index 0000000..d63db89 --- /dev/null +++ b/src/layouts/MainLayout/HorizontalNav/index.jsx @@ -0,0 +1,347 @@ +import { useState, useRef, useEffect } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' +import { + Box, + Typography, + Paper, + Fade, + useTheme, + useMediaQuery, +} from '@mui/material' +import { + Mic as SpeakingIcon, + Create as WritingIcon, + MenuBook as VocabIcon, + Dashboard as DashboardIcon, + Assessment as ReportIcon, + Settings as SettingsIcon, + Headphones as OpicIcon, + SmartToy as AiIcon, + People as PeopleIcon, + Edit as WriteIcon, + School as LearnIcon, + Quiz as QuizIcon, + LibraryBooks as WordListIcon, + TrendingUp as TrendingIcon, +} from '@mui/icons-material' +import { useThemeMode } from '../../../contexts/ThemeContext' +import { useTranslation } from '../../../contexts/SettingsContext' + +// 고정 크기로 일정한 드롭다운 보장 +const DROPDOWN_ITEM_WIDTH = 200 +const DROPDOWN_ITEM_HEIGHT = 72 // 각 아이템의 고정 높이 +const DROPDOWN_PADDING = 12 + +const HorizontalNav = () => { + const theme = useTheme() + const { mode } = useThemeMode() + const location = useLocation() + const navigate = useNavigate() + const { t } = useTranslation() + const isMobile = useMediaQuery(theme.breakpoints.down('md')) + + const [activeMenu, setActiveMenu] = useState(null) + const navRef = useRef(null) + const timeoutRef = useRef(null) + + // 메뉴 정의 + const menuItems = [ + { + id: 'speaking', + label: t('sidebar.speaking'), + icon: SpeakingIcon, + color: '#3b82f6', + children: [ + { id: 'opic', label: t('sidebar.opicPractice'), icon: OpicIcon, path: '/opic', desc: t('sidebar.opicDesc') }, + { id: 'ai-talk', label: t('sidebar.aiTalk'), icon: AiIcon, path: '/freetalk/ai', desc: t('sidebar.aiTalkDesc') }, + ], + }, + { + id: 'writing', + label: t('sidebar.writing'), + icon: WritingIcon, + color: '#8b5cf6', + children: [ + { id: 'chat-people', label: t('sidebar.chatPeople'), icon: PeopleIcon, path: '/freetalk/people', desc: t('sidebar.chatPeopleDesc') }, + { id: 'writing-practice', label: t('sidebar.writingPractice'), icon: WriteIcon, path: '/writing', desc: t('sidebar.writingPracticeDesc') }, + ], + }, + { + id: 'vocab', + label: t('sidebar.vocab'), + icon: VocabIcon, + color: '#059669', + children: [ + { id: 'vocab-daily', label: t('sidebar.vocabLearn'), icon: LearnIcon, path: '/vocab', desc: t('sidebar.vocabLearnDesc') }, + { id: 'vocab-test', label: t('sidebar.vocabTest'), icon: QuizIcon, path: '/vocab/test', desc: t('sidebar.vocabTestDesc') }, + { id: 'vocab-words', label: t('sidebar.vocabWords'), icon: WordListIcon, path: '/vocab/words', desc: t('sidebar.vocabWordsDesc') }, + { id: 'vocab-stats', label: t('vocabDash.viewStats'), icon: TrendingIcon, path: '/vocab/stats', desc: t('sidebar.reportsDesc') }, + ], + }, + { + id: 'dashboard', + label: t('nav.dashboard'), + icon: DashboardIcon, + color: '#f97316', + path: '/dashboard', + }, + { + id: 'reports', + label: t('nav.reports'), + icon: ReportIcon, + color: '#ec4899', + path: '/reports', + }, + { + id: 'settings', + label: t('nav.settings'), + icon: SettingsIcon, + color: '#6b7280', + path: '/settings', + }, + ] + + // 가장 많은 하위 메뉴 개수 찾기 (일정한 드롭다운 높이를 위해) + const maxChildren = Math.max(...menuItems.filter(m => m.children).map(m => m.children.length), 0) + + const handleMouseEnter = (menuId) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + setActiveMenu(menuId) + } + + const handleMouseLeave = () => { + timeoutRef.current = setTimeout(() => { + setActiveMenu(null) + }, 150) + } + + const handleNavigation = (path) => { + navigate(path) + setActiveMenu(null) + } + + const isActive = (path) => location.pathname === path + const isParentActive = (children) => children?.some((child) => location.pathname.startsWith(child.path)) + + // 모바일에서는 숨기기 + if (isMobile) return null + + return ( + + {/* 메인 네비게이션 바 */} + + {menuItems.map((item) => { + const Icon = item.icon + const hasChildren = item.children && item.children.length > 0 + const active = item.path ? isActive(item.path) : isParentActive(item.children) + const isOpen = activeMenu === item.id && hasChildren + + return ( + handleMouseEnter(item.id)} + onMouseLeave={handleMouseLeave} + sx={{ position: 'relative' }} + > + {/* 메인 메뉴 버튼 */} + !hasChildren && handleNavigation(item.path)} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 1, + px: 2, + py: 1.5, + borderRadius: '12px', + cursor: 'pointer', + backgroundColor: active + ? `${item.color}15` + : isOpen + ? (mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)') + : 'transparent', + border: '2px solid', + borderColor: active ? item.color : 'transparent', + transition: 'all 0.2s ease', + '&:hover': { + backgroundColor: active + ? `${item.color}20` + : mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)', + }, + }} + > + + + + + {item.label} + + + + {/* 드롭다운 메뉴 */} + {hasChildren && ( + + handleMouseEnter(item.id)} + onMouseLeave={handleMouseLeave} + sx={{ + position: 'absolute', + top: '100%', + left: '50%', + transform: 'translateX(-50%)', + mt: 0.5, + borderRadius: '16px', + overflow: 'hidden', + // 고정 너비로 모든 드롭다운 크기 통일 + width: DROPDOWN_ITEM_WIDTH + DROPDOWN_PADDING * 2, + // 최대 하위 메뉴 개수 기준으로 고정 높이 설정 + minHeight: maxChildren * DROPDOWN_ITEM_HEIGHT + DROPDOWN_PADDING * 2, + backgroundColor: mode === 'dark' ? '#1e1e1e' : 'white', + boxShadow: mode === 'dark' + ? '0 10px 40px -10px rgba(0,0,0,0.5)' + : '0 10px 40px -10px rgba(0,0,0,0.15)', + display: isOpen ? 'block' : 'none', + }} + > + + {item.children.map((child) => { + const ChildIcon = child.icon + const childActive = isActive(child.path) + + return ( + handleNavigation(child.path)} + sx={{ + // 고정 높이로 아이템 크기 통일 + height: DROPDOWN_ITEM_HEIGHT - 8, + p: 1.5, + mb: 1, + borderRadius: '12px', + cursor: 'pointer', + backgroundColor: childActive + ? `${item.color}10` + : 'transparent', + transition: 'all 0.2s ease', + '&:hover': { + backgroundColor: childActive + ? `${item.color}15` + : mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)', + }, + '&:last-child': { + mb: 0, + }, + }} + > + + + + + + + {child.label} + + {child.desc && ( + + {child.desc} + + )} + + + + ) + })} + + + + )} + + ) + })} + + + ) +} + +export default HorizontalNav diff --git a/src/layouts/MainLayout/Sidebar/index.jsx b/src/layouts/MainLayout/Sidebar/index.jsx index 63daf2f..963a65c 100644 --- a/src/layouts/MainLayout/Sidebar/index.jsx +++ b/src/layouts/MainLayout/Sidebar/index.jsx @@ -14,6 +14,7 @@ import { Collapse, useTheme, useMediaQuery, + LinearProgress, } from '@mui/material' import { ChevronLeft as ChevronLeftIcon, @@ -33,131 +34,154 @@ import { School as LearnIcon, Quiz as QuizIcon, LibraryBooks as WordListIcon, + TrendingUp as TrendingIcon, } from '@mui/icons-material' +import { useThemeMode } from '../../../contexts/ThemeContext' +import { useTranslation } from '../../../contexts/SettingsContext' -const DRAWER_WIDTH = 260 -const DRAWER_WIDTH_COLLAPSED = 72 - -const menuItems = [ - { - category: '학습 모드', - items: [ - { - id: 'speaking', - label: '말하기연습', - icon: SpeakingIcon, - children: [ - { - id: 'opic', - label: '오픽연습', - icon: OpicIcon, - path: '/opic', - description: '레벨별 맞춤 연습' - }, - { - id: 'ai-talk', - label: 'AI와 말해보기', - icon: AiIcon, - path: '/freetalk/ai', - description: 'AI와 자유로운 대화' - }, - ], - }, - { - id: 'writing', - label: '쓰기연습', - icon: WritingCategoryIcon, - children: [ - { - id: 'chat-people', - label: '사람들과 채팅하기', - icon: PeopleIcon, - path: '/freetalk/people', - description: '다른 학습자와 대화' - }, - { - id: 'writing-practice', - label: '작문연습', - icon: WritingIcon, - path: '/writing', - description: '문법 교정 & 피드백' - }, - ], - }, - { - id: 'vocab', - label: '단어 학습', - icon: VocabIcon, - children: [ - { - id: 'vocab-daily', - label: '단어 외우기', - icon: LearnIcon, - path: '/vocab', - description: '매일 55개 단어 학습' - }, - { - id: 'vocab-test', - label: '시험 보기', - icon: QuizIcon, - path: '/vocab/test', - description: '4지선다 퀴즈' - }, - { - id: 'vocab-words', - label: '단어장', - icon: WordListIcon, - path: '/vocab/words', - description: '전체 단어 목록' - }, - ], - }, - ], - }, - { - category: '기타', - items: [ - { - id: 'dashboard', - label: '대시보드', - icon: DashboardIcon, - path: '/dashboard', - description: '학습 현황 요약' - }, - { - id: 'reports', - label: '내 리포트', - icon: ReportIcon, - path: '/reports', - description: '학습 결과 분석' - }, - { - id: 'settings', - label: '설정', - icon: SettingsIcon, - path: '/settings', - description: '계정 및 앱 설정' - }, - ], - }, -] +const DRAWER_WIDTH = 280 +const DRAWER_WIDTH_COLLAPSED = 76 const Sidebar = ({ open, collapsed, onToggleCollapse, onClose }) => { const theme = useTheme() + const { mode } = useThemeMode() const location = useLocation() const navigate = useNavigate() const isMobile = useMediaQuery(theme.breakpoints.down('md')) + const { t } = useTranslation() - // 펼침 상태 (localStorage 저장) const [expandedMenus, setExpandedMenus] = useState(() => { const saved = localStorage.getItem('expandedMenus') - return saved ? JSON.parse(saved) : { speaking: true, writing: true } + return saved ? JSON.parse(saved) : { speaking: true, writing: true, vocab: true } }) useEffect(() => { localStorage.setItem('expandedMenus', JSON.stringify(expandedMenus)) }, [expandedMenus]) + const menuItems = [ + { + category: t('sidebar.learningMode'), + items: [ + { + id: 'speaking', + label: t('sidebar.speaking'), + icon: SpeakingIcon, + color: '#3b82f6', + bgColor: '#eff6ff', + children: [ + { + id: 'opic', + label: t('sidebar.opicPractice'), + icon: OpicIcon, + path: '/opic', + description: t('sidebar.opicDesc'), + }, + { + id: 'ai-talk', + label: t('sidebar.aiTalk'), + icon: AiIcon, + path: '/freetalk/ai', + description: t('sidebar.aiTalkDesc'), + }, + ], + }, + { + id: 'writing', + label: t('sidebar.writing'), + icon: WritingCategoryIcon, + color: '#8b5cf6', + bgColor: '#f5f3ff', + children: [ + { + id: 'chat-people', + label: t('sidebar.chatPeople'), + icon: PeopleIcon, + path: '/freetalk/people', + description: t('sidebar.chatPeopleDesc'), + }, + { + id: 'writing-practice', + label: t('sidebar.writingPractice'), + icon: WritingIcon, + path: '/writing', + description: t('sidebar.writingPracticeDesc'), + }, + ], + }, + { + id: 'vocab', + label: t('sidebar.vocab'), + icon: VocabIcon, + color: '#059669', + bgColor: '#ecfdf5', + children: [ + { + id: 'vocab-daily', + label: t('sidebar.vocabLearn'), + icon: LearnIcon, + path: '/vocab', + description: t('sidebar.vocabLearnDesc'), + }, + { + id: 'vocab-test', + label: t('sidebar.vocabTest'), + icon: QuizIcon, + path: '/vocab/test', + description: t('sidebar.vocabTestDesc'), + }, + { + id: 'vocab-words', + label: t('sidebar.vocabWords'), + icon: WordListIcon, + path: '/vocab/words', + description: t('sidebar.vocabWordsDesc'), + }, + { + id: 'vocab-stats', + label: t('vocabDash.viewStats'), + icon: TrendingIcon, + path: '/vocab/stats', + description: t('sidebar.reportsDesc'), + }, + ], + }, + ], + }, + { + category: t('sidebar.other'), + items: [ + { + id: 'dashboard', + label: t('nav.dashboard'), + icon: DashboardIcon, + path: '/dashboard', + description: t('sidebar.dashboardDesc'), + color: '#f97316', + bgColor: '#fff7ed', + }, + { + id: 'reports', + label: t('nav.reports'), + icon: ReportIcon, + path: '/reports', + description: t('sidebar.reportsDesc'), + color: '#ec4899', + bgColor: '#fdf2f8', + }, + { + id: 'settings', + label: t('nav.settings'), + icon: SettingsIcon, + path: '/settings', + description: t('sidebar.settingsDesc'), + color: '#6b7280', + bgColor: '#f3f4f6', + }, + ], + }, + ] + const drawerWidth = collapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH const handleNavigation = (path) => { @@ -182,6 +206,8 @@ const Sidebar = ({ open, collapsed, onToggleCollapse, onClose }) => { const hasChildren = item.children && item.children.length > 0 const active = item.path ? isActive(item.path) : isParentActive(item.children) const expanded = expandedMenus[item.id] + const itemColor = item.color || '#059669' + const itemBgColor = item.bgColor || '#ecfdf5' return ( @@ -195,17 +221,23 @@ const Sidebar = ({ open, collapsed, onToggleCollapse, onClose }) => { } }} sx={{ - borderRadius: 2, - minHeight: 48, + borderRadius: '14px', + minHeight: collapsed ? 48 : 52, justifyContent: collapsed ? 'center' : 'flex-start', - px: collapsed ? 1 : 2, - pl: isChild && !collapsed ? 4 : (collapsed ? 1 : 2), - backgroundColor: active && !hasChildren ? 'primary.main' : 'transparent', - color: active && !hasChildren ? 'white' : 'text.primary', + px: collapsed ? 1.5 : 2, + pl: isChild && !collapsed ? 3.5 : (collapsed ? 1.5 : 2), + mx: 1, + backgroundColor: active && !hasChildren + ? itemBgColor + : 'transparent', + border: '2px solid', + borderColor: active && !hasChildren ? itemColor : 'transparent', + transition: 'all 0.2s ease', '&:hover': { backgroundColor: active && !hasChildren - ? 'primary.dark' - : 'action.hover', + ? itemBgColor + : mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)', + borderColor: active && !hasChildren ? itemColor : 'transparent', }, }} > @@ -213,39 +245,67 @@ const Sidebar = ({ open, collapsed, onToggleCollapse, onClose }) => { sx={{ minWidth: collapsed ? 0 : 40, justifyContent: 'center', - color: active && !hasChildren ? 'white' : 'primary.main', }} > - + + + {!collapsed && ( <> {hasChildren && ( - expanded ? : + + {expanded ? ( + + ) : ( + + )} + )} )} - {/* 하위 메뉴 */} {hasChildren && !collapsed && ( - + - {item.children.map((child) => renderMenuItem(child, true))} + {item.children.map((child) => renderMenuItem({ ...child, color: itemColor, bgColor: itemBgColor }, true))} )} @@ -254,34 +314,58 @@ const Sidebar = ({ open, collapsed, onToggleCollapse, onClose }) => { } const drawerContent = ( - - {/* 헤더 영역 - Toolbar 높이만큼 여백 */} + + {/* Header spacing */} - {/* 접기/펼치기 버튼 */} + {/* Collapse toggle */} {!isMobile && ( - - - {collapsed ? : } + + + {collapsed ? ( + + ) : ( + + )} )} - {/* 메뉴 리스트 */} - + {/* Menu list */} + {menuItems.map((category, categoryIndex) => ( {!collapsed && ( {category.category} @@ -293,27 +377,68 @@ const Sidebar = ({ open, collapsed, onToggleCollapse, onClose }) => { {categoryIndex < menuItems.length - 1 && !collapsed && ( - + )} ))} - {/* 하단 정보 */} + {/* Bottom stats */} {!collapsed && ( - - - 오늘의 학습 시간 + + + {t('sidebar.todayStudyTime')} - - 1시간 23분 + + 1h 23m + + + + {t('vocabDash.todayProgress')} + + + 68% + + + + )} ) - // 모바일: 임시 Drawer + // Mobile: Temporary Drawer if (isMobile) { return ( { '& .MuiDrawer-paper': { width: DRAWER_WIDTH, boxSizing: 'border-box', + borderRight: 'none', + boxShadow: '4px 0 24px -4px rgba(0,0,0,0.1)', }, }} > @@ -333,7 +460,7 @@ const Sidebar = ({ open, collapsed, onToggleCollapse, onClose }) => { ) } - // 데스크톱: 고정 Drawer + // Desktop: Permanent Drawer return ( { '& .MuiDrawer-paper': { width: drawerWidth, boxSizing: 'border-box', + borderRight: '1px solid', + borderColor: mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)', transition: theme.transitions.create('width', { easing: theme.transitions.easing.sharp, duration: theme.transitions.duration.enteringScreen, diff --git a/src/layouts/MainLayout/index.jsx b/src/layouts/MainLayout/index.jsx index bc3d376..d877ea9 100644 --- a/src/layouts/MainLayout/index.jsx +++ b/src/layouts/MainLayout/index.jsx @@ -3,10 +3,14 @@ import { Outlet } from 'react-router-dom' import { Box, useTheme, useMediaQuery } from '@mui/material' import Header from './Header' import Sidebar from './Sidebar' +import HorizontalNav from './HorizontalNav' import Footer from './Footer' -const DRAWER_WIDTH = 260 -const DRAWER_WIDTH_COLLAPSED = 72 +const DRAWER_WIDTH = 280 +const DRAWER_WIDTH_COLLAPSED = 76 + +// 가로 네비게이션 사용 여부 (true: 가로 네비게이션, false: 사이드바) +const USE_HORIZONTAL_NAV = true const MainLayout = () => { const theme = useTheme() @@ -38,7 +42,13 @@ const MainLayout = () => { setCollapsed(!collapsed) } - const drawerWidth = isMobile ? 0 : (collapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH) + // 가로 네비게이션 사용 시 사이드바 너비는 0 + const drawerWidth = USE_HORIZONTAL_NAV + ? 0 + : (isMobile ? 0 : (collapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH)) + + // 헤더 + 가로 네비게이션 높이 (64 + 56) + const topOffset = USE_HORIZONTAL_NAV && !isMobile ? 120 : 64 return ( @@ -48,13 +58,18 @@ const MainLayout = () => { sidebarOpen={mobileOpen} /> - {/* Sidebar */} - + {/* 가로 네비게이션 (데스크톱) */} + {USE_HORIZONTAL_NAV && } + + {/* 사이드바 (모바일에서만 사용하거나 USE_HORIZONTAL_NAV가 false일 때) */} + {(!USE_HORIZONTAL_NAV || isMobile) && ( + + )} {/* Main Content */} { }), }} > - {/* Toolbar 높이만큼 여백 */} - + {/* 상단 여백 (헤더 + 가로 네비게이션) */} + {/* 콘텐츠 영역 */} { + const levelKey = level?.toLowerCase() || 'beginner' + const levelConfig = DESIGN_TOKENS.level[levelKey] || DESIGN_TOKENS.level.beginner + return { + backgroundColor: isDark ? levelConfig.bgDark : levelConfig.bg, + color: isDark ? levelConfig.textDark : levelConfig.text, + } +} + +// Helper function to get chat styles +export const getChatStyles = (isOwn, isDark = false) => { + if (isOwn) { + return { + backgroundColor: isDark + ? DESIGN_TOKENS.chat.ownMessage.dark + : DESIGN_TOKENS.chat.ownMessage.light, + } + } + return { + backgroundColor: isDark + ? DESIGN_TOKENS.chat.otherMessage.dark + : DESIGN_TOKENS.chat.otherMessage.light, + } +} + +// Fresh Editorial Design System - MUI Theme Integration const baseTheme = { typography: { - fontFamily: '"Roboto", "Noto Sans KR", "Helvetica", "Arial", sans-serif', - h1: { fontWeight: 700 }, - h2: { fontWeight: 600 }, - h3: { fontWeight: 600 }, - h4: { fontWeight: 600 }, - h5: { fontWeight: 500 }, - h6: { fontWeight: 500 }, + fontFamily: '"DM Sans", "Noto Sans KR", system-ui, sans-serif', + h1: { + fontFamily: '"Outfit", "Noto Sans KR", system-ui, sans-serif', + fontWeight: 800, + letterSpacing: '-0.02em', + }, + h2: { + fontFamily: '"Outfit", "Noto Sans KR", system-ui, sans-serif', + fontWeight: 700, + letterSpacing: '-0.01em', + }, + h3: { + fontFamily: '"Outfit", "Noto Sans KR", system-ui, sans-serif', + fontWeight: 700, + letterSpacing: '-0.01em', + }, + h4: { + fontFamily: '"Outfit", "Noto Sans KR", system-ui, sans-serif', + fontWeight: 600, + }, + h5: { + fontFamily: '"Outfit", "Noto Sans KR", system-ui, sans-serif', + fontWeight: 600, + }, + h6: { + fontFamily: '"Outfit", "Noto Sans KR", system-ui, sans-serif', + fontWeight: 600, + }, + subtitle1: { + fontWeight: 500, + }, + subtitle2: { + fontWeight: 600, + }, + body1: { + lineHeight: 1.6, + }, + body2: { + lineHeight: 1.5, + }, + button: { + fontFamily: '"Outfit", "Noto Sans KR", system-ui, sans-serif', + fontWeight: 600, + letterSpacing: '0.01em', + }, }, shape: { - borderRadius: 8, + borderRadius: 12, }, + shadows: [ + 'none', + '0 1px 2px 0 rgb(0 0 0 / 0.05)', + '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)', + '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', + '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)', + '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)', + '0 25px 50px -12px rgb(0 0 0 / 0.25)', + '0 25px 50px -12px rgb(0 0 0 / 0.25)', + '0 25px 50px -12px rgb(0 0 0 / 0.25)', + '0 25px 50px -12px rgb(0 0 0 / 0.25)', + '0 25px 50px -12px rgb(0 0 0 / 0.25)', + '0 25px 50px -12px rgb(0 0 0 / 0.25)', + '0 25px 50px -12px rgb(0 0 0 / 0.25)', + '0 25px 50px -12px rgb(0 0 0 / 0.25)', + '0 25px 50px -12px rgb(0 0 0 / 0.25)', + '0 25px 50px -12px rgb(0 0 0 / 0.25)', + '0 25px 50px -12px rgb(0 0 0 / 0.25)', + '0 25px 50px -12px rgb(0 0 0 / 0.25)', + '0 25px 50px -12px rgb(0 0 0 / 0.25)', + '0 25px 50px -12px rgb(0 0 0 / 0.25)', + '0 25px 50px -12px rgb(0 0 0 / 0.25)', + '0 25px 50px -12px rgb(0 0 0 / 0.25)', + '0 25px 50px -12px rgb(0 0 0 / 0.25)', + '0 25px 50px -12px rgb(0 0 0 / 0.25)', + '0 25px 50px -12px rgb(0 0 0 / 0.25)', + ], components: { MuiButton: { styleOverrides: { root: { textTransform: 'none', - borderRadius: 8, - fontWeight: 500, + borderRadius: 10, + fontWeight: 600, + padding: '10px 20px', + transition: 'all 200ms cubic-bezier(0.4, 0, 0.2, 1)', + '&:hover': { + transform: 'translateY(-1px)', + }, + }, + contained: { + boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', + '&:hover': { + boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)', + }, + }, + sizeLarge: { + padding: '14px 28px', + fontSize: '1rem', }, }, }, MuiCard: { styleOverrides: { root: { - borderRadius: 12, + borderRadius: 16, + boxShadow: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)', + transition: 'all 200ms cubic-bezier(0.4, 0, 0.2, 1)', + '&:hover': { + boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)', + }, }, }, }, MuiPaper: { + styleOverrides: { + root: { + borderRadius: 16, + }, + elevation1: { + boxShadow: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)', + }, + elevation2: { + boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', + }, + elevation3: { + boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)', + }, + }, + }, + MuiChip: { + styleOverrides: { + root: { + fontWeight: 500, + borderRadius: 8, + }, + sizeSmall: { + height: 24, + fontSize: '0.75rem', + }, + }, + }, + MuiLinearProgress: { + styleOverrides: { + root: { + borderRadius: 8, + backgroundColor: 'rgba(0, 0, 0, 0.06)', + }, + bar: { + borderRadius: 8, + }, + }, + }, + MuiIconButton: { + styleOverrides: { + root: { + transition: 'all 200ms cubic-bezier(0.4, 0, 0.2, 1)', + '&:hover': { + transform: 'scale(1.1)', + }, + }, + }, + }, + MuiDialog: { + styleOverrides: { + paper: { + borderRadius: 20, + }, + }, + }, + MuiToggleButton: { + styleOverrides: { + root: { + textTransform: 'none', + fontWeight: 500, + borderRadius: 8, + }, + }, + }, + MuiAlert: { styleOverrides: { root: { borderRadius: 12, }, }, }, + MuiTextField: { + styleOverrides: { + root: { + '& .MuiOutlinedInput-root': { + borderRadius: 12, + }, + }, + }, + }, }, } -// 라이트 모드 +// Light Theme - Fresh Emerald export const lightTheme = createTheme({ ...baseTheme, palette: { mode: 'light', primary: { - main: '#0124ac', - light: '#4a52d4', - dark: '#001a7a', + main: '#059669', // Emerald 600 + light: '#34d399', // Emerald 400 + dark: '#047857', // Emerald 700 contrastText: '#ffffff', }, secondary: { - main: '#2196f3', - light: '#64b5f6', - dark: '#1565c0', + main: '#f97316', // Orange 500 + light: '#fb923c', // Orange 400 + dark: '#ea580c', // Orange 600 + contrastText: '#ffffff', + }, + success: { + main: '#10b981', // Emerald 500 + light: '#34d399', + dark: '#059669', + contrastText: '#ffffff', + }, + warning: { + main: '#f59e0b', // Amber 500 + light: '#fbbf24', + dark: '#d97706', + contrastText: '#1c1917', + }, + error: { + main: '#ef4444', // Red 500 + light: '#f87171', + dark: '#dc2626', + contrastText: '#ffffff', + }, + info: { + main: '#3b82f6', // Blue 500 + light: '#60a5fa', + dark: '#2563eb', contrastText: '#ffffff', }, background: { - default: '#f5f7fa', + default: '#fafaf9', // Stone 50 paper: '#ffffff', }, text: { - primary: '#1a1a2e', - secondary: '#6b7280', + primary: '#1c1917', // Stone 900 + secondary: '#57534e', // Stone 600 + disabled: '#a8a29e', // Stone 400 + }, + divider: '#e7e5e4', // Stone 200 + action: { + hover: 'rgba(5, 150, 105, 0.04)', + selected: 'rgba(5, 150, 105, 0.08)', + focus: 'rgba(5, 150, 105, 0.12)', }, - divider: '#e5e7eb', }, }) -// 다크 모드 +// Dark Theme export const darkTheme = createTheme({ ...baseTheme, palette: { mode: 'dark', primary: { - main: '#4a6cf7', - light: '#7b93f9', - dark: '#2148c4', - contrastText: '#ffffff', + main: '#34d399', // Emerald 400 + light: '#6ee7b7', // Emerald 300 + dark: '#10b981', // Emerald 500 + contrastText: '#064e3b', }, secondary: { - main: '#64b5f6', - light: '#90caf9', - dark: '#42a5f5', - contrastText: '#000000', + main: '#fb923c', // Orange 400 + light: '#fdba74', // Orange 300 + dark: '#f97316', // Orange 500 + contrastText: '#1c1917', + }, + success: { + main: '#34d399', + light: '#6ee7b7', + dark: '#10b981', + contrastText: '#064e3b', + }, + warning: { + main: '#fbbf24', + light: '#fcd34d', + dark: '#f59e0b', + contrastText: '#1c1917', + }, + error: { + main: '#f87171', + light: '#fca5a5', + dark: '#ef4444', + contrastText: '#1c1917', + }, + info: { + main: '#60a5fa', + light: '#93c5fd', + dark: '#3b82f6', + contrastText: '#1c1917', }, background: { - default: '#0f172a', - paper: '#1e293b', + default: '#0c0a09', // Stone 950 + paper: '#1c1917', // Stone 900 }, text: { - primary: '#f1f5f9', - secondary: '#94a3b8', + primary: '#fafaf9', // Stone 50 + secondary: '#a8a29e', // Stone 400 + disabled: '#78716c', // Stone 500 + }, + divider: '#292524', // Stone 800 + action: { + hover: 'rgba(52, 211, 153, 0.08)', + selected: 'rgba(52, 211, 153, 0.16)', + focus: 'rgba(52, 211, 153, 0.24)', }, - divider: '#334155', }, }) -// 기본 export (하위 호환성) export default lightTheme