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 (
+
+ )
+}
+
+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: (
+
+
+
+ ),
+ }}
+ />
+
+ {/* 레벨 필터 */}
+
+ 전체
+ 초급
+ 중급
+ 고급
+ 참여중
+
+
+ {/* 새로고침 버튼 */}
+ }
+ onClick={handleRefresh}
+ disabled={loading}
+ >
+ 새로고침
+
+
+
+ {/* 에러 메시지 */}
+ {error && (
+ setError(null)}>
+ {error}
+
+ )}
+
+ {/* 채팅방 목록 */}
+
+ {filteredRooms.map((room, index) => (
+
+
+
+ ))}
+
+
+ {/* 로딩 인디케이터 */}
+ {loading && (
+
+
+
+ )}
+
+ {/* 빈 상태 */}
+ {!loading && filteredRooms.length === 0 && (
+
+
+ {error ? '데이터를 불러올 수 없습니다' : '채팅방이 없습니다'}
+
+
+ {error ? '새로고침 버튼을 눌러 다시 시도해주세요' : '새 채팅방을 만들어보세요'}
+
+
+ )}
+
+ {/* 채팅방 만들기 FAB */}
+ setCreateModalOpen(true)}
+ >
+
+
+
+ {/* 채팅방 생성 모달 */}
+ setCreateModalOpen(false)}
+ onSuccess={handleRefresh}
+ />
+
+ {/* 입장 모달 */}
+
+
+ )
+}
+
+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 (
+
+ )
+}
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}
+ />
+
+
+ {/* 정답/오답 버튼 */}
+
+ }
+ onClick={() => handleAnswer(false)}
+ sx={{ flex: 1, maxWidth: 160, py: 1.5 }}
+ >
+ 모르겠어요
+
+ }
+ onClick={() => handleAnswer(true)}
+ sx={{ flex: 1, maxWidth: 160, py: 1.5 }}
+ >
+ 알고있어요
+
+
+
+ {/* 액션 바 */}
+
+
+
+ {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}
+ />
+ ))}
+
+
+
+
+ : }
+ onClick={handleStart}
+ disabled={loading}
+ >
+ 시험 시작하기
+
+
+
+ {/* 최근 시험 기록 */}
+ {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)}
+ />
+
+ {/* 네비게이션 */}
+
+ }
+ onClick={onPrev}
+ disabled={currentIndex === 0}
+ >
+ 이전
+
+
+ {currentIndex === questions.length - 1 ? (
+
+ ) : (
+ }
+ onClick={onNext}
+ >
+ 다음
+
+ )}
+
+
+ {/* 문제 번호 표시 */}
+
+ {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}
+
+
+
+
+ }
+ onClick={() => navigate('/vocab/daily')}
+ sx={{
+ backgroundColor: 'white',
+ color: '#667eea',
+ fontWeight: 600,
+ '&:hover': {
+ backgroundColor: 'rgba(255,255,255,0.9)',
+ },
+ }}
+ >
+ {dailyData?.isCompleted ? '복습하기' : '학습 계속하기'}
+
+
+
+
+ {/* 퀵 액션 카드 */}
+
+
+
+ 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"
+ />
+
+
+ {/* 학습 상태 */}
+ 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}
+ />
+
+ )
+}
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
+
+
+
+
+ {/* 알림 메뉴 */}
+
+
+ {/* 프로필 메뉴 */}
+
+
+
+ )
+}
+
+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 */}
+
+
+
+ )
+}
+
+export default MainLayout
diff --git a/src/main.jsx b/src/main.jsx
index b103b9f..3d4cc67 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -2,19 +2,23 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'
import { BrowserRouter } from 'react-router-dom'
-import { ThemeProvider, CssBaseline } from '@mui/material'
import App from './App.jsx'
import { store } from './store'
-import theme from './theme/theme'
+import { ThemeProvider } from './contexts/ThemeContext'
+import { ChatProvider } from './contexts/ChatContext'
+import { SettingsProvider } from './contexts/SettingsContext'
import './index.css'
createRoot(document.getElementById('root')).render(
-
-
-
+
+
+
+
+
+
diff --git a/src/store/index.js b/src/store/index.js
index 14c2785..0228fab 100644
--- a/src/store/index.js
+++ b/src/store/index.js
@@ -1,8 +1,17 @@
-import { configureStore } from '@reduxjs/toolkit'
+import { configureStore, createSlice } from '@reduxjs/toolkit'
+
+// 임시 슬라이스 (빈 store 에러 방지)
+const appSlice = createSlice({
+ name: 'app',
+ initialState: {
+ initialized: true,
+ },
+ reducers: {},
+})
export const store = configureStore({
reducer: {
- // 슬라이스들 추가
+ app: appSlice.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
diff --git a/src/theme/theme.js b/src/theme/theme.js
index 7622490..2658c28 100644
--- a/src/theme/theme.js
+++ b/src/theme/theme.js
@@ -1,7 +1,50 @@
import { createTheme } from '@mui/material/styles'
-const theme = createTheme({
+const baseTheme = {
+ typography: {
+ fontFamily: '"Roboto", "Noto Sans KR", "Helvetica", "Arial", sans-serif',
+ h1: { fontWeight: 700 },
+ h2: { fontWeight: 600 },
+ h3: { fontWeight: 600 },
+ h4: { fontWeight: 600 },
+ h5: { fontWeight: 500 },
+ h6: { fontWeight: 500 },
+ },
+ shape: {
+ borderRadius: 8,
+ },
+ components: {
+ MuiButton: {
+ styleOverrides: {
+ root: {
+ textTransform: 'none',
+ borderRadius: 8,
+ fontWeight: 500,
+ },
+ },
+ },
+ MuiCard: {
+ styleOverrides: {
+ root: {
+ borderRadius: 12,
+ },
+ },
+ },
+ MuiPaper: {
+ styleOverrides: {
+ root: {
+ borderRadius: 12,
+ },
+ },
+ },
+ },
+}
+
+// 라이트 모드
+export const lightTheme = createTheme({
+ ...baseTheme,
palette: {
+ mode: 'light',
primary: {
main: '#0124ac',
light: '#4a52d4',
@@ -18,36 +61,42 @@ const theme = createTheme({
default: '#f5f7fa',
paper: '#ffffff',
},
- },
- typography: {
- fontFamily: '"Roboto", "Noto Sans KR", "Helvetica", "Arial", sans-serif',
- h1: {
- fontWeight: 700,
+ text: {
+ primary: '#1a1a2e',
+ secondary: '#6b7280',
},
- h2: {
- fontWeight: 600,
+ divider: '#e5e7eb',
+ },
+})
+
+// 다크 모드
+export const darkTheme = createTheme({
+ ...baseTheme,
+ palette: {
+ mode: 'dark',
+ primary: {
+ main: '#4a6cf7',
+ light: '#7b93f9',
+ dark: '#2148c4',
+ contrastText: '#ffffff',
},
- h3: {
- fontWeight: 600,
+ secondary: {
+ main: '#64b5f6',
+ light: '#90caf9',
+ dark: '#42a5f5',
+ contrastText: '#000000',
},
- },
- components: {
- MuiButton: {
- styleOverrides: {
- root: {
- textTransform: 'none',
- borderRadius: 8,
- },
- },
+ background: {
+ default: '#0f172a',
+ paper: '#1e293b',
},
- MuiCard: {
- styleOverrides: {
- root: {
- borderRadius: 12,
- },
- },
+ text: {
+ primary: '#f1f5f9',
+ secondary: '#94a3b8',
},
+ divider: '#334155',
},
})
-export default theme
+// 기본 export (하위 호환성)
+export default lightTheme