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 (
+
+ )
+}
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"
+ />
+
+
+ {/* 학습 상태 */}
+ setStatusAnchor(e.currentTarget)}
+ onDelete={statusFilter ? () => setStatusFilter(null) : undefined}
+ variant={statusFilter ? 'filled' : 'outlined'}
+ size="small"
+ />
+
+
+ {/* 북마크 */}
+ }
+ 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}
+ />
+
+ )
+}