diff --git a/src/App.jsx b/src/App.jsx index 0a69750..9956acc 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -35,6 +35,7 @@ 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' @@ -366,6 +367,7 @@ function App() { } /> } /> } /> + } /> } /> } /> 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/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} + /> + + ) +}