diff --git a/src/App.jsx b/src/App.jsx index f6c0508..483d766 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -33,6 +33,7 @@ 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 { useChat } from './contexts/ChatContext' import { useSettings } from './contexts/SettingsContext' @@ -362,6 +363,7 @@ function App() { } /> } /> } /> + } /> } /> } /> 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/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} + + ))} + + + + + + + + + + ) +}