diff --git a/src/App.jsx b/src/App.jsx index 1f9a6fe..59c2179 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -45,6 +45,8 @@ import { useAuth } from './contexts/AuthContext' import LoginPage from './pages/Login' import SignUpPage from './pages/SignUp' import { fetchMyProfile } from "./domains/profile/store/profileSlice"; +import ProfilePage from './domains/profile/pages/ProfilePage' +import OPIcPage from './domains/opic/pages/OPIcPage' function ProtectedRoute({ children }) { @@ -665,16 +667,6 @@ function Dashboard() { ) } -// Placeholder Pages -function OpicPage() { - return ( - - OPIC Practice - Level-based training - - ) -} - function ReportsPage() { const { isKorean } = useSettings() @@ -1183,7 +1175,8 @@ function App() { }> } /> } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/src/api/opicApi.js b/src/api/opicApi.js new file mode 100644 index 0000000..b30640c --- /dev/null +++ b/src/api/opicApi.js @@ -0,0 +1,13 @@ +import api from './axios' + +const OPIC_TIMEOUT = 60000 + +const opicApi = { + get: (url, config) => api.get(url, { timeout: OPIC_TIMEOUT, ...config }).then(res => res.data), + post: (url, data, config) => api.post(url, data, { timeout: OPIC_TIMEOUT, ...config }).then(res => res.data), + put: (url, data, config) => api.put(url, data, { timeout: OPIC_TIMEOUT, ...config }).then(res => res.data), + patch: (url, data, config) => api.patch(url, data, { timeout: OPIC_TIMEOUT, ...config }).then(res => res.data), + delete: (url, config) => api.delete(url, { timeout: OPIC_TIMEOUT, ...config }).then(res => res.data), +} + +export default opicApi \ No newline at end of file diff --git a/src/domains/freetalk/components/ChatRoomModal.jsx b/src/domains/freetalk/components/ChatRoomModal.jsx index 1856abc..653be25 100644 --- a/src/domains/freetalk/components/ChatRoomModal.jsx +++ b/src/domains/freetalk/components/ChatRoomModal.jsx @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useRef, useState} from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Alert, Avatar, @@ -30,20 +30,20 @@ import { messageService, voiceService } from '../../chat/services/chatService' -import {useSettings} from '../../../contexts/SettingsContext' -import {useAuth} from '../../../contexts/AuthContext' -import {useThemeMode} from '../../../contexts/ThemeContext' -import {DESIGN_TOKENS, getChatStyles} from '../../../theme/theme' -import {useChatWebSocket} from '../hooks/useChatWebSocket' +import { useSettings } from '../../../contexts/SettingsContext' +import { useAuth } from '../../../contexts/AuthContext' +import { useThemeMode } from '../../../contexts/ThemeContext' +import { DESIGN_TOKENS, getChatStyles } from '../../../theme/theme' +import { useChatWebSocket } from '../hooks/useChatWebSocket' import SystemCommandMessage from './SystemCommandMessage' -import {MessageType} from '../types/chatCommandTypes' +import { MessageType } from '../types/chatCommandTypes' -const ChatRoomModal = ({open, onClose, room, onLeave}) => { +const ChatRoomModal = ({ open, onClose, room, onLeave }) => { const theme = useTheme() - const {mode} = useThemeMode() + const { mode } = useThemeMode() const isDark = mode === 'dark' - const {settings} = useSettings() - const {user} = useAuth() + const { settings } = useSettings() + const { user } = useAuth() const currentUserId = user?.userId || user?.username || user?.sub const messagesEndRef = useRef(null) const dragRef = useRef(null) @@ -65,10 +65,10 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { 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 [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 [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }) const [opacity, setOpacity] = useState(100) const [opacityAnchorEl, setOpacityAnchorEl] = useState(null) // 메시지 목록 조회 @@ -76,7 +76,7 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { if (!room?.id) return try { - const response = await messageService.getList(room.id, {limit: 50}) + 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#', ''), @@ -102,9 +102,9 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { // 초기 로드 useEffect(() => { - console.log('[ChatRoomModal] useEffect triggered:', {open, roomId: room?.id, currentUserId}) + console.log('[ChatRoomModal] useEffect triggered:', { open, roomId: room?.id, currentUserId }) if (open && room?.id && currentUserId) { - console.log('[ChatRoomModal] Initializing...', {roomId: room.id, userId: currentUserId}) + console.log('[ChatRoomModal] Initializing...', { roomId: room.id, userId: currentUserId }) setLoading(true) setMinimized(false) @@ -125,7 +125,7 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { // 스크롤 맨 아래로 const scrollToBottom = (instant = false) => { - messagesEndRef.current?.scrollIntoView({behavior: instant ? 'instant' : 'smooth'}) + messagesEndRef.current?.scrollIntoView({ behavior: instant ? 'instant' : 'smooth' }) } // 메시지 로드 완료 후 스크롤 @@ -259,7 +259,7 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { if (!minimized) { // 최소화: 현재 위치 저장 후 우측 하단으로 이동 setSavedPosition(position) - setPosition({x: 0, y: 0}) + setPosition({ x: 0, y: 0 }) } else { // 최대화: 저장된 위치로 복원 후 스크롤 맨 아래로 setPosition(savedPosition) @@ -299,7 +299,7 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { { pointerEvents: 'auto', }} > - + {room?.name || '채팅방'} @@ -358,28 +358,28 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { /> )} - + setOpacityAnchorEl(e.currentTarget)} - sx={{color: 'white'}} + sx={{ color: 'white' }} title="투명도" > - + setOpacityAnchorEl(null)} - anchorOrigin={{vertical: 'top', horizontal: 'center'}} - transformOrigin={{vertical: 'bottom', horizontal: 'center'}} + anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + transformOrigin={{ vertical: 'bottom', horizontal: 'center' }} slotProps={{ paper: { - sx: {pointerEvents: 'auto'} + sx: { pointerEvents: 'auto' } } }} > - + 투명도: {opacity}% @@ -392,18 +392,18 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { /> - - + + - - {minimized ? : } + + {minimized ? : } - - + + - - + + @@ -412,61 +412,61 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { <> {/* 에러 메시지 */} {error && ( - setError(null)} sx={{borderRadius: 0}}> + setError(null)} sx={{ borderRadius: 0 }}> {error} )} {/* 메시지 영역 */} {loading ? ( - + + + ) : ( + - - - ) : ( - - {messages.length === 0 ? ( - - - 첫 메시지를 보내보세요! - - - ) : ( - messages.map((message) => { - // 시스템 명령어 메시지 (SYSTEM_COMMAND) - if (message.messageType === MessageType.SYSTEM_COMMAND || message.messageType === 'SYSTEM_COMMAND') { - return ( - - ) - } - + // 스크롤바 숨김 (hover 시만 표시) + '&::-webkit-scrollbar': { + width: 6, + }, + '&::-webkit-scrollbar-thumb': { + bgcolor: 'transparent', + borderRadius: 3, + }, + '&:hover::-webkit-scrollbar-thumb': { + bgcolor: isDark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)', + }, + }} + > + {messages.length === 0 ? ( + + + 첫 메시지를 보내보세요! + + + ) : ( + messages.map((message) => { + // 시스템 명령어 메시지 (SYSTEM_COMMAND) + if (message.messageType === MessageType.SYSTEM_COMMAND || message.messageType === 'SYSTEM_COMMAND') { return ( + + ) + } + + return ( { whiteSpace: 'pre-wrap', height: 'auto', py: 0.5, - '& .MuiChip-label': {whiteSpace: 'pre-wrap'}, + '& .MuiChip-label': { whiteSpace: 'pre-wrap' }, }} /> ) : ( @@ -518,7 +518,7 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { mr: message.isOwn ? 0.5 : 0, fontSize: '0.6rem' }}> - {message.userId} + {message.nickname || message.userId} { size="small" onClick={() => handlePlayTTS(message.id)} disabled={playingTTS === message.id} - sx={{p: 0.25}} + sx={{ p: 0.25 }} > {playingTTS === message.id ? ( - + ) : ( + }} /> )} + color="text.secondary" + sx={{ fontSize: '0.55rem' }}> {formatTime(message.createdAt)} @@ -584,17 +584,17 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { size="small" onClick={() => handlePlayTTS(message.id)} disabled={playingTTS === message.id} - sx={{p: 0.25}} + sx={{ p: 0.25 }} > {playingTTS === message.id ? ( - + ) : ( - + )} + color="text.secondary" + sx={{ fontSize: '0.55rem' }}> {formatTime(message.createdAt)} @@ -604,11 +604,12 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { )} - )}) - )} -
- - )} + ) + }) + )} +
+ + )} {/* 입력 영역 */} { color: 'white', width: 32, height: 32, - '&:hover': {bgcolor: 'primary.dark'}, - '&:disabled': {bgcolor: 'grey.300'}, + '&:hover': { bgcolor: 'primary.dark' }, + '&:disabled': { bgcolor: 'grey.300' }, }} > - {sendingMessage ? : - } + {sendingMessage ? : + } diff --git a/src/domains/opic/constants/opicConstants.js b/src/domains/opic/constants/opicConstants.js new file mode 100644 index 0000000..0faa114 --- /dev/null +++ b/src/domains/opic/constants/opicConstants.js @@ -0,0 +1,319 @@ +/** + * OPIc domain constants + * Based on grammarConstants.js pattern + */ + +// ============================================ +// 주제 타입 +// ============================================ +export const OPIC_TOPICS = { + DESCRIPTION: 'DESCRIPTION', + HABIT: 'HABIT', + PAST_EXPERIENCE: 'PAST_EXPERIENCE', + COMPARISON: 'COMPARISON', + ROLE_PLAY: 'ROLE_PLAY', + ISSUE: 'ISSUE', +} + + +export const OPIC_TOPIC_LABELS = { + DESCRIPTION: { ko: '단순 묘사', en: 'Description' }, + HABIT: { ko: '습관/경향', en: 'Habit' }, + PAST_EXPERIENCE: { ko: '과거 경험', en: 'Past Experience' }, + COMPARISON: { ko: '비교', en: 'Comparison' }, + ROLE_PLAY: { ko: '롤플레이', en: 'Role Play' }, + ISSUE: { ko: '사회 이슈/심화', en: 'Social Issues' }, +} + +// 주제별 아이콘 +export const OPIC_TOPIC_ICONS = { + DESCRIPTION: '🏠', + HABIT: '🔄', + PAST_EXPERIENCE: '📅', +} + +// 주제별 색상 +export const OPIC_TOPIC_COLORS = { + DESCRIPTION: '#3b82f6', + HABIT: '#22c55e', + PAST_EXPERIENCE: '#f97316', +} + +// ============================================ +// 세부 주제 +// ============================================ +export const OPIC_SUBTOPICS = [ + { value: 'BANKS', labelKo: '은행', labelEn: 'Banks' }, + { value: 'BARS', labelKo: '술집/바', labelEn: 'Bars' }, + { value: 'CAFES', labelKo: '카페', labelEn: 'Cafes' }, + { value: 'CONCERTS', labelKo: '콘서트', labelEn: 'Concerts' }, + { value: 'FAMILY', labelKo: '가족', labelEn: 'Family' }, + { value: 'FURNITURE', labelKo: '가구', labelEn: 'Furniture' }, + { value: 'GAMES', labelKo: '게임', labelEn: 'Games' }, + { value: 'GYM', labelKo: '헬스장', labelEn: 'Gym' }, + { value: 'HEALTH', labelKo: '건강', labelEn: 'Health' }, + { value: 'HOLIDAYS', labelKo: '명절/휴일', labelEn: 'Holidays' }, + { value: 'HOMES', labelKo: '집', labelEn: 'Homes' }, + { value: 'HOTEL', labelKo: '호텔', labelEn: 'Hotel' }, + { value: 'INTERNET', labelKo: '인터넷', labelEn: 'Internet' }, + { value: 'MOVIES', labelKo: '영화', labelEn: 'Movies' }, + { value: 'MUSIC', labelKo: '음악', labelEn: 'Music' }, + { value: 'PARKS', labelKo: '공원', labelEn: 'Parks' }, + { value: 'PHONE', labelKo: '전화기', labelEn: 'Phone' }, + { value: 'RECYCLING', labelKo: '재활용', labelEn: 'Recycling' }, + { value: 'RESTAURANTS', labelKo: '식당', labelEn: 'Restaurants' }, + { value: 'SHOPPING', labelKo: '쇼핑', labelEn: 'Shopping' }, + { value: 'TECHNOLOGY', labelKo: '기술', labelEn: 'Technology' }, + { value: 'TRAVEL', labelKo: '여행', labelEn: 'Travel' }, + { value: 'VACATION', labelKo: '휴가', labelEn: 'Vacation' }, + { value: 'WEATHER', labelKo: '날씨', labelEn: 'Weather' }, + { value: 'FREESTYLE', labelKo: '돌발/자유', labelEn: 'Freestyle' }, + { value: 'GENERAL', labelKo: '일반', labelEn: 'General' }, +]; + +// ============================================ +// 레벨 +// ============================================ +export const OPIC_LEVELS = { + IM1: 'IM1', + IM2: 'IM2', + IM3: 'IM3', + IH: 'IH', + AL: 'AL', +} + +export const OPIC_LEVEL_LABELS = { + IM1: { ko: 'IM1 (중급 하)', en: 'IM1 (Intermediate Mid 1)' }, + IM2: { ko: 'IM2 (중급 중)', en: 'IM2 (Intermediate Mid 2)' }, + IM3: { ko: 'IM3 (중급 상)', en: 'IM3 (Intermediate Mid 3)' }, + IH: { ko: 'IH (중상급)', en: 'IH (Intermediate High)' }, + AL: { ko: 'AL (고급)', en: 'AL (Advanced Low)' }, +} + +// 레벨별 색상 +export const OPIC_LEVEL_COLORS = { + IM1: '#22c55e', + IM2: '#3b82f6', + IM3: '#8b5cf6', + IH: '#f97316', +} + +// 레벨별 배경 색상 +export const OPIC_LEVEL_BG_COLORS = { + IM1: '#f0fdf4', + IM2: '#eff6ff', + IM3: '#f5f3ff', + IH: '#fff7ed', +} + +// ============================================ +// UI 번역 (한국어) +// ============================================ +export const OPIC_UI_KO = { + // 헤더 + title: 'OPIc 스피킹 테스트', + subtitle: 'AI 기반 영어 스피킹 연습 및 피드백', + + // 세션 설정 + sessionSetup: '세션 설정', + topic: '주제', + subTopic: '세부 주제', + targetLevel: '목표 레벨', + start: '시작하기', + + // 질문 + question: '질문', + listenQuestion: '질문 듣기', + + // 녹음 + recordAnswer: '답변 녹음', + startRecording: '녹음 시작', + stopRecording: '녹음 중지', + recording: '녹음 중...', + recordingReady: '녹음 완료', + playToReview: '재생하여 확인하세요', + reRecord: '다시 녹음', + + // 제출 + getUploadUrl: 'Upload URL 발급', + preparing: '준비 중...', + submitAnswer: '답변 제출', + submitting: '제출 중...', + + // 피드백 + aiFeedback: 'AI 피드백', + yourAnswer: '내 답변', + correctedAnswer: '교정된 답변', + modelAnswer: '모범 답변', + grammarErrors: '문법 오류', + + // 네비게이션 + nextQuestion: '다음 질문', + completeSession: '세션 완료', + completing: '완료 중...', + + // 결과 + sessionCompleted: '세션이 완료되었습니다!', + checkReport: '리포트를 확인하세요.', + overallScore: '종합 점수', + totalQuestions: '총 질문 수', + answeredQuestions: '답변한 질문', + + // 에러 + errorCreateSession: '세션 생성에 실패했습니다', + errorGetQuestion: '다음 질문을 불러오는데 실패했습니다', + errorStartRecording: '녹음을 시작할 수 없습니다', + errorGetUploadUrl: 'Upload URL 발급에 실패했습니다', + errorSubmitAnswer: '답변 제출에 실패했습니다', + errorCompleteSession: '세션 완료에 실패했습니다', + + // 마이크 권한 + micPermissionRequired: '마이크 권한이 필요합니다', + micPermissionDenied: '마이크 권한이 거부되었습니다', +} + +// ============================================ +// UI 번역 (영어) +// ============================================ +export const OPIC_UI_EN = { + // 헤더 + title: 'OPIc Speaking Test', + subtitle: 'AI-powered English speaking practice & feedback', + + // 세션 설정 + sessionSetup: 'Session Setup', + topic: 'Topic', + subTopic: 'Sub Topic', + targetLevel: 'Target Level', + start: 'Start Session', + + // 질문 + question: 'Question', + listenQuestion: 'Listen to question', + + // 녹음 + recordAnswer: 'Record Answer', + startRecording: 'Start Recording', + stopRecording: 'Stop Recording', + recording: 'Recording...', + recordingReady: 'Recording Ready', + playToReview: 'Play to review', + reRecord: 'Re-record', + + // 제출 + getUploadUrl: 'Get Upload URL', + preparing: 'Preparing...', + submitAnswer: 'Submit Answer', + submitting: 'Submitting...', + + // 피드백 + aiFeedback: 'AI Feedback', + yourAnswer: 'Your Answer', + correctedAnswer: 'Corrected Answer', + modelAnswer: 'Model Answer', + grammarErrors: 'Grammar Errors', + + // 네비게이션 + nextQuestion: 'Next Question', + completeSession: 'Complete Session', + completing: 'Completing...', + + // 결과 + sessionCompleted: 'Session completed!', + checkReport: 'Check your report.', + overallScore: 'Overall Score', + totalQuestions: 'Total Questions', + answeredQuestions: 'Answered Questions', + + // 에러 + errorCreateSession: 'Failed to create session', + errorGetQuestion: 'Failed to get next question', + errorStartRecording: 'Failed to start recording', + errorGetUploadUrl: 'Failed to get upload URL', + errorSubmitAnswer: 'Failed to submit answer', + errorCompleteSession: 'Failed to complete session', + + // 마이크 권한 + micPermissionRequired: 'Microphone permission required', + micPermissionDenied: 'Microphone permission denied', +} + +// ============================================ +// 점수 등급 (grammarConstants 패턴) +// ============================================ +export const OPIC_SCORE_GRADES = { + EXCELLENT: { min: 90, max: 100, label: 'Excellent', labelKo: '훌륭해요!' }, + GOOD: { min: 70, max: 89, label: 'Good', labelKo: '잘했어요!' }, + FAIR: { min: 50, max: 69, label: 'Fair', labelKo: '괜찮아요' }, + POOR: { min: 0, max: 49, label: 'Needs Practice', labelKo: '더 연습해요' }, +} + +// 점수에 따른 등급 반환 +export const getOpicScoreGrade = (score) => { + if (score >= 90) return OPIC_SCORE_GRADES.EXCELLENT + if (score >= 70) return OPIC_SCORE_GRADES.GOOD + if (score >= 50) return OPIC_SCORE_GRADES.FAIR + return OPIC_SCORE_GRADES.POOR +} + +// 점수에 따른 색상 반환 +export const getOpicScoreColor = (score) => { + if (score >= 90) return '#059669' + if (score >= 70) return '#3b82f6' + if (score >= 50) return '#f97316' + return '#ef4444' +} + +// ============================================ +// 설정값 +// ============================================ +export const OPIC_CONFIG = { + DEFAULT_TOTAL_QUESTIONS: 12, + MAX_RECORDING_TIME: 120, // 2분 + MIN_RECORDING_TIME: 5, // 5초 + AUDIO_MIME_TYPE: 'audio/webm', + FALLBACK_MIME_TYPE: 'audio/mp4', +} + +// ============================================ +// 헬퍼 함수 (오류 수정됨) +// ============================================ + +// 언어에 따른 주제 라벨 반환 +export const getTopicLabel = (topic, isKorean) => { + const labelObj = OPIC_TOPIC_LABELS[topic]; + return labelObj ? (isKorean ? labelObj.ko : labelObj.en) : topic; +} + +// 언어에 따른 세부주제 라벨 반환 +export const getSubtopicLabel = (subtopicValue, isKorean) => { + const found = OPIC_SUBTOPICS.find(item => item.value === subtopicValue); + if (!found) return subtopicValue; + return isKorean ? found.labelKo : found.labelEn; +} + +// 언어에 따른 레벨 라벨 반환 +export const getLevelLabel = (level, isKorean) => { + const labelObj = OPIC_LEVEL_LABELS[level]; + return labelObj ? (isKorean ? labelObj.ko : labelObj.en) : level; +} + +// 녹음 시간 포맷 (mm:ss) +export const formatRecordingTime = (seconds) => { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins}:${secs.toString().padStart(2, '0')}` +} + +export default { + OPIC_TOPICS, + OPIC_TOPIC_LABELS, + OPIC_TOPIC_ICONS, + OPIC_SUBTOPICS, + OPIC_LEVELS, + OPIC_LEVEL_LABELS, + getTopicLabel, + getSubtopicLabel, + getLevelLabel, + formatRecordingTime, +} \ No newline at end of file diff --git a/src/domains/opic/pages/OPIcPage.jsx b/src/domains/opic/pages/OPIcPage.jsx new file mode 100644 index 0000000..af50e00 --- /dev/null +++ b/src/domains/opic/pages/OPIcPage.jsx @@ -0,0 +1,704 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Alert, + Box, + Button, + Card, + CardContent, + CircularProgress, + FormControl, + InputLabel, + MenuItem, + Select, + TextField, + Typography, + useMediaQuery, + useTheme, + LinearProgress, + Chip, + Divider, + IconButton, + Grid, + Paper +} from '@mui/material' +import { + RecordVoiceOver as VoiceIcon, + Mic as MicIcon, + Stop as StopIcon, + PlayArrow as PlayIcon, + Pause as PauseIcon, + CheckCircle as CheckIcon, + Send as SendIcon, + UploadFile as UploadIcon, + VolumeUp as SpeakerIcon, + Home as HomeIcon, + TrendingUp as TrendingUpIcon, + Warning as WarningIcon, + Lightbulb as LightbulbIcon, + Email as EmailIcon +} from '@mui/icons-material' +import { useSettings } from '../../../contexts/SettingsContext' +import { useAuth } from '../../../contexts/AuthContext' +import { sessionService, uploadAudioToS3, pollForAnswerResult } from '../services/opicService' +import { + OPIC_TOPICS, + OPIC_TOPIC_LABELS, + OPIC_SUBTOPICS, +} from '../constants/opicConstants' + +export default function OPIcPage() { + const { t, isKorean } = useSettings() + const { user } = useAuth() + const theme = useTheme() + const navigate = useNavigate() + const isMobile = useMediaQuery(theme.breakpoints.down('md')) + + // Session state + const [sessionId, setSessionId] = useState(null) + const [sessionSettings, setSessionSettings] = useState({ + topic: OPIC_TOPICS.DESCRIPTION, + subTopic: 'HOMES', + }) + + // Question state + const [currentQuestion, setCurrentQuestion] = useState(null) + const [questionNumber, setQuestionNumber] = useState(0) + const [totalQuestions, setTotalQuestions] = useState(12) + + // Recording state + const [isRecording, setIsRecording] = useState(false) + const [recordedBlob, setRecordedBlob] = useState(null) + const [recordedUrl, setRecordedUrl] = useState(null) + const [mediaRecorder, setMediaRecorder] = useState(null) + const [recordingTime, setRecordingTime] = useState(0) + const audioChunksRef = useRef([]) + const [processingStatus, setProcessingStatus] = useState(null) + + // Upload state + const [uploadUrl, setUploadUrl] = useState(null) + const [s3Key, setS3Key] = useState(null) + + // Feedback state + const [feedback, setFeedback] = useState(null) + + // Report state + const [sessionReport, setSessionReport] = useState(null) + const [showReport, setShowReport] = useState(false) + + // UI state + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [uploadProgress, setUploadProgress] = useState(0) + + // Audio playback state + const [isPlayingQuestion, setIsPlayingQuestion] = useState(false) + const [isPlayingRecorded, setIsPlayingRecorded] = useState(false) + const questionAudioRef = useRef(null) + const recordedAudioRef = useRef(null) + + // Recording timer + useEffect(() => { + let interval + if (isRecording) { + interval = setInterval(() => { + setRecordingTime((prev) => prev + 1) + }, 1000) + } else { + setRecordingTime(0) + } + return () => clearInterval(interval) + }, [isRecording]) + + // Format recording time + const formatTime = (seconds) => { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins}:${secs.toString().padStart(2, '0')}` + } + + // Create session + const handleCreateSession = async () => { + try { + setLoading(true) + setError(null) + const userTargetLevel = user?.level || 'IM2'; + + const requestData = { + topic: sessionSettings.topic, + subTopic: sessionSettings.subTopic, + targetLevel: userTargetLevel + }; + + const data = await sessionService.create(requestData) + + console.log("✅ 백엔드 응답 데이터:", data); + + setSessionId(data.sessionId) + const firstQuestionData = data.question || data.questionResponse || data.firstQuestion; + + if (firstQuestionData) { + displayQuestion(firstQuestionData) + setQuestionNumber(1) + } else { + console.error("❌ 질문 데이터가 응답에 없습니다:", data); + setError("서버 응답에서 질문 데이터를 찾을 수 없습니다."); + } + + if (data.totalQuestions) { + setTotalQuestions(data.totalQuestions) + } + } catch (err) { + console.error('Failed to create session:', err) + setError(isKorean ? '세션 생성에 실패했습니다' : 'Failed to create session') + } finally { + setLoading(false) + } + } + + // Display question + const displayQuestion = (questionData) => { + setCurrentQuestion(questionData) + setFeedback(null) + setRecordedBlob(null) + setRecordedUrl(null) + setUploadUrl(null) + setS3Key(null) + setProcessingStatus(null) + } + + // Get next question + const handleNextQuestion = async () => { + try { + setLoading(true) + setError(null) + const data = await sessionService.getNextQuestion(sessionId) + + // 모든 질문 완료 확인 + if (data.completed) { + // 세션 완료 처리 + await handleCompleteSession() + return + } + + if (data.question) { + displayQuestion(data.question) + + // 질문 번호 업데이트 (백엔드에서 오는 questionNumber가 있으면 사용, 없으면 수동 계산) + setQuestionNumber(data.question.questionNumber || questionNumber + 1) + } else { + console.error("❌ 질문 데이터를 찾을 수 없습니다:", data) + setError(isKorean ? '질문 데이터를 불러오지 못했습니다' : 'Failed to load question data') + } + } catch (err) { + console.error('Failed to get next question:', err) + setError(isKorean ? '다음 질문을 불러오는데 실패했습니다' : 'Failed to get next question') + } finally { + setLoading(false) + } + } + + // Toggle recording + const toggleRecording = async () => { + if (!isRecording) { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + const recorder = new MediaRecorder(stream, { + mimeType: MediaRecorder.isTypeSupported('audio/webm') + ? 'audio/webm' + : 'audio/mp4', + }) + + audioChunksRef.current = [] + + recorder.ondataavailable = (event) => { + if (event.data.size > 0) { + audioChunksRef.current.push(event.data) + } + } + + recorder.onstop = () => { + const blob = new Blob(audioChunksRef.current, { type: 'audio/webm' }) + setRecordedBlob(blob) + const url = URL.createObjectURL(blob) + setRecordedUrl(url) + stream.getTracks().forEach((track) => track.stop()) + } + + recorder.start() + setMediaRecorder(recorder) + setIsRecording(true) + } catch (err) { + console.error('Failed to start recording:', err) + setError(isKorean ? '녹음을 시작할 수 없습니다' : 'Failed to start recording') + } + } else { + if (mediaRecorder) { + mediaRecorder.stop() + setMediaRecorder(null) + setIsRecording(false) + } + } + } + + // Get upload URL + const handleGetUploadUrl = async () => { + try { + setLoading(true) + setError(null) + const data = await sessionService.getUploadUrl(sessionId) + setUploadUrl(data.uploadUrl) + setS3Key(data.s3Key) + } catch (err) { + console.error('Failed to get upload URL:', err) + setError(isKorean ? 'Upload URL 발급에 실패했습니다' : 'Failed to get upload URL') + } finally { + setLoading(false) + } + } + + // Submit answer + const handleSubmitAnswer = async () => { + try { + setLoading(true) + setError(null) + setUploadProgress(0) + setProcessingStatus(isKorean ? 'S3에 업로드 중...' : 'Uploading to S3...') + + // 1. S3 업로드 + await uploadAudioToS3(uploadUrl, recordedBlob) + setUploadProgress(20) + setProcessingStatus(isKorean ? '답변 제출 중...' : 'Submitting...') + + // 2. 답변 제출 (비동기 처리 시작 요청) + const submitResult = await sessionService.submitAnswer(sessionId, { audioS3Key: s3Key }) + setUploadProgress(40) + setProcessingStatus(isKorean ? 'AI가 분석 중...' : 'AI is analyzing...') + + // 3. 폴링으로 결과 대기 (백엔드 완료될 때까지 반복 확인) + const result = await pollForAnswerResult(sessionId, submitResult.questionIndex, { + onProgress: ({ attempt }) => { + // 진행 상황에 따라 프로그레스 바를 조금씩 채움 (40% ~ 90%) + setUploadProgress(prev => Math.min(prev + 1, 90)) + } + }) + + // 4. 최종 결과 세팅 + setFeedback(result) + setUploadProgress(100) + setProcessingStatus(null) + + } catch (err) { + console.error('Failed to submit answer:', err) + setError(isKorean ? '분석 중 오류가 발생했습니다. 다시 시도해주세요.' : 'Analysis failed. Please try again.') + } finally { + setLoading(false) + setTimeout(() => setUploadProgress(0), 2000) + } + } + + + + // Complete session + const handleCompleteSession = async () => { + try { + setLoading(true) + setError(null) + const report = await sessionService.complete(sessionId) + + const reportData = report.data || report; + + // 리포트 데이터 저장 및 화면 전환 + setSessionReport(reportData) + setShowReport(true) + } catch (err) { + console.error('Failed to complete session:', err) + setError(isKorean ? '세션 완료에 실패했습니다' : 'Failed to complete session') + } finally { + setLoading(false) + } + } + + const handleStartNewSession = () => { + setSessionId(null) + setCurrentQuestion(null) + setQuestionNumber(0) + setFeedback(null) + setShowReport(false) + setSessionReport(null) + setRecordedBlob(null) + setRecordedUrl(null) + setUploadUrl(null) + setS3Key(null) + } + + // Navigate to reports page + const handleGoToReports = () => { + navigate('/reports') + } + + // Play question audio + const handlePlayQuestionAudio = () => { + if (questionAudioRef.current) { + if (isPlayingQuestion) { + questionAudioRef.current.pause() + setIsPlayingQuestion(false) + } else { + questionAudioRef.current.play() + setIsPlayingQuestion(true) + } + } + } + + // Play recorded audio + const handlePlayRecordedAudio = () => { + if (recordedAudioRef.current) { + if (isPlayingRecorded) { + recordedAudioRef.current.pause() + setIsPlayingRecorded(false) + } else { + recordedAudioRef.current.play() + setIsPlayingRecorded(true) + } + } + } + + const getLevelColor = (level) => { + const colors = { + 'IM1': '#22c55e', 'IM2': '#3b82f6', 'IM3': '#8b5cf6', + 'IH': '#f97316', 'AL': '#ef4444' + }; + return colors[level] || '#3b82f6'; + } + + // 결과 리포트 화면 + if (showReport && sessionReport) { + return ( + + + + {/* Header: Score & Level */} + + + {isKorean ? '테스트 결과 리포트' : 'TEST REPORT'} + + + + {/* Level */} + + + {sessionReport.estimatedLevel} + + LEVEL + + + + + {/* Score */} + + + {sessionReport.overallScore} + + SCORE + + + + {/* Overall Feedback */} + + + {sessionReport.feedback} + + + + + + + {/* Details Grid */} + + {[ + { title: isKorean ? "잘한 점" : "Strengths", items: sessionReport.strengths, color: "#16a34a", icon: TrendingUpIcon }, + { title: isKorean ? "아쉬운 점" : "Weaknesses", items: sessionReport.weaknesses, color: "#ea580c", icon: WarningIcon }, + { title: isKorean ? "학습 추천" : "Tips", items: sessionReport.recommendations, color: "#7c3aed", icon: LightbulbIcon } + ].map((section, idx) => ( + + + + + + {section.title} + + + + {section.items && section.items.length > 0 ? ( + section.items.map((item, i) => ( + + {item} + + )) + ) : ( + - + )} + + + + ))} + + + {/* Email Notification */} + + } + label={isKorean ? "결과가 이메일로 발송되었습니다." : "Report sent to your email."} + variant="outlined" + size="small" + sx={{ borderColor: '#e2e8f0', color: '#94a3b8', fontSize: '0.8rem' }} + /> + + + + {/* Bottom Actions */} + + + + + + ); + } + + return ( + + + {/* Header */} + + + + + + {isKorean ? 'OPIc 스피킹 테스트' : 'OPIc Speaking Test'} + {isKorean ? 'AI 기반 OPIc 연습 및 피드백' : 'AI-powered English speaking practice'} + + + + {/* Main Content */} + + {error && {error}} + + {/* --- [1] Session Setup --- */} + {!sessionId && ( + + + {isKorean ? 'OPIc 질문 생성' : 'Session Setup'} + + + {isKorean ? '질문 유형' : 'Question Type'} + + + + + {isKorean ? '주제' : 'Topic'} + + + + + * {isKorean ? `프로필에 설정된 나의 레벨(${user?.level || 'IM2'})에 맞춰 AI 피드백이 제공됩니다.` : `AI feedback tailored to level (${user?.level || 'IM2'}).`} + + + + + + )} + + {/* --- [2] Question Area --- */} + {sessionId && currentQuestion && ( + <> + + + + + + {/* 질문 카드 */} + + + {currentQuestion.questionText} + {currentQuestion.audioUrl && ( + + + {isPlayingQuestion ? : } + + {isKorean ? '질문 듣기' : 'Listen'} + + )} + + + + {/* 녹음 카드 */} + + + {isKorean ? '답변 녹음' : 'Record Answer'} + + + {isRecording && {formatTime(recordingTime)}} + {recordedUrl && !isRecording && ( + + + {isPlayingRecorded ? : } + + + {isKorean ? '녹음 완료' : 'Recorded'} + + + )} + + + + + {/* 제출 버튼 영역 */} + {recordedBlob && !feedback && ( + + + + {!uploadUrl ? ( + + ) : ( + + )} + + {uploadProgress > 0 && ( + + + {processingStatus && ( + + {processingStatus} + + )} + + )} + + + )} + + {/* 피드백 및 다음 질문 버튼 */} + {feedback && ( + + + AI Feedback + + {feedback.transcript && ( + + {isKorean ? '내 답변' : 'Your Answer'} + {feedback.transcript} + + )} + + {feedback.feedback?.correctedAnswer && ( + + {isKorean ? '교정된 답변' : 'Corrected'} + {feedback.feedback.correctedAnswer} + + )} + {feedback.feedback?.errors && feedback.feedback.errors.length > 0 && ( + + + {isKorean ? ' 문법 오류 체크' : 'Grammar Errors'} + + {feedback.feedback.errors.map((error, index) => ( + + + • {error.original}{error.corrected} + + {error.explanation && ( + + └ {error.explanation} + + )} + + ))} + + )} + + {(feedback.feedback?.grammarCorrection || feedback.feedback?.feedback) && ( + + + + + {isKorean ? ' AI 학습 팁 & 문법 교정' : 'Tips & Grammar Correction'} + + + + {/* grammarCorrection이 있으면 우선 보여주고, 없으면 전체 feedback을 보여줌 */} + {feedback.feedback.grammarCorrection || feedback.feedback.feedback} + + + )} + + + + + {/* 마지막 질문이 아니면 '다음 질문' 버튼, 마지막 질문이면 '세션 완료' 버튼 */} + {questionNumber < totalQuestions ? ( + + ) : ( + + )} + + + + )} + + )} + + + ); +} + diff --git a/src/domains/opic/services/opicService.js b/src/domains/opic/services/opicService.js new file mode 100644 index 0000000..3bff7b5 --- /dev/null +++ b/src/domains/opic/services/opicService.js @@ -0,0 +1,159 @@ +import opicApi from '../../../api/opicApi' + +/** + * OPIc 세션 서비스 + */ +export const sessionService = { + /** + * 세션 생성 + * @param {Object} data - { topic, subTopic } + */ + async create(data) { + const response = await opicApi.post('/opic/sessions', data) + if (!response.isSuccess) { + throw new Error(response.message || 'Failed to create session') + } + return response.data + }, + + /** + * 세션 목록 조회 + */ + async getList() { + const response = await opicApi.get('/opic/sessions') + if (!response.isSuccess) { + throw new Error(response.message || 'Failed to get sessions') + } + return response.data + }, + + /** + * 세션 상세 조회 + * @param {string} sessionId + */ + async getDetail(sessionId) { + const response = await opicApi.get(`/opic/sessions/${sessionId}`) + if (!response.isSuccess) { + throw new Error(response.message || 'Failed to get session detail') + } + return response.data + }, + + /** + * 다음 질문 조회 + * @param {string} sessionId + */ + async getNextQuestion(sessionId) { + const response = await opicApi.get(`/opic/sessions/${sessionId}/questions/next`) + if (!response.isSuccess) { + throw new Error(response.message || 'Failed to get question') + } + return response.data + }, + + /** + * 음성 업로드 URL 발급 + * @param {string} sessionId + */ + async getUploadUrl(sessionId) { + const response = await opicApi.get(`/opic/sessions/${sessionId}/upload-url`) + if (!response.isSuccess) { + throw new Error(response.message || 'Failed to get upload URL') + } + return response.data + }, + + /** + * 답변 제출 + * @param {string} sessionId + * @param {Object} data - { audioS3Key } + */ + async submitAnswer(sessionId, data) { + const response = await opicApi.post(`/opic/sessions/${sessionId}/answers`, data) + if (!response.isSuccess) { + throw new Error(response.message || 'Failed to submit answer') + } + return response.data + }, + + /** + * 답변 상태 조회 (폴링용 함수 추가) + */ + async getAnswerStatus(sessionId, questionIndex) { + const response = await opicApi.get(`/opic/sessions/${sessionId}/answers/${questionIndex}/status`) + if (!response.isSuccess) { + throw new Error(response.message || 'Failed to get answer status') + } + return response.data; // { status, transcript, feedback 등 } + }, + + /** + * 세션 완료 + * @param {string} sessionId + */ + async complete(sessionId) { + const response = await opicApi.post(`/opic/sessions/${sessionId}/complete`) + if (!response.isSuccess) { + throw new Error(response.message || 'Failed to complete session') + } + return response.data + }, +} + +/** + * S3 업로드 헬퍼 (presigned URL 사용) + * @param {string} uploadUrl - Presigned URL + * @param {Blob} audioBlob - 녹음된 오디오 + */ +export const uploadAudioToS3 = async (uploadUrl, audioBlob) => { + const response = await fetch(uploadUrl, { + method: 'PUT', + headers: { 'Content-Type': 'audio/webm' }, + body: audioBlob, + }) + if (!response.ok) { + throw new Error('Failed to upload audio to S3') + } + return true +} + +/** + * 답변 처리 결과 폴링 헬퍼 함수 + */ +export const pollForAnswerResult = async (sessionId, questionIndex, options = {}) => { + const { + maxAttempts = 90, + intervalMs = 2000, // 2초 간격 + onProgress = null // 진행 상태 보고용 콜백 + } = options + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const result = await sessionService.getAnswerStatus(sessionId, questionIndex) + + // 1. 완료 시 결과 반환 + if (result.status === 'COMPLETED') { + return result + } + + // 2. 실패 시 에러 발생 + if (result.status === 'FAILED') { + throw new Error(result.message || 'AI 분석 중 오류가 발생했습니다.') + } + + // 3. 처리 중일 때 (PROCESSING) + if (onProgress) { + onProgress({ attempt: attempt + 1, status: result.status }) + } + + // 대기 후 재시도 + await new Promise(resolve => setTimeout(resolve, intervalMs)) + } + + throw new Error('분석 시간이 초과되었습니다. 잠시 후 다시 확인해주세요.') +} + +export default { + sessionService, + uploadAudioToS3, + pollForAnswerResult +} \ No newline at end of file diff --git a/src/domains/profile/pages/ProfilePage.jsx b/src/domains/profile/pages/ProfilePage.jsx new file mode 100644 index 0000000..2f29b8b --- /dev/null +++ b/src/domains/profile/pages/ProfilePage.jsx @@ -0,0 +1,106 @@ +import { useState, useEffect } from 'react' +import { Box, Button, Card, CardContent, Container, TextField, Typography, Avatar, Alert } from '@mui/material' +import { useDispatch, useSelector } from 'react-redux' + +// ▼▼▼ [중요] 파일 위치가 바뀌었으니 import 경로도 수정했습니다! ▼▼▼ +// (pages 폴더와 store 폴더가 같은 profile 폴더 안에 형제(sibling) 관계이므로) +import { updateProfile } from '../store/profileSlice' + +const ProfilePage = () => { + const dispatch = useDispatch() + const { profile, updateLoading } = useSelector((state) => state.profile) + + const [nickname, setNickname] = useState('') + const [successMsg, setSuccessMsg] = useState('') + + // 리덕스에 있는 내 정보(profile)가 로드되면 입력창에 채워넣기 + useEffect(() => { + if (profile?.nickname) { + setNickname(profile.nickname) + } + }, [profile]) + + const handleSave = async () => { + try { + // 1. 닉네임 변경 요청 보내기 + await dispatch(updateProfile({ nickname })).unwrap() + + // 2. 성공 메시지 띄우기 + setSuccessMsg('닉네임이 변경되었습니다! 로그아웃 후 다시 로그인해주세요.') + } catch (error) { + alert('변경 실패: ' + error) + } + } + + return ( + + + 내 프로필 수정 + + + + + + {/* 프로필 이미지 */} + + + {nickname ? nickname.substring(0, 1).toUpperCase() : 'U'} + + + + {/* 이메일 (수정 불가) */} + + + {/* 닉네임 (수정 가능) */} + setNickname(e.target.value)} + fullWidth + variant="outlined" + helperText="채팅방에서 사용할 멋진 닉네임을 지어주세요!" + /> + + {/* 성공 메시지 */} + {successMsg && ( + {successMsg} + )} + + {/* 저장 버튼 */} + + + + + + ) +} + +export default ProfilePage \ No newline at end of file diff --git a/src/domains/speaking/services/speakingApi.js b/src/domains/speaking/services/speakingApi.js deleted file mode 100644 index 4d18b1d..0000000 --- a/src/domains/speaking/services/speakingApi.js +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Speaking REST API Service - */ - -const API_BASE_URL = import.meta.env.VITE_API_URL - -export const speakingApi = { - /** - * 대화 요청 (음성 또는 텍스트) - */ - async chat({ sessionId, audio, text, level = 'INTERMEDIATE' }) { - const token = localStorage.getItem('accessToken') - - // sessionId가 null/undefined면 body에서 제외 - const requestBody = { - ...(sessionId && { sessionId }), // null이 아닐 때만 포함 - ...(audio && { audio }), - ...(text && { text }), - level, - } - - console.log('[speakingApi] Request body:', { - hasSessionId: !!sessionId, - hasAudio: !!audio, - hasText: !!text, - level - }) - - const response = await fetch(`${API_BASE_URL}/api/speaking/chat`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify(requestBody), - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error || '요청 처리에 실패했습니다') - } - - return response.json() - }, - - /** - * 대화 초기화 - */ - async reset(sessionId) { - const token = localStorage.getItem('accessToken') - - const response = await fetch(`${API_BASE_URL}/api/speaking/reset`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify({ sessionId }), - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error || '초기화에 실패했습니다') - } - - return response.json() - }, -} \ No newline at end of file diff --git a/src/domains/vocab/pages/DailyLearning.jsx b/src/domains/vocab/pages/DailyLearning.jsx index 0a9a65b..c0d8c86 100644 --- a/src/domains/vocab/pages/DailyLearning.jsx +++ b/src/domains/vocab/pages/DailyLearning.jsx @@ -37,7 +37,7 @@ import {useAuth} from '../../../contexts/AuthContext' import {useThemeMode} from '../../../contexts/ThemeContext' // 카드 셔플 애니메이션 컴포넌트 -function ShuffleAnimation({count, isKorean}) { +function ShuffleAnimation({count, isKorean, isDark}) { return ( )}