diff --git a/src/App.jsx b/src/App.jsx index 9ab39ae..9956acc 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,50 +1,389 @@ -import { Routes, Route } from 'react-router-dom' -import { Box, Typography, Container, Button, Stack } from '@mui/material' +import { useState } from 'react' +import { Routes, Route, useNavigate } from 'react-router-dom' +import { + Box, + Typography, + Container, + Card, + CardContent, + Grid, + Button, + Collapse, + FormControl, + FormLabel, + RadioGroup, + FormControlLabel, + Radio, +} from '@mui/material' +import { + Mic as SpeakingIcon, + Create as WritingCategoryIcon, + Headphones as OpicIcon, + Edit as WritingIcon, + People as PeopleIcon, + SmartToy as AiIcon, + ChevronRight as ChevronRightIcon, + MenuBook as VocabIcon, + School as LearnIcon, + Quiz as QuizIcon, + LibraryBooks as WordListIcon, +} from '@mui/icons-material' +import MainLayout from './layouts/MainLayout' +import FreetalkPeoplePage from './domains/freetalk/pages/FreetalkPeoplePage' +import ChatRoomPage from './domains/freetalk/pages/ChatRoomPage' +import ChatRoomModal from './domains/freetalk/components/ChatRoomModal' +import VocabDashboard from './domains/vocab/pages/VocabDashboard' +import DailyLearning from './domains/vocab/pages/DailyLearning' +import TestPage from './domains/vocab/pages/TestPage' +import WordListPage from './domains/vocab/pages/WordListPage' +import { useChat } from './contexts/ChatContext' +import { useSettings } from './contexts/SettingsContext' + +// 임시 대시보드 페이지 +function Dashboard() { + const navigate = useNavigate() + const [expandedCard, setExpandedCard] = useState(null) + + const learningModes = [ + { + id: 'speaking', + title: '말하기연습', + description: '오픽 연습과 AI 대화로 스피킹 실력 향상', + icon: SpeakingIcon, + color: '#2196f3', + children: [ + { id: 'opic', title: '오픽연습', icon: OpicIcon, path: '/opic', description: '레벨별 맞춤 연습' }, + { id: 'ai-talk', title: 'AI와 말해보기', icon: AiIcon, path: '/freetalk/ai', description: 'AI와 자유로운 대화' }, + ], + }, + { + id: 'writing', + title: '쓰기연습', + description: '채팅과 작문으로 라이팅 실력 향상', + icon: WritingCategoryIcon, + color: '#4caf50', + children: [ + { id: 'chat-people', title: '사람들과 채팅하기', icon: PeopleIcon, path: '/freetalk/people', description: '다른 학습자와 대화' }, + { id: 'writing-practice', title: '작문연습', icon: WritingIcon, path: '/writing', description: '문법 교정 & 피드백' }, + ], + }, + { + id: 'vocab', + title: '단어 학습', + description: '매일 55개 단어로 어휘력 향상', + icon: VocabIcon, + color: '#9c27b0', + 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: '전체 단어 목록' }, + ], + }, + ] + + const handleCardHover = (modeId) => { + setExpandedCard(modeId) + } + + const handleCardLeave = () => { + setExpandedCard(null) + } + + const handleSubItemClick = (path, e) => { + e.stopPropagation() + navigate(path) + } -function Home() { return ( - - - Welcome to FE Repository + + + 안녕하세요! + + + 오늘은 어떤 학습을 해볼까요? + + + + + {learningModes.map((mode) => { + const Icon = mode.icon + const isExpanded = expandedCard === mode.id + 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', + '&:hover': { + boxShadow: 6, + }, + }} + > + + + {/* 메인 아이콘 */} + + + + + {/* 텍스트 */} + + + + {mode.title} + + {hasChildren && ( + + )} + + + {mode.description} + + + + + {/* 하위 카테고리 - 애니메이션으로 펼쳐짐 */} + {hasChildren && ( + + + {mode.children.map((child, index) => { + const ChildIcon = child.icon + return ( + handleSubItemClick(child.path, e)} + sx={{ + flex: 1, + p: 2, + borderRadius: 2, + backgroundColor: 'action.hover', + cursor: 'pointer', + transition: 'all 0.3s', + transform: isExpanded ? 'translateX(0)' : 'translateX(-20px)', + opacity: isExpanded ? 1 : 0, + transitionDelay: `${index * 100}ms`, + '&:hover': { + backgroundColor: `${mode.color}20`, + transform: 'scale(1.02)', + }, + }} + > + + + + + {child.title} + + + {child.description} + + + + + ) + })} + + + )} + + + + ) + })} + + + {/* 최근 학습 */} + + + 최근 학습 + + + + + 아직 학습 기록이 없습니다. 학습을 시작해보세요! + + + + + + ) +} + +// 임시 페이지들 +function OpicPage() { + return ( + + OPIC 연습 + 레벨별 맞춤 연습 + + ) +} + +function FreetalkAiPage() { + return ( + + 프리토킹 - AI와 + AI와 자유로운 대화 + + ) +} + +function WritingPage() { + return ( + + 작문 연습 + 문법 교정 & 피드백 + + ) +} + +function ReportsPage() { + return ( + + 내 리포트 + 학습 결과 분석 + + ) +} + +function SettingsPage() { + const { settings, setTtsVoice } = useSettings() + + return ( + + + + 설정 - - React + Vite + MUI 프로젝트가 준비되었습니다. + + 앱 설정을 변경할 수 있습니다 + - - - - - - - - - Primary Color: #0124ac - - + + + + + TTS 음성 선택 + + + 채팅에서 메시지를 읽어줄 음성을 선택하세요 + + setTtsVoice(e.target.value)} + > + } + label="여성 음성" + /> + } + label="남성 음성" + /> + + + + + + ) +} + +function NotFound() { + return ( + + + + 404 + + + 페이지를 찾을 수 없습니다 + + ) } function App() { + const { activeRoom, closeChatRoom } = useChat() + + const handleRefreshRooms = () => { + // 채팅방 퇴장 후 목록 새로고침 (페이지에서 처리) + } + return ( - - } /> - + <> + + {/* 채팅방 페이지 (별도 레이아웃) */} + } /> + + {/* MainLayout 적용 라우트 */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + {/* 404 */} + } /> + + + {/* 전역 채팅 모달 */} + + ) } diff --git a/src/api/chatApi.js b/src/api/chatApi.js new file mode 100644 index 0000000..73ba2cc --- /dev/null +++ b/src/api/chatApi.js @@ -0,0 +1,30 @@ +import axios from 'axios' + +const chatApi = axios.create({ + baseURL: import.meta.env.VITE_CHAT_API_URL, + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, +}) + +// Request interceptor +chatApi.interceptors.request.use( + (config) => { + return config + }, + (error) => { + return Promise.reject(error) + } +) + +// Response interceptor +chatApi.interceptors.response.use( + (response) => response.data, + (error) => { + console.error('Chat API Error:', error.response?.data || error.message) + return Promise.reject(error) + } +) + +export default chatApi diff --git a/src/api/vocabApi.js b/src/api/vocabApi.js new file mode 100644 index 0000000..4de64ec --- /dev/null +++ b/src/api/vocabApi.js @@ -0,0 +1,30 @@ +import axios from 'axios' + +const vocabApi = axios.create({ + baseURL: import.meta.env.VITE_VOCAB_API_URL, + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, +}) + +// Request interceptor +vocabApi.interceptors.request.use( + (config) => { + return config + }, + (error) => { + return Promise.reject(error) + } +) + +// Response interceptor +vocabApi.interceptors.response.use( + (response) => response.data, + (error) => { + console.error('Vocab API Error:', error.response?.data || error.message) + return Promise.reject(error) + } +) + +export default vocabApi diff --git a/src/contexts/ChatContext.jsx b/src/contexts/ChatContext.jsx new file mode 100644 index 0000000..edf5be6 --- /dev/null +++ b/src/contexts/ChatContext.jsx @@ -0,0 +1,29 @@ +import { createContext, useContext, useState, useCallback } from 'react' + +const ChatContext = createContext(null) + +export const ChatProvider = ({ children }) => { + const [activeRoom, setActiveRoom] = useState(null) + + const openChatRoom = useCallback((room) => { + setActiveRoom(room) + }, []) + + const closeChatRoom = useCallback(() => { + setActiveRoom(null) + }, []) + + return ( + + {children} + + ) +} + +export const useChat = () => { + const context = useContext(ChatContext) + if (!context) { + throw new Error('useChat must be used within a ChatProvider') + } + return context +} diff --git a/src/contexts/SettingsContext.jsx b/src/contexts/SettingsContext.jsx new file mode 100644 index 0000000..c831958 --- /dev/null +++ b/src/contexts/SettingsContext.jsx @@ -0,0 +1,42 @@ +import { createContext, useContext, useState, useCallback, useEffect } from 'react' + +const SettingsContext = createContext(null) + +const STORAGE_KEY = 'app_settings' + +const defaultSettings = { + ttsVoice: 'FEMALE', // MALE | FEMALE +} + +export const SettingsProvider = ({ children }) => { + const [settings, setSettings] = useState(() => { + const stored = localStorage.getItem(STORAGE_KEY) + return stored ? { ...defaultSettings, ...JSON.parse(stored) } : defaultSettings + }) + + useEffect(() => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)) + }, [settings]) + + const updateSettings = useCallback((updates) => { + setSettings((prev) => ({ ...prev, ...updates })) + }, []) + + const setTtsVoice = useCallback((voice) => { + updateSettings({ ttsVoice: voice }) + }, [updateSettings]) + + return ( + + {children} + + ) +} + +export const useSettings = () => { + const context = useContext(SettingsContext) + if (!context) { + throw new Error('useSettings must be used within a SettingsProvider') + } + return context +} diff --git a/src/contexts/ThemeContext.jsx b/src/contexts/ThemeContext.jsx new file mode 100644 index 0000000..cad8b7b --- /dev/null +++ b/src/contexts/ThemeContext.jsx @@ -0,0 +1,79 @@ +import { createContext, useContext, useState, useEffect, useMemo } from 'react' +import { ThemeProvider as MuiThemeProvider } from '@mui/material/styles' +import { CssBaseline } from '@mui/material' +import { lightTheme, darkTheme } from '../theme/theme' + +const ThemeContext = createContext({ + mode: 'light', + toggleTheme: () => {}, + setMode: () => {}, +}) + +export const useThemeMode = () => { + const context = useContext(ThemeContext) + if (!context) { + throw new Error('useThemeMode must be used within ThemeProvider') + } + return context +} + +export const ThemeProvider = ({ children }) => { + // localStorage에서 테마 모드 불러오기 (시스템 설정 기본값) + const [mode, setMode] = useState(() => { + const savedMode = localStorage.getItem('themeMode') + if (savedMode) return savedMode + + // 시스템 다크모드 감지 + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + return 'dark' + } + return 'light' + }) + + // 시스템 테마 변경 감지 + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + const handleChange = (e) => { + const savedMode = localStorage.getItem('themeMode') + if (!savedMode) { + setMode(e.matches ? 'dark' : 'light') + } + } + + mediaQuery.addEventListener('change', handleChange) + return () => mediaQuery.removeEventListener('change', handleChange) + }, []) + + // 테마 변경 시 localStorage 저장 + useEffect(() => { + localStorage.setItem('themeMode', mode) + // body 클래스 추가 (CSS 변수 등에서 활용) + document.body.classList.remove('light-mode', 'dark-mode') + document.body.classList.add(`${mode}-mode`) + }, [mode]) + + const toggleTheme = () => { + setMode((prev) => (prev === 'light' ? 'dark' : 'light')) + } + + const theme = useMemo(() => { + return mode === 'dark' ? darkTheme : lightTheme + }, [mode]) + + const contextValue = useMemo(() => ({ + mode, + toggleTheme, + setMode, + }), [mode]) + + return ( + + + + {children} + + + ) +} + +export default ThemeContext diff --git a/src/domains/chat/services/chatService.js b/src/domains/chat/services/chatService.js new file mode 100644 index 0000000..cdf9372 --- /dev/null +++ b/src/domains/chat/services/chatService.js @@ -0,0 +1,83 @@ +import chatApi from '../../../api/chatApi' + +const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1' + +// 채팅방 API +export const chatRoomService = { + // 채팅방 생성 + create: async (data) => { + return chatApi.post('/chat/rooms', { + ...data, + createdBy: TEMP_USER_ID, + }) + }, + + // 채팅방 목록 조회 + 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()}`) + }, + + // 채팅방 상세 조회 + getDetail: async (roomId) => { + return chatApi.get(`/chat/rooms/${roomId}`) + }, + + // 채팅방 입장 + join: async (roomId, password) => { + return chatApi.post(`/chat/rooms/${roomId}/join`, { + userId: TEMP_USER_ID, + ...(password && { password }), + }) + }, + + // 채팅방 퇴장 + leave: async (roomId) => { + return chatApi.post(`/chat/rooms/${roomId}/leave`, { + userId: TEMP_USER_ID, + }) + }, +} + +// 메시지 API +export const messageService = { + // 메시지 전송 + send: async (roomId, content, messageType = 'TEXT') => { + return chatApi.post(`/chat/rooms/${roomId}/messages`, { + userId: TEMP_USER_ID, + content, + messageType, + }) + }, + + // 메시지 목록 조회 + 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()}`) + }, +} + +// 음성 API +export const voiceService = { + // TTS 변환 + synthesize: async (messageId, roomId, voice = 'FEMALE') => { + return chatApi.post('/chat/voice/synthesize', { messageId, roomId, voice }) + }, +} + +export { TEMP_USER_ID } diff --git a/src/domains/freetalk/components/ChatRoomCard.jsx b/src/domains/freetalk/components/ChatRoomCard.jsx new file mode 100644 index 0000000..f7e1fc9 --- /dev/null +++ b/src/domains/freetalk/components/ChatRoomCard.jsx @@ -0,0 +1,138 @@ +import { Card, CardContent, Box, Typography, Chip, Button } from '@mui/material' +import { AccessTime as TimeIcon, People as PeopleIcon, Lock as LockIcon } from '@mui/icons-material' + +const levelColors = { + beginner: { bg: '#e8f5e9', color: '#2e7d32', label: '초급' }, + intermediate: { bg: '#fff3e0', color: '#ef6c00', label: '중급' }, + advanced: { bg: '#fce4ec', color: '#c2185b', label: '고급' }, +} + +const formatTimeAgo = (date) => { + const now = new Date() + const diff = Math.floor((now - new Date(date)) / 1000) + + if (diff < 60) return '방금 전' + if (diff < 3600) return `${Math.floor(diff / 60)}분 전` + if (diff < 86400) return `${Math.floor(diff / 3600)}시간 전` + return `${Math.floor(diff / 86400)}일 전` +} + +const formatDate = (date) => { + const d = new Date(date) + const year = d.getFullYear().toString().slice(2) + const month = (d.getMonth() + 1).toString().padStart(2, '0') + const day = d.getDate().toString().padStart(2, '0') + return `${year}/${month}/${day}` +} + +const ChatRoomCard = ({ room, onClick }) => { + const level = levelColors[room.level] || levelColors.beginner + + const handleEnterClick = (e) => { + e.stopPropagation() + onClick?.(room) + } + + return ( + + + {/* 상단: 레벨 뱃지 + 방 이름 + 입장 버튼 */} + + + + + {room.isPrivate && ( + + )} + + {room.name} + + + + + + + {/* 중단: 소개 */} + {room.description && ( + + {room.description} + + )} + + {/* 하단: 인원, 마지막 대화, 생성일 */} + + + + + + {room.currentMembers} + + /{room.maxMembers} + + + + + + + {formatTimeAgo(room.lastMessageAt)} + + + + + · 생성: {formatDate(room.createdAt)} + + + + + ) +} + +export default ChatRoomCard diff --git a/src/domains/freetalk/components/ChatRoomModal.jsx b/src/domains/freetalk/components/ChatRoomModal.jsx new file mode 100644 index 0000000..ba971a9 --- /dev/null +++ b/src/domains/freetalk/components/ChatRoomModal.jsx @@ -0,0 +1,492 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { + Box, + Typography, + TextField, + IconButton, + CircularProgress, + Alert, + Chip, + Avatar, + Paper, + Fade, +} from '@mui/material' +import { + Close as CloseIcon, + Send as SendIcon, + VolumeUp as VolumeUpIcon, + Refresh as RefreshIcon, + ExitToApp as ExitToAppIcon, + Minimize as MinimizeIcon, + OpenInFull as MaximizeIcon, +} from '@mui/icons-material' +import { chatRoomService, messageService, voiceService, TEMP_USER_ID } 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: '고급' }, +} + +const ChatRoomModal = ({ open, onClose, room, onLeave }) => { + const { settings } = useSettings() + const messagesEndRef = useRef(null) + const dragRef = useRef(null) + + const [messages, setMessages] = useState([]) + const [newMessage, setNewMessage] = useState('') + const [loading, setLoading] = useState(true) + const [sendingMessage, setSendingMessage] = useState(false) + const [error, setError] = useState(null) + const [playingTTS, setPlayingTTS] = useState(null) + const [minimized, setMinimized] = useState(false) + const [position, setPosition] = useState({ x: 0, y: 0 }) + const [savedPosition, setSavedPosition] = useState({ x: 0, y: 0 }) + const [isDragging, setIsDragging] = useState(false) + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }) + // 메시지 목록 조회 + const fetchMessages = useCallback(async () => { + if (!room?.id) return + + try { + const response = await messageService.getList(room.id, { limit: 50 }) + const responseData = response.data || response + const transformedMessages = (responseData.messages || []).map((msg) => ({ + id: msg.messageId || msg.pk?.replace('MESSAGE#', ''), + content: msg.content, + userId: msg.userId, + messageType: msg.messageType, + createdAt: new Date(msg.createdAt), + isOwn: msg.userId === TEMP_USER_ID, + })) + setMessages(transformedMessages.reverse()) + } catch (err) { + console.error('Failed to fetch messages:', err) + setError('메시지를 불러오는데 실패했습니다') + } + }, [room?.id]) + + // 초기 로드 + useEffect(() => { + if (open && room?.id) { + setLoading(true) + setMessages([]) + setMinimized(false) + fetchMessages().finally(() => setLoading(false)) + } + }, [open, room?.id, fetchMessages]) + + // 스크롤 맨 아래로 + const scrollToBottom = (instant = false) => { + messagesEndRef.current?.scrollIntoView({ behavior: instant ? 'instant' : 'smooth' }) + } + + // 메시지 로드 완료 후 스크롤 + useEffect(() => { + if (!loading && messages.length > 0) { + // 처음 로드 시 즉시 스크롤 + setTimeout(() => scrollToBottom(true), 100) + } + }, [loading]) + + // 새 메시지 추가 시 부드럽게 스크롤 + useEffect(() => { + if (messages.length > 0 && !loading) { + scrollToBottom(false) + } + }, [messages.length]) + + // 드래그 핸들러 + const handleMouseDown = (e) => { + // 버튼, 입력창, 슬라이더, 팝오버 클릭 시 드래그 방지 + if ( + e.target.closest('button') || + e.target.closest('input') || + e.target.closest('.MuiSlider-root') || + e.target.closest('.MuiPopover-root') + ) return + setIsDragging(true) + const rect = dragRef.current?.getBoundingClientRect() + setDragOffset({ + x: e.clientX - (rect?.left || 0), + y: e.clientY - (rect?.top || 0), + }) + } + + useEffect(() => { + const handleMouseMove = (e) => { + if (!isDragging) return + setPosition({ + x: e.clientX - dragOffset.x, + y: e.clientY - dragOffset.y, + }) + } + + const handleMouseUp = () => { + setIsDragging(false) + } + + if (isDragging) { + window.addEventListener('mousemove', handleMouseMove) + window.addEventListener('mouseup', handleMouseUp) + } + + return () => { + window.removeEventListener('mousemove', handleMouseMove) + window.removeEventListener('mouseup', handleMouseUp) + } + }, [isDragging, dragOffset]) + + // 메시지 전송 + const handleSendMessage = async () => { + if (!newMessage.trim() || sendingMessage) return + + setSendingMessage(true) + const messageContent = newMessage.trim() + setNewMessage('') + + const tempMessage = { + id: `temp-${Date.now()}`, + content: messageContent, + userId: TEMP_USER_ID, + messageType: 'TEXT', + createdAt: new Date(), + isOwn: true, + } + setMessages((prev) => [...prev, tempMessage]) + + try { + await messageService.send(room.id, messageContent) + await fetchMessages() + } catch (err) { + console.error('Failed to send message:', err) + setMessages((prev) => prev.filter((m) => m.id !== tempMessage.id)) + setError('메시지 전송에 실패했습니다') + } finally { + setSendingMessage(false) + } + } + + const handleKeyPress = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSendMessage() + } + } + + // TTS 재생 (모든 메시지에서 가능) + const handlePlayTTS = async (messageId) => { + if (playingTTS === messageId) return + + setPlayingTTS(messageId) + try { + const response = await voiceService.synthesize(messageId, room.id, settings.ttsVoice) + const responseData = response.data || response + if (responseData.audioUrl) { + const audio = new Audio(responseData.audioUrl) + audio.onended = () => setPlayingTTS(null) + audio.onerror = () => setPlayingTTS(null) + await audio.play() + } + } catch (err) { + console.error('Failed to play TTS:', err) + setPlayingTTS(null) + } + } + + // 최소화/최대화 토글 + const handleToggleMinimize = () => { + if (!minimized) { + // 최소화: 현재 위치 저장 후 우측 하단으로 이동 + setSavedPosition(position) + setPosition({ x: 0, y: 0 }) + } else { + // 최대화: 저장된 위치로 복원 후 스크롤 맨 아래로 + setPosition(savedPosition) + setTimeout(() => scrollToBottom(true), 100) + } + setMinimized(!minimized) + } + + // 채팅방 퇴장 + const handleLeaveRoom = async () => { + try { + await chatRoomService.leave(room.id) + onLeave?.() + onClose() + } catch (err) { + console.error('Failed to leave room:', err) + setError('채팅방 퇴장에 실패했습니다') + } + } + + const formatTime = (date) => { + return new Intl.DateTimeFormat('ko-KR', { + hour: '2-digit', + minute: '2-digit', + }).format(date) + } + + if (!open) return null + + return ( + + + {/* 헤더 - 드래그 가능 */} + + + + {room?.name || '채팅방'} + + {room?.level && ( + + )} + + + + + + + {minimized ? : } + + + + + + + + + + + {!minimized && ( + <> + {/* 에러 메시지 */} + {error && ( + setError(null)} sx={{ borderRadius: 0 }}> + {error} + + )} + + {/* 메시지 영역 */} + {loading ? ( + + + + ) : ( + + {messages.length === 0 ? ( + + + 첫 메시지를 보내보세요! + + + ) : ( + messages.map((message) => ( + + {!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)} + + + )} + + + + {message.content} + + + + {!message.isOwn && ( + + handlePlayTTS(message.id)} + disabled={playingTTS === message.id} + sx={{ p: 0.25 }} + > + {playingTTS === message.id ? ( + + ) : ( + + )} + + + {formatTime(message.createdAt)} + + + )} + + + + )) + )} +
+ + )} + + {/* 입력 영역 */} + + setNewMessage(e.target.value)} + onKeyPress={handleKeyPress} + size="small" + multiline + maxRows={2} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: 2, + fontSize: '0.85rem', + }, + '& .MuiOutlinedInput-input': { + py: 0.75, + }, + }} + /> + + {sendingMessage ? : } + + + + )} + + + ) +} + +export default ChatRoomModal diff --git a/src/domains/freetalk/components/CreateRoomModal.jsx b/src/domains/freetalk/components/CreateRoomModal.jsx new file mode 100644 index 0000000..c08ce79 --- /dev/null +++ b/src/domains/freetalk/components/CreateRoomModal.jsx @@ -0,0 +1,188 @@ +import { useState } from 'react' +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, + FormControl, + InputLabel, + Select, + MenuItem, + FormControlLabel, + Switch, + Box, + CircularProgress, + Alert, +} from '@mui/material' +import { chatRoomService } from '../../chat/services/chatService' + +const CreateRoomModal = ({ open, onClose, onSuccess }) => { + const [formData, setFormData] = useState({ + name: '', + description: '', + level: 'BEGINNER', + maxParticipants: 6, + isPrivate: false, + password: '', + }) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const handleChange = (field) => (e) => { + const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value + setFormData((prev) => ({ + ...prev, + [field]: value, + })) + setError(null) + } + + const handleSubmit = async () => { + if (!formData.name.trim()) { + setError('채팅방 이름을 입력해주세요') + return + } + + if (formData.isPrivate && !formData.password) { + setError('비밀방은 비밀번호가 필요합니다') + return + } + + setLoading(true) + setError(null) + + try { + const payload = { + name: formData.name.trim(), + description: formData.description.trim() || undefined, + level: formData.level, + maxParticipants: formData.maxParticipants, + isPrivate: formData.isPrivate, + ...(formData.isPrivate && { password: formData.password }), + } + + await chatRoomService.create(payload) + handleClose() + if (onSuccess) onSuccess() + } catch (err) { + console.error('Failed to create room:', err) + setError('채팅방 생성에 실패했습니다. 다시 시도해주세요.') + } finally { + setLoading(false) + } + } + + const handleClose = () => { + setFormData({ + name: '', + description: '', + level: 'BEGINNER', + maxParticipants: 6, + isPrivate: false, + password: '', + }) + setError(null) + onClose() + } + + return ( + + 새 채팅방 만들기 + + + {error && ( + setError(null)}> + {error} + + )} + + + + + + + 레벨 + + + + + 최대 인원 + + + + + } + label="비밀방으로 설정" + /> + + {formData.isPrivate && ( + + )} + + + + + + + + ) +} + +export default CreateRoomModal diff --git a/src/domains/freetalk/pages/ChatRoomPage.jsx b/src/domains/freetalk/pages/ChatRoomPage.jsx new file mode 100644 index 0000000..ce4257e --- /dev/null +++ b/src/domains/freetalk/pages/ChatRoomPage.jsx @@ -0,0 +1,386 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { + Box, + Container, + Paper, + Typography, + TextField, + IconButton, + CircularProgress, + Alert, + AppBar, + Toolbar, + Chip, + Avatar, +} from '@mui/material' +import { + ArrowBack as ArrowBackIcon, + Send as SendIcon, + VolumeUp as VolumeUpIcon, + Refresh as RefreshIcon, + ExitToApp as ExitToAppIcon, +} from '@mui/icons-material' +import { chatRoomService, messageService, voiceService, TEMP_USER_ID } from '../../chat/services/chatService' + +const levelColors = { + beginner: { bg: '#e8f5e9', color: '#2e7d32', label: '초급' }, + intermediate: { bg: '#fff3e0', color: '#ef6c00', label: '중급' }, + advanced: { bg: '#fce4ec', color: '#c2185b', label: '고급' }, +} + +const ChatRoomPage = () => { + const { roomId } = useParams() + const navigate = useNavigate() + const messagesEndRef = useRef(null) + const messagesContainerRef = useRef(null) + + const [room, setRoom] = useState(null) + const [messages, setMessages] = useState([]) + const [newMessage, setNewMessage] = useState('') + const [loading, setLoading] = useState(true) + const [sendingMessage, setSendingMessage] = useState(false) + const [error, setError] = useState(null) + const [playingTTS, setPlayingTTS] = useState(null) + + // 채팅방 정보 조회 + const fetchRoomDetail = useCallback(async () => { + try { + const response = await chatRoomService.getDetail(roomId) + setRoom({ + id: response.roomId, + name: response.name, + description: response.description, + level: response.level?.toLowerCase() || 'beginner', + currentMembers: response.currentParticipants || 0, + maxMembers: response.maxParticipants || 6, + }) + } catch (err) { + console.error('Failed to fetch room detail:', err) + setError('채팅방 정보를 불러오는데 실패했습니다') + } + }, [roomId]) + + // 메시지 목록 조회 + const fetchMessages = useCallback(async () => { + try { + const response = await messageService.getList(roomId, { limit: 50 }) + const transformedMessages = (response.messages || []).map((msg) => ({ + id: msg.messageId, + content: msg.content, + userId: msg.userId, + messageType: msg.messageType, + createdAt: new Date(msg.createdAt), + isOwn: msg.userId === TEMP_USER_ID, + })) + // 오래된 메시지가 위에 오도록 정렬 + setMessages(transformedMessages.reverse()) + } catch (err) { + console.error('Failed to fetch messages:', err) + setError('메시지를 불러오는데 실패했습니다') + } + }, [roomId]) + + // 초기 로드 + useEffect(() => { + const loadData = async () => { + setLoading(true) + await Promise.all([fetchRoomDetail(), fetchMessages()]) + setLoading(false) + } + loadData() + }, [fetchRoomDetail, fetchMessages]) + + // 스크롤 맨 아래로 + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + } + + useEffect(() => { + scrollToBottom() + }, [messages]) + + // 메시지 전송 + const handleSendMessage = async () => { + if (!newMessage.trim() || sendingMessage) return + + setSendingMessage(true) + const messageContent = newMessage.trim() + setNewMessage('') + + // Optimistic update + const tempMessage = { + id: `temp-${Date.now()}`, + content: messageContent, + userId: TEMP_USER_ID, + messageType: 'TEXT', + createdAt: new Date(), + isOwn: true, + } + setMessages((prev) => [...prev, tempMessage]) + + try { + await messageService.send(roomId, messageContent) + // 전송 성공 시 메시지 새로고침 + await fetchMessages() + } catch (err) { + console.error('Failed to send message:', err) + // 실패 시 임시 메시지 제거 + setMessages((prev) => prev.filter((m) => m.id !== tempMessage.id)) + setError('메시지 전송에 실패했습니다') + } finally { + setSendingMessage(false) + } + } + + // 엔터 키 전송 + const handleKeyPress = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSendMessage() + } + } + + // TTS 재생 + const handlePlayTTS = async (messageId) => { + if (playingTTS === messageId) return + + setPlayingTTS(messageId) + try { + const response = await voiceService.synthesize(messageId, roomId) + const responseData = response.data || response + if (responseData.audioUrl) { + const audio = new Audio(responseData.audioUrl) + audio.onended = () => setPlayingTTS(null) + audio.onerror = () => setPlayingTTS(null) + await audio.play() + } + } catch (err) { + console.error('Failed to play TTS:', err) + setPlayingTTS(null) + } + } + + // 채팅방 퇴장 + const handleLeaveRoom = async () => { + try { + await chatRoomService.leave(roomId) + navigate('/freetalk/people') + } catch (err) { + console.error('Failed to leave room:', err) + setError('채팅방 퇴장에 실패했습니다') + } + } + + // 새로고침 + const handleRefresh = () => { + fetchMessages() + } + + // 시간 포맷 + const formatTime = (date) => { + return new Intl.DateTimeFormat('ko-KR', { + hour: '2-digit', + minute: '2-digit', + }).format(date) + } + + if (loading) { + return ( + + + + ) + } + + return ( + + {/* 헤더 */} + + + navigate('/freetalk/people')} sx={{ mr: 1 }}> + + + + + {room?.name || '채팅방'} + + + {room?.level && ( + + )} + + {room?.currentMembers}/{room?.maxMembers}명 + + + + + + + + + + + + + {/* 에러 메시지 */} + {error && ( + setError(null)} sx={{ m: 1 }}> + {error} + + )} + + {/* 메시지 영역 */} + + {messages.length === 0 ? ( + + + 아직 메시지가 없습니다. 첫 메시지를 보내보세요! + + + ) : ( + messages.map((message) => ( + + {/* 아바타 (상대방만) */} + {!message.isOwn && ( + + {message.userId?.charAt(0)?.toUpperCase() || 'U'} + + )} + + + {/* 사용자 이름 (상대방만) */} + {!message.isOwn && ( + + {message.userId} + + )} + + {/* 메시지 버블 */} + + {message.isOwn && ( + + {formatTime(message.createdAt)} + + )} + + + + {message.content} + + + + {!message.isOwn && ( + + handlePlayTTS(message.id)} + disabled={playingTTS === message.id} + sx={{ p: 0.5 }} + > + {playingTTS === message.id ? ( + + ) : ( + + )} + + + {formatTime(message.createdAt)} + + + )} + + + + )) + )} +
+ + + {/* 입력 영역 */} + + setNewMessage(e.target.value)} + onKeyPress={handleKeyPress} + size="small" + multiline + maxRows={3} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: 3, + }, + }} + /> + + {sendingMessage ? : } + + + + ) +} + +export default ChatRoomPage diff --git a/src/domains/freetalk/pages/FreetalkPeoplePage.jsx b/src/domains/freetalk/pages/FreetalkPeoplePage.jsx new file mode 100644 index 0000000..8b7c30c --- /dev/null +++ b/src/domains/freetalk/pages/FreetalkPeoplePage.jsx @@ -0,0 +1,444 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { + Container, + Box, + Typography, + Grid, + TextField, + InputAdornment, + ToggleButtonGroup, + ToggleButton, + Button, + Fab, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Chip, + CircularProgress, + Alert, +} from '@mui/material' +import { + Search as SearchIcon, + Add as AddIcon, + Lock as LockIcon, + People as PeopleIcon, + Refresh as RefreshIcon, +} from '@mui/icons-material' +import ChatRoomCard from '../components/ChatRoomCard' +import CreateRoomModal from '../components/CreateRoomModal' +import { chatRoomService, TEMP_USER_ID } from '../../chat/services/chatService' +import { useChat } from '../../../contexts/ChatContext' + +const levelColors = { + beginner: { bg: '#e8f5e9', color: '#2e7d32', label: '초급' }, + intermediate: { bg: '#fff3e0', color: '#ef6c00', label: '중급' }, + advanced: { bg: '#fce4ec', color: '#c2185b', label: '고급' }, +} + +const FreetalkPeoplePage = () => { + const { openChatRoom } = useChat() + const [searchQuery, setSearchQuery] = useState('') + const [levelFilter, setLevelFilter] = useState('all') + const [selectedRoom, setSelectedRoom] = useState(null) + const [modalOpen, setModalOpen] = useState(false) + const [password, setPassword] = useState('') + const [passwordError, setPasswordError] = useState('') + + // API 관련 state + const [rooms, setRooms] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [cursor, setCursor] = useState(null) + const [hasMore, setHasMore] = useState(true) + const [joining, setJoining] = useState(false) + const [createModalOpen, setCreateModalOpen] = useState(false) + const observerRef = useRef(null) + + // API에서 채팅방 데이터를 프론트엔드 형식으로 변환 + const transformRoomData = (apiRoom) => { + // pk가 "ROOM#uuid" 형식이므로 ID 추출 + const roomId = apiRoom.pk?.replace('ROOM#', '') || apiRoom.roomId + // 참여자 수 필드명 여러 가지 체크 + const currentMembers = apiRoom.currentParticipants || apiRoom.participantCount || apiRoom.memberCount || apiRoom.currentMembers || 0 + const maxMembers = apiRoom.maxParticipants || apiRoom.maxMembers || 6 + return { + id: roomId, + name: apiRoom.name || '채팅방', + description: apiRoom.description || '', + level: (apiRoom.level || 'beginner').toLowerCase(), + currentMembers, + maxMembers, + lastMessageAt: apiRoom.lastMessageAt ? new Date(apiRoom.lastMessageAt) : null, + createdAt: apiRoom.createdAt ? new Date(apiRoom.createdAt) : new Date(), + isPrivate: apiRoom.isPrivate || false, + isJoined: apiRoom.isJoined || false, + } + } + + // 채팅방 목록 조회 + const fetchRooms = useCallback(async (isLoadMore = false) => { + if (loading) return + + setLoading(true) + setError(null) + + try { + const params = { + limit: 10, + ...(levelFilter !== 'all' && levelFilter !== 'joined' && { level: levelFilter.toUpperCase() }), + ...(levelFilter === 'joined' && { joined: true }), + ...(isLoadMore && cursor && { cursor }), + } + + const response = await chatRoomService.getList(params) + // API 응답: { success, message, data: { rooms, hasMore, nextCursor } } + const responseData = response.data || response + const transformedRooms = (responseData.rooms || []).map(transformRoomData) + + if (isLoadMore) { + setRooms((prev) => [...prev, ...transformedRooms]) + } else { + setRooms(transformedRooms) + } + + setCursor(responseData.nextCursor || null) + setHasMore(!!responseData.nextCursor) + } catch (err) { + console.error('Failed to fetch rooms:', err) + setError('채팅방 목록을 불러오는데 실패했습니다') + } finally { + setLoading(false) + } + }, [levelFilter, cursor, loading]) + + // 초기 로드 및 필터 변경 시 재조회 + useEffect(() => { + const loadRooms = async () => { + setLoading(true) + setError(null) + setCursor(null) + setHasMore(true) + setRooms([]) + + try { + const params = { + limit: 10, + ...(levelFilter !== 'all' && levelFilter !== 'joined' && { level: levelFilter.toUpperCase() }), + ...(levelFilter === 'joined' && { joined: true }), + } + + const response = await chatRoomService.getList(params) + const responseData = response.data || response + const transformedRooms = (responseData.rooms || []).map(transformRoomData) + setRooms(transformedRooms) + setCursor(responseData.nextCursor || null) + setHasMore(!!responseData.nextCursor) + } catch (err) { + console.error('Failed to fetch rooms:', err) + setError('채팅방 목록을 불러오는데 실패했습니다') + } finally { + setLoading(false) + } + } + + loadRooms() + }, [levelFilter]) + + // 무한 스크롤 + const lastRoomRef = useCallback((node) => { + if (loading) return + if (observerRef.current) observerRef.current.disconnect() + + observerRef.current = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && hasMore && !loading) { + fetchRooms(true) + } + }) + + if (node) observerRef.current.observe(node) + }, [loading, hasMore, fetchRooms]) + + // 새로고침 + const handleRefresh = async () => { + setLoading(true) + setError(null) + setCursor(null) + setHasMore(true) + setRooms([]) + + try { + const params = { + limit: 10, + ...(levelFilter !== 'all' && levelFilter !== 'joined' && { level: levelFilter.toUpperCase() }), + ...(levelFilter === 'joined' && { joined: true }), + } + + const response = await chatRoomService.getList(params) + const responseData = response.data || response + const transformedRooms = (responseData.rooms || []).map(transformRoomData) + setRooms(transformedRooms) + setCursor(responseData.nextCursor || null) + setHasMore(!!responseData.nextCursor) + } catch (err) { + console.error('Failed to fetch rooms:', err) + setError('채팅방 목록을 불러오는데 실패했습니다') + } finally { + setLoading(false) + } + } + + const handleLevelChange = (event, newLevel) => { + if (newLevel !== null) { + setLevelFilter(newLevel) + } + } + + const handleRoomClick = (room) => { + setSelectedRoom(room) + setPassword('') + setPasswordError('') + setModalOpen(true) + } + + const handleCloseModal = () => { + setModalOpen(false) + setSelectedRoom(null) + setPassword('') + setPasswordError('') + } + + const handleEnterRoom = async () => { + if (!selectedRoom) return + + setJoining(true) + setPasswordError('') + + try { + await chatRoomService.join(selectedRoom.id, selectedRoom.isPrivate ? password : undefined) + // 전역 채팅 모달 열기 + openChatRoom(selectedRoom) + handleCloseModal() + } catch (err) { + console.error('Failed to join room:', err) + if (err.response?.status === 401 || err.response?.data?.message?.includes('password')) { + setPasswordError('비밀번호가 일치하지 않습니다') + } else if (err.response?.status === 409) { + // 이미 참여중인 경우 바로 입장 + openChatRoom(selectedRoom) + handleCloseModal() + } else { + setPasswordError('입장에 실패했습니다. 다시 시도해주세요.') + } + } finally { + setJoining(false) + } + } + + // 클라이언트 사이드 검색 필터 + const filteredRooms = rooms.filter((room) => { + const matchesSearch = room.name.toLowerCase().includes(searchQuery.toLowerCase()) + return matchesSearch + }) + + return ( + + {/* 헤더 */} + + + 사람들과 프리토킹 + + + 다른 학습자들과 함께 영어로 자유롭게 대화해보세요 + + + + {/* 필터 영역 */} + + {/* 검색 */} + setSearchQuery(e.target.value)} + sx={{ flex: 1, maxWidth: { sm: 300 } }} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + {/* 레벨 필터 */} + + 전체 + 초급 + 중급 + 고급 + 참여중 + + + {/* 새로고침 버튼 */} + + + + {/* 에러 메시지 */} + {error && ( + setError(null)}> + {error} + + )} + + {/* 채팅방 목록 */} + + {filteredRooms.map((room, index) => ( + + + + ))} + + + {/* 로딩 인디케이터 */} + {loading && ( + + + + )} + + {/* 빈 상태 */} + {!loading && filteredRooms.length === 0 && ( + + + {error ? '데이터를 불러올 수 없습니다' : '채팅방이 없습니다'} + + + {error ? '새로고침 버튼을 눌러 다시 시도해주세요' : '새 채팅방을 만들어보세요'} + + + )} + + {/* 채팅방 만들기 FAB */} + setCreateModalOpen(true)} + > + + + + {/* 채팅방 생성 모달 */} + setCreateModalOpen(false)} + onSuccess={handleRefresh} + /> + + {/* 입장 모달 */} + + {selectedRoom && ( + <> + + + {selectedRoom.isPrivate && ( + + )} + + {selectedRoom.name} + + + + + {/* 방 정보 */} + + + + + + + {selectedRoom.currentMembers}/{selectedRoom.maxMembers}명 + + + + {selectedRoom.description && ( + + {selectedRoom.description} + + )} + + + {/* 비밀번호 입력 (비밀방인 경우) */} + {selectedRoom.isPrivate && ( + { + setPassword(e.target.value) + setPasswordError('') + }} + error={!!passwordError} + helperText={passwordError} + size="small" + autoFocus + /> + )} + + + + + + + )} + + + ) +} + +export default FreetalkPeoplePage diff --git a/src/domains/vocab/components/FlashCard.jsx b/src/domains/vocab/components/FlashCard.jsx new file mode 100644 index 0000000..37e9f72 --- /dev/null +++ b/src/domains/vocab/components/FlashCard.jsx @@ -0,0 +1,137 @@ +import { Box, Typography, IconButton, Chip } from '@mui/material' +import { VolumeUp as VolumeIcon } 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 + + return ( + + + {/* 앞면 - 영어 */} + + + {word.english} + + + { + e.stopPropagation() + onPlayTTS?.() + }} + disabled={isPlayingTTS} + sx={{ mb: 2 }} + > + + + + {word.example && ( + + "{word.example}" + + )} + + + 탭하여 뜻 보기 + + + + {/* 뒷면 - 한국어 */} + + + {word.korean} + + + + + + + + + 탭하여 영어 보기 + + + + + ) +} diff --git a/src/domains/vocab/components/TestQuestion.jsx b/src/domains/vocab/components/TestQuestion.jsx new file mode 100644 index 0000000..880d42f --- /dev/null +++ b/src/domains/vocab/components/TestQuestion.jsx @@ -0,0 +1,124 @@ +import { Box, Typography, Paper, Radio, RadioGroup, FormControlLabel } from '@mui/material' + +export default function TestQuestion({ + question, + selectedAnswer, + onSelect, + showResult = false, + disabled = false, +}) { + if (!question) return null + + const getOptionStyle = (option) => { + if (!showResult) { + return { + border: selectedAnswer === option ? '2px solid' : '1px solid', + borderColor: selectedAnswer === option ? 'primary.main' : 'divider', + backgroundColor: selectedAnswer === option ? 'primary.50' : 'background.paper', + } + } + + // 결과 표시 모드 + const isCorrect = option === question.correctAnswer + const isSelected = option === selectedAnswer + + if (isCorrect) { + return { + border: '2px solid', + borderColor: 'success.main', + backgroundColor: 'success.50', + } + } + if (isSelected && !isCorrect) { + return { + border: '2px solid', + borderColor: 'error.main', + backgroundColor: 'error.50', + } + } + return { + border: '1px solid', + borderColor: 'divider', + backgroundColor: 'background.paper', + opacity: 0.6, + } + } + + return ( + + {/* 문제 */} + + + {question.question} + + + {question.type === 'KOREAN_TO_ENGLISH' + ? '다음 중 올바른 영어 단어는?' + : '다음 중 올바른 한국어 뜻은?'} + + + + {/* 선택지 */} + !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)', + }, + }} + > + + } + label={ + + {`${['①', '②', '③', '④'][index]} ${option}`} + + } + sx={{ m: 0, width: '100%' }} + /> + + ))} + + + + ) +} diff --git a/src/domains/vocab/components/WordDetailModal.jsx b/src/domains/vocab/components/WordDetailModal.jsx new file mode 100644 index 0000000..c4927bc --- /dev/null +++ b/src/domains/vocab/components/WordDetailModal.jsx @@ -0,0 +1,227 @@ +import { useState } from 'react' +import { + Dialog, + DialogTitle, + DialogContent, + Box, + Typography, + IconButton, + Chip, + Button, + ToggleButton, + ToggleButtonGroup, + Divider, +} from '@mui/material' +import { + Close as CloseIcon, + VolumeUp as VolumeIcon, + Star as StarIcon, + StarBorder as StarBorderIcon, + Favorite as FavoriteIcon, + FavoriteBorder as FavoriteBorderIcon, +} from '@mui/icons-material' +import { + LEVEL_LABELS, + LEVEL_COLORS, + CATEGORY_LABELS, + WORD_STATUS_LABELS, + WORD_STATUS_COLORS, + DIFFICULTY, + DIFFICULTY_LABELS, + VOICE_TYPES, +} from '../constants/vocabConstants' + +export default function WordDetailModal({ + open, + onClose, + word, + userWord, + onPlayTTS, + onToggleBookmark, + onToggleFavorite, + onSetDifficulty, + isPlayingTTS, +}) { + const [selectedVoice, setSelectedVoice] = useState(VOICE_TYPES.FEMALE) + + if (!word) return null + + const handlePlayTTS = () => { + onPlayTTS?.(selectedVoice) + } + + return ( + + + + + {word.english} + + + + + + + + + {/* TTS */} + + + 발음 듣기 + + + val && setSelectedVoice(val)} + size="small" + > + 여성 + 남성 + + + + + + {/* 뜻 */} + + + {word.korean} + + + + + + + + {/* 예문 */} + {word.example && ( + + + 예문 + + + "{word.example}" + + + )} + + + + {/* 학습 현황 */} + {userWord && ( + + + 학습 현황 + + + + 정답 + + {userWord.correctCount || 0}회 + + + + 오답 + + {userWord.incorrectCount || 0}회 + + + + 정확도 + + {userWord.correctCount + userWord.incorrectCount > 0 + ? ( + (userWord.correctCount / + (userWord.correctCount + userWord.incorrectCount)) * + 100 + ).toFixed(1) + : 0} + % + + + + 상태 + + + {userWord.nextReviewAt && ( + + 다음 복습 + + {new Date(userWord.nextReviewAt).toLocaleDateString()} + + + )} + + + )} + + {/* 액션 */} + + + + {userWord?.bookmarked ? ( + + ) : ( + + )} + + + {userWord?.favorite ? ( + + ) : ( + + )} + + + + + + 난이도 + + val && onSetDifficulty?.(val)} + size="small" + > + {Object.entries(DIFFICULTY_LABELS).map(([key, label]) => ( + + {label} + + ))} + + + + + + ) +} diff --git a/src/domains/vocab/components/WordListItem.jsx b/src/domains/vocab/components/WordListItem.jsx new file mode 100644 index 0000000..83be7e7 --- /dev/null +++ b/src/domains/vocab/components/WordListItem.jsx @@ -0,0 +1,118 @@ +import { + Box, + Typography, + IconButton, + Chip, + ListItem, + ListItemText, + Tooltip, +} from '@mui/material' +import { + VolumeUp as VolumeIcon, + Star as StarIcon, + StarBorder as StarBorderIcon, +} from '@mui/icons-material' +import { + LEVEL_LABELS, + LEVEL_COLORS, + CATEGORY_LABELS, + WORD_STATUS_LABELS, + WORD_STATUS_COLORS, +} from '../constants/vocabConstants' + +export default function WordListItem({ + word, + userWord, + onPlayTTS, + onToggleBookmark, + onClick, + isPlayingTTS, +}) { + const status = userWord?.status + const bookmarked = userWord?.bookmarked + + return ( + + + + {word.english} + + + + + } + secondary={ + + + {word.korean} + + {status && ( + + )} + + } + /> + + + + { + e.stopPropagation() + onPlayTTS?.() + }} + disabled={isPlayingTTS} + > + + + + + { + e.stopPropagation() + onToggleBookmark?.() + }} + > + {bookmarked ? ( + + ) : ( + + )} + + + + + ) +} diff --git a/src/domains/vocab/constants/vocabConstants.js b/src/domains/vocab/constants/vocabConstants.js new file mode 100644 index 0000000..60484e8 --- /dev/null +++ b/src/domains/vocab/constants/vocabConstants.js @@ -0,0 +1,95 @@ +// 학습 레벨 +export const LEVELS = { + BEGINNER: 'BEGINNER', + INTERMEDIATE: 'INTERMEDIATE', + ADVANCED: 'ADVANCED', +} + +export const LEVEL_LABELS = { + [LEVELS.BEGINNER]: '초급', + [LEVELS.INTERMEDIATE]: '중급', + [LEVELS.ADVANCED]: '고급', +} + +export const LEVEL_COLORS = { + [LEVELS.BEGINNER]: 'success', + [LEVELS.INTERMEDIATE]: 'warning', + [LEVELS.ADVANCED]: 'error', +} + +// 카테고리 +export const CATEGORIES = { + DAILY: 'DAILY', + BUSINESS: 'BUSINESS', + ACADEMIC: 'ACADEMIC', +} + +export const CATEGORY_LABELS = { + [CATEGORIES.DAILY]: '일상', + [CATEGORIES.BUSINESS]: '비즈니스', + [CATEGORIES.ACADEMIC]: '학술', +} + +// 학습 상태 +export const WORD_STATUS = { + NEW: 'NEW', + LEARNING: 'LEARNING', + REVIEWING: 'REVIEWING', + MASTERED: 'MASTERED', +} + +export const WORD_STATUS_LABELS = { + [WORD_STATUS.NEW]: '새 단어', + [WORD_STATUS.LEARNING]: '학습 중', + [WORD_STATUS.REVIEWING]: '복습 중', + [WORD_STATUS.MASTERED]: '암기 완료', +} + +export const WORD_STATUS_COLORS = { + [WORD_STATUS.NEW]: 'default', + [WORD_STATUS.LEARNING]: 'warning', + [WORD_STATUS.REVIEWING]: 'info', + [WORD_STATUS.MASTERED]: 'success', +} + +// 난이도 +export const DIFFICULTY = { + EASY: 'EASY', + NORMAL: 'NORMAL', + HARD: 'HARD', +} + +export const DIFFICULTY_LABELS = { + [DIFFICULTY.EASY]: '쉬움', + [DIFFICULTY.NORMAL]: '보통', + [DIFFICULTY.HARD]: '어려움', +} + +// 시험 유형 +export const TEST_TYPES = { + KOREAN_TO_ENGLISH: 'KOREAN_TO_ENGLISH', + ENGLISH_TO_KOREAN: 'ENGLISH_TO_KOREAN', +} + +export const TEST_TYPE_LABELS = { + [TEST_TYPES.KOREAN_TO_ENGLISH]: '한국어 → 영어', + [TEST_TYPES.ENGLISH_TO_KOREAN]: '영어 → 한국어', +} + +// TTS 음성 +export const VOICE_TYPES = { + MALE: 'MALE', + FEMALE: 'FEMALE', +} + +export const VOICE_LABELS = { + [VOICE_TYPES.MALE]: '남성', + [VOICE_TYPES.FEMALE]: '여성', +} + +// 일일 학습 목표 +export const DAILY_GOAL = { + NEW_WORDS: 50, + REVIEW_WORDS: 5, + TOTAL: 55, +} diff --git a/src/domains/vocab/index.js b/src/domains/vocab/index.js new file mode 100644 index 0000000..05c63ec --- /dev/null +++ b/src/domains/vocab/index.js @@ -0,0 +1,5 @@ +// Services +export * from './services/vocabService' + +// Constants +export * from './constants/vocabConstants' diff --git a/src/domains/vocab/pages/DailyLearning.jsx b/src/domains/vocab/pages/DailyLearning.jsx new file mode 100644 index 0000000..7100655 --- /dev/null +++ b/src/domains/vocab/pages/DailyLearning.jsx @@ -0,0 +1,383 @@ +import { useState, useEffect, useCallback } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Container, + Box, + Typography, + LinearProgress, + Button, + IconButton, + Tooltip, + CircularProgress, + Alert, + Paper, + Switch, + FormControlLabel, + ToggleButton, + ToggleButtonGroup, +} from '@mui/material' +import { + ArrowBack as BackIcon, + VolumeUp as VolumeIcon, + Star as StarIcon, + StarBorder as StarBorderIcon, + SkipNext as SkipIcon, + Check as CheckIcon, + Close as CloseIcon, + Celebration as CelebrationIcon, +} from '@mui/icons-material' +import FlashCard from '../components/FlashCard' +import { dailyService, userWordService, voiceService } from '../services/vocabService' +import { DIFFICULTY, DIFFICULTY_LABELS } from '../constants/vocabConstants' + +const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1' + +export default function DailyLearning() { + const navigate = useNavigate() + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [words, setWords] = useState([]) + const [currentIndex, setCurrentIndex] = useState(0) + const [isFlipped, setIsFlipped] = useState(false) + 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 }) + + useEffect(() => { + fetchDailyWords() + }, []) + + const fetchDailyWords = async () => { + try { + setLoading(true) + setError(null) + const response = await dailyService.getWords(TEMP_USER_ID) + const allWords = [ + ...(response?.data?.newWords || []), + ...(response?.data?.reviewWords || []), + ] + setWords(allWords) + + // 이미 학습한 단어 체크 + const learnedCount = response?.data?.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) + } + } catch (err) { + console.error('Fetch daily words error:', err) + setError('단어를 불러오는데 실패했습니다.') + } finally { + setLoading(false) + } + } + + const currentWord = words[currentIndex] + const progress = words.length > 0 ? (learnedIds.size / words.length) * 100 : 0 + + const playTTS = useCallback(async (word) => { + if (!word || isPlayingTTS) return + try { + setIsPlayingTTS(true) + const response = await voiceService.synthesize(word.wordId, word.english) + if (response?.data?.audioUrl) { + const audio = new Audio(response.data.audioUrl) + audio.onended = () => setIsPlayingTTS(false) + audio.onerror = () => setIsPlayingTTS(false) + await audio.play() + } else { + setIsPlayingTTS(false) + } + } catch (err) { + console.error('TTS error:', err) + setIsPlayingTTS(false) + } + }, [isPlayingTTS]) + + useEffect(() => { + if (autoPlayTTS && currentWord && !isFlipped) { + playTTS(currentWord) + } + }, [currentIndex, autoPlayTTS]) + + const handleFlip = () => { + setIsFlipped(!isFlipped) + } + + const handleAnswer = async (isCorrect) => { + if (!currentWord) return + + 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() + } + + const moveToNext = () => { + setIsFlipped(false) + if (currentIndex < words.length - 1) { + setCurrentIndex(prev => prev + 1) + } else { + setIsCompleted(true) + } + } + + const handleToggleBookmark = async () => { + if (!currentWord) return + try { + const newBookmarked = !currentWord.bookmarked + await userWordService.updateTag(TEMP_USER_ID, currentWord.wordId, { + bookmarked: newBookmarked, + }) + // 로컬 상태 업데이트 + setWords(prev => + prev.map(w => + w.wordId === currentWord.wordId ? { ...w, bookmarked: newBookmarked } : w + ) + ) + } catch (err) { + console.error('Bookmark error:', err) + } + } + + 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) + setResults({ correct: 0, incorrect: 0 }) + } + + if (loading) { + return ( + + + + + + ) + } + + if (error) { + return ( + + + {error} + + + + ) + } + + // 학습 완료 화면 + if (isCompleted) { + const totalAnswered = results.correct + results.incorrect + const accuracy = totalAnswered > 0 ? (results.correct / totalAnswered) * 100 : 0 + + return ( + + + + + 학습 완료! + + + 오늘의 학습을 완료했습니다 + + + + 학습 결과 + + + + {results.correct} + + 정답 + + + + {results.incorrect} + + 오답 + + + + 정확도: {accuracy.toFixed(1)}% + + + + + + + + + + ) + } + + return ( + + {/* 헤더 */} + + navigate('/vocab')}> + + + + 오늘의 학습 ({currentIndex + 1}/{words.length}) + + setAutoPlayTTS(e.target.checked)} + size="small" + /> + } + label={} + /> + + + {/* 진행률 바 */} + + + + 진행률 + + + {Math.round(progress)}% + + + + + + {/* 플래시카드 */} + + playTTS(currentWord)} + isPlayingTTS={isPlayingTTS} + /> + + + {/* 정답/오답 버튼 */} + + + + + + {/* 액션 바 */} + + + + {currentWord?.bookmarked ? ( + + ) : ( + + )} + + + + handleSetDifficulty(val)} + size="small" + > + {Object.entries(DIFFICULTY_LABELS).map(([key, label]) => ( + + {label} + + ))} + + + + + + + + + + ) +} diff --git a/src/domains/vocab/pages/TestPage.jsx b/src/domains/vocab/pages/TestPage.jsx new file mode 100644 index 0000000..2ff9f6a --- /dev/null +++ b/src/domains/vocab/pages/TestPage.jsx @@ -0,0 +1,488 @@ +import { useState, useEffect, useCallback } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Container, + Box, + Typography, + Button, + Paper, + ToggleButton, + ToggleButtonGroup, + RadioGroup, + Radio, + FormControlLabel, + FormControl, + FormLabel, + LinearProgress, + IconButton, + Chip, + Card, + CardContent, + CardActionArea, + CircularProgress, + Alert, +} from '@mui/material' +import { + ArrowBack as BackIcon, + PlayArrow as PlayIcon, + Timer as TimerIcon, + NavigateNext as NextIcon, + NavigateBefore as PrevIcon, +} from '@mui/icons-material' +import TestQuestion from '../components/TestQuestion' +import { testService } from '../services/vocabService' +import { LEVELS, LEVEL_LABELS, TEST_TYPES, TEST_TYPE_LABELS } from '../constants/vocabConstants' + +const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1' + +// 시험 설정 화면 +function TestSetup({ onStart, recentResults, loading }) { + const [wordCount, setWordCount] = useState(20) + const [level, setLevel] = useState(null) + const [type, setType] = useState(TEST_TYPES.ENGLISH_TO_KOREAN) + + const handleStart = () => { + onStart({ wordCount, level, type }) + } + + return ( + + + + 시험 설정 + + + {/* 문제 수 */} + + + 문제 수 + + val && setWordCount(val)} + fullWidth + > + 10개 + 20개 + 30개 + + + + {/* 레벨 */} + + + 레벨 + + 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} + /> + ))} + + + + + + + + {/* 최근 시험 기록 */} + {recentResults.length > 0 && ( + + + 최근 시험 기록 + + {recentResults.map((result, index) => ( + + + + + + {new Date(result.completedAt).toLocaleDateString()} + + + {result.totalQuestions}문제 + + + = 80 ? 'success' : result.successRate >= 60 ? 'warning' : 'error'} + /> + + + + ))} + + )} + + ) +} + +// 시험 진행 화면 +function TestInProgress({ + questions, + currentIndex, + answers, + timeRemaining, + onAnswer, + onNext, + onPrev, + onSubmit, +}) { + const currentQuestion = questions[currentIndex] + const progress = ((currentIndex + 1) / questions.length) * 100 + const minutes = Math.floor(timeRemaining / 60) + const seconds = timeRemaining % 60 + + return ( + + {/* 헤더 */} + + + 문제 {currentIndex + 1} / {questions.length} + + } + label={`${minutes}:${seconds.toString().padStart(2, '0')}`} + color={timeRemaining < 60 ? 'error' : 'default'} + /> + + + {/* 진행률 */} + + + {/* 문제 */} + onAnswer(currentQuestion.questionId, answer)} + /> + + {/* 네비게이션 */} + + + + {currentIndex === questions.length - 1 ? ( + + ) : ( + + )} + + + {/* 문제 번호 표시 */} + + {questions.map((q, idx) => ( + onNext(idx - currentIndex)} + > + {idx + 1} + + ))} + + + ) +} + +// 결과 화면 +function TestResult({ result, onRetry, onHome }) { + const navigate = useNavigate() + + return ( + + + {result.successRate?.toFixed(0)}점 + + + {result.correctCount} / {result.totalQuestions} 정답 + + + + + + + {result.correctCount} + + 정답 + + + + {result.incorrectCount} + + 오답 + + + + + {/* 틀린 문제 */} + {result.results?.filter(r => !r.isCorrect).length > 0 && ( + + + 틀린 문제 + + {result.results.filter(r => !r.isCorrect).map((r, idx) => ( + + + 내 답: {r.userAnswer} + + + 정답: {r.correctAnswer} + + + ))} + + )} + + + + + + + ) +} + +export default function TestPage() { + const navigate = useNavigate() + const [phase, setPhase] = useState('setup') // setup, testing, result + 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) + const [answers, setAnswers] = useState({}) + const [timeRemaining, setTimeRemaining] = useState(0) + const [result, setResult] = useState(null) + + useEffect(() => { + fetchRecentResults() + }, []) + + // 타이머 + useEffect(() => { + if (phase !== 'testing' || timeRemaining <= 0) return + + const timer = setInterval(() => { + setTimeRemaining(prev => { + if (prev <= 1) { + handleSubmit() + return 0 + } + return prev - 1 + }) + }, 1000) + + return () => clearInterval(timer) + }, [phase, timeRemaining]) + + const fetchRecentResults = async () => { + try { + const response = await testService.getResults(TEMP_USER_ID, { limit: 5 }) + setRecentResults(response?.data?.testResults || []) + } catch (err) { + console.error('Fetch results error:', err) + } + } + + const handleStart = async (options) => { + try { + setLoading(true) + setError(null) + const response = await testService.start(TEMP_USER_ID, options) + + if (response?.data) { + setTestId(response.data.testId) + setQuestions(response.data.questions || []) + setTimeRemaining(options.wordCount * 30) // 문제당 30초 + setAnswers({}) + setCurrentIndex(0) + setPhase('testing') + } + } catch (err) { + console.error('Start test error:', err) + setError('시험을 시작할 수 없습니다.') + } finally { + setLoading(false) + } + } + + const handleAnswer = (questionId, answer) => { + setAnswers(prev => ({ ...prev, [questionId]: answer })) + } + + const handleNext = (offset = 1) => { + const newIndex = currentIndex + offset + if (newIndex >= 0 && newIndex < questions.length) { + setCurrentIndex(newIndex) + } + } + + const handlePrev = () => { + if (currentIndex > 0) { + setCurrentIndex(currentIndex - 1) + } + } + + const handleSubmit = async () => { + try { + setLoading(true) + const answersArray = questions.map(q => ({ + questionId: q.questionId, + wordId: q.wordId, + answer: answers[q.questionId] || '', + })) + + const response = await testService.submit(TEMP_USER_ID, testId, answersArray) + + if (response?.data) { + setResult(response.data) + setPhase('result') + } + } catch (err) { + console.error('Submit error:', err) + setError('제출에 실패했습니다.') + } finally { + setLoading(false) + } + } + + const handleRetry = () => { + setPhase('setup') + setTestId(null) + setQuestions([]) + setAnswers({}) + setResult(null) + fetchRecentResults() + } + + return ( + + {/* 헤더 */} + + navigate('/vocab')}> + + + + 단어 시험 + + + + {error && ( + setError(null)}> + {error} + + )} + + {phase === 'setup' && ( + + )} + + {phase === 'testing' && ( + handleNext(1)} + onPrev={handlePrev} + onSubmit={handleSubmit} + /> + )} + + {phase === 'result' && result && ( + navigate('/vocab')} + /> + )} + + ) +} diff --git a/src/domains/vocab/pages/VocabDashboard.jsx b/src/domains/vocab/pages/VocabDashboard.jsx new file mode 100644 index 0000000..eb7e2d7 --- /dev/null +++ b/src/domains/vocab/pages/VocabDashboard.jsx @@ -0,0 +1,416 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Container, + Box, + Typography, + Grid, + Card, + CardContent, + CardActionArea, + LinearProgress, + Button, + Chip, + IconButton, + Tooltip, + CircularProgress, + Alert, +} from '@mui/material' +import { + MenuBook as VocabIcon, + PlayArrow as PlayIcon, + Assessment as StatsIcon, + Quiz as TestIcon, + LibraryBooks as WordListIcon, + VolumeUp as VolumeIcon, + Star as StarIcon, + StarBorder as StarBorderIcon, + CheckCircle as CheckIcon, + RadioButtonUnchecked as UncheckedIcon, +} from '@mui/icons-material' +import { dailyService, statsService, userWordService, voiceService } from '../services/vocabService' +import { + LEVEL_LABELS, + LEVEL_COLORS, + CATEGORY_LABELS, + DAILY_GOAL, +} from '../constants/vocabConstants' + +const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1' + +export default function VocabDashboard() { + const navigate = useNavigate() + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [dailyData, setDailyData] = useState(null) + const [statsData, setStatsData] = useState(null) + const [weeklyStats, setWeeklyStats] = useState([]) + const [weakWords, setWeakWords] = useState([]) + const [playingTTS, setPlayingTTS] = useState(null) + + useEffect(() => { + fetchDashboardData() + }, []) + + const fetchDashboardData = async () => { + try { + setLoading(true) + setError(null) + + const [daily, stats, weekly, weakness] = await Promise.all([ + dailyService.getWords(TEMP_USER_ID).catch(() => null), + statsService.getOverall(TEMP_USER_ID).catch(() => null), + statsService.getDaily(TEMP_USER_ID, { limit: 7 }).catch(() => null), + statsService.getWeakness(TEMP_USER_ID).catch(() => null), + ]) + + setDailyData(daily?.data) + setStatsData(stats?.data) + setWeeklyStats(weekly?.data?.dailyStats || []) + setWeakWords(weakness?.data?.weakestWords?.slice(0, 5) || []) + } catch (err) { + console.error('Dashboard fetch error:', err) + setError('데이터를 불러오는데 실패했습니다.') + } finally { + setLoading(false) + } + } + + const handlePlayTTS = async (word) => { + try { + setPlayingTTS(word.wordId) + const response = await voiceService.synthesize(word.wordId, word.english) + if (response?.data?.audioUrl) { + const audio = new Audio(response.data.audioUrl) + audio.onended = () => setPlayingTTS(null) + audio.onerror = () => setPlayingTTS(null) + await audio.play() + } + } catch (err) { + console.error('TTS error:', err) + setPlayingTTS(null) + } + } + + const handleToggleBookmark = async (word) => { + try { + 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 + ) + ) + } catch (err) { + console.error('Bookmark error:', err) + } + } + + if (loading) { + return ( + + + + + + ) + } + + const learnedCount = dailyData?.learnedCount || 0 + const totalWords = dailyData?.totalWords || DAILY_GOAL.TOTAL + const progress = totalWords > 0 ? (learnedCount / totalWords) * 100 : 0 + const newWordsCount = dailyData?.newWords?.length || 0 + const reviewWordsCount = dailyData?.reviewWords?.length || 0 + + return ( + + {/* 헤더 */} + + + + + 단어 학습 + + + + 매일 55개 단어로 영어 실력을 키워보세요 + + + + {error && ( + + {error} + + )} + + {/* 오늘의 학습 진행률 카드 */} + + + + 오늘의 학습 진행률 + + + + + + {learnedCount} / {totalWords} 단어 + + + {Math.round(progress)}% + + + + + + + + + 새 단어 + + + {newWordsCount} / {DAILY_GOAL.NEW_WORDS} + + + + + 복습 단어 + + + {reviewWordsCount} / {DAILY_GOAL.REVIEW_WORDS} + + + + + + + + + {/* 퀵 액션 카드 */} + + + + navigate('/vocab/stats')}> + + + + 전체 통계 + + + + {statsData?.totalWords || 0}개 + + + 학습한 단어 + + + + 정확도 {statsData?.accuracy?.toFixed(1) || 0}% + + + + + + + + + navigate('/vocab/test')}> + + + + 시험 보기 + + + + {statsData?.avgSuccessRate?.toFixed(1) || 0}% + + + 평균 성적 + + + + {statsData?.testCount || 0}회 응시 + + + + + + + + + navigate('/vocab/words')}> + + + + 단어장 + + + + {statsData?.wordStatusCounts?.MASTERED || 0}개 + + + 암기 완료 + + + + 북마크 {statsData?.bookmarkedCount || 0}개 + + + + + + + + {/* 주간 학습 현황 */} + + + + 주간 학습 현황 + + + {['월', '화', '수', '목', '금', '토', '일'].map((day, index) => { + const stat = weeklyStats[index] + const isCompleted = stat?.isCompleted + const hasProgress = stat?.learnedCount > 0 + + return ( + + + {day} + + + {isCompleted ? ( + + ) : hasProgress ? ( + + ) : ( + + )} + + + {stat?.learnedCount || '-'} + + + ) + })} + + + + + {/* 약점 단어 TOP 5 */} + {weakWords.length > 0 && ( + + + + 약점 단어 TOP 5 + + + 자주 틀리는 단어들을 집중 학습해보세요 + + + {weakWords.map((word) => ( + + + + {word.english} + + + + {word.korean} + + + + + + + handlePlayTTS(word)} + disabled={playingTTS === word.wordId} + > + + + + + handleToggleBookmark(word)}> + {word.bookmarked ? ( + + ) : ( + + )} + + + + + ))} + + + )} + + ) +} diff --git a/src/domains/vocab/pages/WordListPage.jsx b/src/domains/vocab/pages/WordListPage.jsx new file mode 100644 index 0000000..88d4aab --- /dev/null +++ b/src/domains/vocab/pages/WordListPage.jsx @@ -0,0 +1,485 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Container, + Box, + Typography, + TextField, + 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, +} from '@mui/icons-material' +import WordListItem from '../components/WordListItem' +import WordDetailModal from '../components/WordDetailModal' +import { wordService, userWordService, voiceService } from '../services/vocabService' +import { + LEVELS, + LEVEL_LABELS, + CATEGORIES, + CATEGORY_LABELS, + WORD_STATUS, + WORD_STATUS_LABELS, + VOICE_TYPES, +} from '../constants/vocabConstants' + +const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1' +const PAGE_SIZE = 20 + +// 디바운스 훅 +function useDebounce(value, delay) { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay) + return () => clearTimeout(timer) + }, [value, delay]) + + return debouncedValue +} + +export default function WordListPage() { + const navigate = useNavigate() + const observerRef = useRef(null) + const loadMoreRef = useRef(null) + + // 검색 & 필터 + 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 [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [hasMore, setHasMore] = useState(true) + const [page, setPage] = useState(0) + + // 상세 모달 + 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) => { + if (loading) return + + try { + setLoading(true) + setError(null) + + const params = { + page: pageNum, + size: PAGE_SIZE, + } + + 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) + } + } + + // 필터링 (bookmarked, status는 클라이언트에서 처리) + let filteredWords = newWords + + setWords(prev => reset ? filteredWords : [...prev, ...filteredWords]) + setHasMore(newWords.length === PAGE_SIZE) + setPage(pageNum) + } catch (err) { + console.error('Fetch words error:', err) + setError('단어 목록을 불러오는데 실패했습니다.') + } finally { + setLoading(false) + } + }, [loading, debouncedSearch, levelFilter, categoryFilter]) + + // 필터 변경시 리셋 + useEffect(() => { + setWords([]) + setUserWords({}) + setPage(0) + setHasMore(true) + fetchWords(0, true) + }, [debouncedSearch, levelFilter, categoryFilter]) + + // 무한 스크롤 + useEffect(() => { + if (loading || !hasMore) return + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasMore && !loading) { + fetchWords(page + 1) + } + }, + { threshold: 0.1 } + ) + + if (loadMoreRef.current) { + observer.observe(loadMoreRef.current) + } + + observerRef.current = observer + + return () => { + if (observerRef.current) { + 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 + }) + + // TTS 재생 + const handlePlayTTS = async (word, voice = VOICE_TYPES.FEMALE) => { + if (playingWordId) return + + try { + setPlayingWordId(word.wordId) + const response = await voiceService.synthesize({ + text: word.english, + voiceType: voice, + }) + + if (response?.data?.audioUrl) { + const audio = new Audio(response.data.audioUrl) + audio.onended = () => setPlayingWordId(null) + audio.onerror = () => setPlayingWordId(null) + await audio.play() + } else { + setPlayingWordId(null) + } + } catch (err) { + console.error('TTS error:', err) + setPlayingWordId(null) + } + } + + // 북마크 토글 + const handleToggleBookmark = async (word) => { + const userWord = userWords[word.wordId] + const newBookmarked = !userWord?.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) + } + } + + // 즐겨찾기 토글 + const handleToggleFavorite = async (word) => { + const userWord = userWords[word.wordId] + const newFavorite = !userWord?.favorite + + 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, + }, + })) + } catch (err) { + console.error('Set difficulty error:', err) + } + } + + // 단어 상세 열기 + const handleWordClick = (word) => { + setSelectedWord(word) + setModalOpen(true) + } + + // 검색 초기화 + const handleClearSearch = () => { + setSearchText('') + } + + // 필터 초기화 + const handleClearFilters = () => { + setLevelFilter(null) + setCategoryFilter(null) + setStatusFilter(null) + setBookmarkedOnly(false) + } + + const hasActiveFilters = levelFilter || categoryFilter || statusFilter || bookmarkedOnly + + return ( + + {/* 헤더 */} + + navigate('/vocab')}> + + + + 단어장 + + + + {/* 검색 */} + setSearchText(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: searchText && ( + + + + + + ), + }} + sx={{ mb: 2 }} + /> + + {/* 필터 */} + + {/* 레벨 필터 */} + + + 레벨 + + 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}개의 단어 + + + {error && ( + setError(null)}> + {error} + + )} + + {/* 단어 목록 */} + + {filteredWords.map((word) => ( + handlePlayTTS(word)} + onToggleBookmark={() => handleToggleBookmark(word)} + onClick={() => handleWordClick(word)} + isPlayingTTS={playingWordId === word.wordId} + /> + ))} + + + {/* 로딩 & 더보기 트리거 */} + + {loading && } + {!loading && !hasMore && filteredWords.length > 0 && ( + + 모든 단어를 불러왔습니다 + + )} + {!loading && filteredWords.length === 0 && !error && ( + + 검색 결과가 없습니다 + + )} + + + {/* 상세 모달 */} + setModalOpen(false)} + word={selectedWord} + userWord={selectedWord ? userWords[selectedWord.wordId] : null} + onPlayTTS={(voice) => selectedWord && handlePlayTTS(selectedWord, voice)} + 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 new file mode 100644 index 0000000..1014b98 --- /dev/null +++ b/src/domains/vocab/services/vocabService.js @@ -0,0 +1,91 @@ +import vocabApi from '../../../api/vocabApi' + +/** + * 단어 관리 API (#65) + */ +export const wordService = { + // 단어 목록 조회 + getList: ({ level, category, limit = 20, cursor } = {}) => + vocabApi.get('/vocab/words', { params: { level, category, limit, cursor } }), + + // 단어 검색 + search: ({ q, limit = 20, cursor } = {}) => + vocabApi.get('/vocab/words/search', { params: { q, limit, cursor } }), + + // 단어 상세 조회 + getDetail: (wordId) => + vocabApi.get(`/vocab/words/${wordId}`), +} + +/** + * 일일 학습 API (#66) + */ +export const dailyService = { + // 오늘의 학습 단어 조회 + getWords: (userId) => + vocabApi.get(`/vocab/daily/${userId}`), + + // 단어 학습 완료 표시 + markLearned: (userId, wordId, isCorrect) => + vocabApi.post(`/vocab/daily/${userId}/words/${wordId}/learned`, { isCorrect }), +} + +/** + * 사용자 단어 학습 상태 API (#67) + */ +export const userWordService = { + // 학습 상태 조회 + getList: (userId, { status, limit = 20, cursor } = {}) => + vocabApi.get(`/vocab/users/${userId}/words`, { params: { status, limit, cursor } }), + + // 학습 결과 업데이트 (정답/오답) + update: (userId, wordId, isCorrect) => + vocabApi.put(`/vocab/users/${userId}/words/${wordId}`, { isCorrect }), + + // 단어 태그 변경 (북마크/즐겨찾기/난이도) + updateTag: (userId, wordId, { bookmarked, favorite, difficulty }) => + vocabApi.put(`/vocab/users/${userId}/words/${wordId}/tag`, { bookmarked, favorite, difficulty }), +} + +/** + * 시험 API (#68) + */ +export const testService = { + // 시험 시작 + start: (userId, { wordCount = 20, level, type = 'ENGLISH_TO_KOREAN' } = {}) => + vocabApi.post(`/vocab/test/${userId}/start`, { wordCount, level, type }), + + // 답안 제출 + submit: (userId, testId, answers) => + vocabApi.post(`/vocab/test/${userId}/submit`, { testId, answers }), + + // 시험 결과 조회 + getResults: (userId, { limit = 20, cursor } = {}) => + vocabApi.get(`/vocab/test/${userId}/results`, { params: { limit, cursor } }), +} + +/** + * 통계 API (#69) + */ +export const statsService = { + // 전체 학습 통계 + getOverall: (userId) => + vocabApi.get(`/vocab/stats/${userId}`), + + // 일별 학습 통계 + getDaily: (userId, { limit = 30 } = {}) => + vocabApi.get(`/vocab/stats/${userId}/daily`, { params: { limit } }), + + // 약점 분석 + getWeakness: (userId) => + vocabApi.get(`/vocab/stats/${userId}/weakness`), +} + +/** + * 음성 API (TTS) (#70) + */ +export const voiceService = { + // 단어 발음 합성 + synthesize: (wordId, text, voice = 'FEMALE') => + vocabApi.post('/vocab/voice/synthesize', { wordId, text, voice }), +} diff --git a/src/layouts/MainLayout/Footer/index.jsx b/src/layouts/MainLayout/Footer/index.jsx new file mode 100644 index 0000000..5a54f86 --- /dev/null +++ b/src/layouts/MainLayout/Footer/index.jsx @@ -0,0 +1,71 @@ +import { Box, Typography, Link, Container, Divider } from '@mui/material' + +const Footer = () => { + return ( + + + + {/* 저작권 */} + + © 2026 AI Language Learning. All rights reserved. + + + {/* 링크 */} + + + 이용약관 + + + 개인정보처리방침 + + + 고객센터 + + + + + + ) +} + +export default Footer diff --git a/src/layouts/MainLayout/Header/index.jsx b/src/layouts/MainLayout/Header/index.jsx new file mode 100644 index 0000000..90f60d6 --- /dev/null +++ b/src/layouts/MainLayout/Header/index.jsx @@ -0,0 +1,256 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { + AppBar, + Toolbar, + Typography, + IconButton, + Box, + Button, + Avatar, + Menu, + MenuItem, + Divider, + Badge, + useMediaQuery, + useTheme, +} from '@mui/material' +import { + Menu as MenuIcon, + Notifications as NotificationsIcon, + Person as PersonIcon, + Settings as SettingsIcon, + Logout as LogoutIcon, + School as SchoolIcon, + DarkMode as DarkModeIcon, + LightMode as LightModeIcon, +} from '@mui/icons-material' +import { useThemeMode } from '../../../contexts/ThemeContext' + +const Header = ({ onMenuClick, sidebarOpen }) => { + const theme = useTheme() + const navigate = useNavigate() + const isMobile = useMediaQuery(theme.breakpoints.down('md')) + const { mode, toggleTheme } = useThemeMode() + + const [anchorEl, setAnchorEl] = useState(null) + const [notificationAnchor, setNotificationAnchor] = useState(null) + + const handleProfileMenuOpen = (event) => { + setAnchorEl(event.currentTarget) + } + + const handleProfileMenuClose = () => { + setAnchorEl(null) + } + + const handleNotificationOpen = (event) => { + setNotificationAnchor(event.currentTarget) + } + + const handleNotificationClose = () => { + setNotificationAnchor(null) + } + + const handleLogout = () => { + handleProfileMenuClose() + // TODO: 로그아웃 로직 + navigate('/login') + } + + return ( + + + {/* 햄버거 메뉴 (모바일/태블릿) */} + {isMobile && ( + + + + )} + + {/* 로고 & 서비스명 */} + navigate('/')} + > + + + AI 언어 학습 + + + + {/* 중앙 네비게이션 (데스크톱) */} + {!isMobile && ( + + + + + + )} + + + + {/* 우측 아이콘들 */} + + {/* 다크모드 토글 */} + + {mode === 'dark' ? : } + + + {/* 알림 */} + + + + + + + {/* 프로필 */} + + + U + + + + + {/* 알림 메뉴 */} + + + + 알림 + + + + + + + 면접 연습 세션이 완료되었습니다. + + + 10분 전 + + + + + + + OPIC 모의고사 결과가 도착했습니다. + + + 1시간 전 + + + + + + + 새로운 학습 리포트가 생성되었습니다. + + + 어제 + + + + + + {/* 프로필 메뉴 */} + + + + 사용자님 + + + user@example.com + + + + { handleProfileMenuClose(); navigate('/profile'); }}> + + 내 프로필 + + { handleProfileMenuClose(); navigate('/settings'); }}> + + 설정 + + + + + 로그아웃 + + + + + ) +} + +export default Header diff --git a/src/layouts/MainLayout/Sidebar/index.jsx b/src/layouts/MainLayout/Sidebar/index.jsx new file mode 100644 index 0000000..63daf2f --- /dev/null +++ b/src/layouts/MainLayout/Sidebar/index.jsx @@ -0,0 +1,359 @@ +import { useState, useEffect } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' +import { + Drawer, + Box, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + IconButton, + Divider, + Typography, + Collapse, + useTheme, + useMediaQuery, +} from '@mui/material' +import { + ChevronLeft as ChevronLeftIcon, + ChevronRight as ChevronRightIcon, + Dashboard as DashboardIcon, + Headphones as OpicIcon, + Edit as WritingIcon, + Assessment as ReportIcon, + Settings as SettingsIcon, + Mic as SpeakingIcon, + Create as WritingCategoryIcon, + People as PeopleIcon, + SmartToy as AiIcon, + ExpandMore as ExpandMoreIcon, + ExpandLess as ExpandLessIcon, + MenuBook as VocabIcon, + School as LearnIcon, + Quiz as QuizIcon, + LibraryBooks as WordListIcon, +} from '@mui/icons-material' + +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 Sidebar = ({ open, collapsed, onToggleCollapse, onClose }) => { + const theme = useTheme() + const location = useLocation() + const navigate = useNavigate() + const isMobile = useMediaQuery(theme.breakpoints.down('md')) + + // 펼침 상태 (localStorage 저장) + const [expandedMenus, setExpandedMenus] = useState(() => { + const saved = localStorage.getItem('expandedMenus') + return saved ? JSON.parse(saved) : { speaking: true, writing: true } + }) + + useEffect(() => { + localStorage.setItem('expandedMenus', JSON.stringify(expandedMenus)) + }, [expandedMenus]) + + const drawerWidth = collapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH + + const handleNavigation = (path) => { + navigate(path) + if (isMobile) { + onClose() + } + } + + const handleToggleExpand = (menuId) => { + setExpandedMenus((prev) => ({ + ...prev, + [menuId]: !prev[menuId], + })) + } + + const isActive = (path) => location.pathname === path + const isParentActive = (children) => children?.some((child) => location.pathname === child.path) + + const renderMenuItem = (item, isChild = false) => { + const Icon = item.icon + const hasChildren = item.children && item.children.length > 0 + const active = item.path ? isActive(item.path) : isParentActive(item.children) + const expanded = expandedMenus[item.id] + + return ( + + + { + if (hasChildren) { + handleToggleExpand(item.id) + } else if (item.path) { + handleNavigation(item.path) + } + }} + sx={{ + borderRadius: 2, + minHeight: 48, + 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', + '&:hover': { + backgroundColor: active && !hasChildren + ? 'primary.dark' + : 'action.hover', + }, + }} + > + + + + + {!collapsed && ( + <> + + {hasChildren && ( + expanded ? : + )} + + )} + + + + {/* 하위 메뉴 */} + {hasChildren && !collapsed && ( + + + {item.children.map((child) => renderMenuItem(child, true))} + + + )} + + ) + } + + const drawerContent = ( + + {/* 헤더 영역 - Toolbar 높이만큼 여백 */} + + + {/* 접기/펼치기 버튼 */} + {!isMobile && ( + + + {collapsed ? : } + + + )} + + {/* 메뉴 리스트 */} + + {menuItems.map((category, categoryIndex) => ( + + {!collapsed && ( + + {category.category} + + )} + + + {category.items.map((item) => renderMenuItem(item))} + + + {categoryIndex < menuItems.length - 1 && !collapsed && ( + + )} + + ))} + + + {/* 하단 정보 */} + {!collapsed && ( + + + 오늘의 학습 시간 + + + 1시간 23분 + + + )} + + ) + + // 모바일: 임시 Drawer + if (isMobile) { + return ( + + {drawerContent} + + ) + } + + // 데스크톱: 고정 Drawer + return ( + + {drawerContent} + + ) +} + +export default Sidebar diff --git a/src/layouts/MainLayout/index.jsx b/src/layouts/MainLayout/index.jsx new file mode 100644 index 0000000..bc3d376 --- /dev/null +++ b/src/layouts/MainLayout/index.jsx @@ -0,0 +1,95 @@ +import { useState, useEffect } from 'react' +import { Outlet } from 'react-router-dom' +import { Box, useTheme, useMediaQuery } from '@mui/material' +import Header from './Header' +import Sidebar from './Sidebar' +import Footer from './Footer' + +const DRAWER_WIDTH = 260 +const DRAWER_WIDTH_COLLAPSED = 72 + +const MainLayout = () => { + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down('md')) + + // 모바일 사이드바 열림 상태 + const [mobileOpen, setMobileOpen] = useState(false) + + // 데스크톱 사이드바 접힘 상태 (localStorage 저장) + const [collapsed, setCollapsed] = useState(() => { + const saved = localStorage.getItem('sidebarCollapsed') + return saved ? JSON.parse(saved) : false + }) + + // collapsed 상태 localStorage 저장 + useEffect(() => { + localStorage.setItem('sidebarCollapsed', JSON.stringify(collapsed)) + }, [collapsed]) + + const handleMobileToggle = () => { + setMobileOpen(!mobileOpen) + } + + const handleMobileClose = () => { + setMobileOpen(false) + } + + const handleCollapseToggle = () => { + setCollapsed(!collapsed) + } + + const drawerWidth = isMobile ? 0 : (collapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH) + + return ( + + {/* Header */} +
+ + {/* Sidebar */} + + + {/* Main Content */} + + {/* Toolbar 높이만큼 여백 */} + + + {/* 콘텐츠 영역 */} + + + + + {/* Footer */} +