diff --git a/src/App.jsx b/src/App.jsx index f8229b3..b9ac263 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -38,6 +38,7 @@ import DailyLearning from './domains/vocab/pages/DailyLearning' import TestPage from './domains/vocab/pages/TestPage' import WordListPage from './domains/vocab/pages/WordListPage' import StatsPage from './domains/vocab/pages/StatsPage' +import { WritingPage } from './domains/grammar' import { useChat } from './contexts/ChatContext' import { useSettings } from './contexts/SettingsContext' @@ -334,14 +335,6 @@ function FreetalkAiPage() { ) } -function WritingPage() { - return ( - - Writing Practice - Grammar correction & feedback - - ) -} function ReportsPage() { return ( diff --git a/src/api/grammarApi.js b/src/api/grammarApi.js new file mode 100644 index 0000000..5a1029c --- /dev/null +++ b/src/api/grammarApi.js @@ -0,0 +1,40 @@ +import axios from 'axios' + +const grammarApi = axios.create({ + baseURL: import.meta.env.VITE_GRAMMAR_API_URL || import.meta.env.VITE_API_URL, + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + }, +}) + +// Request interceptor +grammarApi.interceptors.request.use( + (config) => { + const token = localStorage.getItem('accessToken') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + (error) => { + return Promise.reject(error) + } +) + +// Response interceptor +grammarApi.interceptors.response.use( + (response) => response.data, + (error) => { + console.error('Grammar API Error:', error.response?.data || error.message) + + if (error.response?.status === 401) { + localStorage.removeItem('accessToken') + window.location.href = '/login' + } + + return Promise.reject(error) + } +) + +export default grammarApi diff --git a/src/domains/grammar/components/ChatInput.jsx b/src/domains/grammar/components/ChatInput.jsx new file mode 100644 index 0000000..d1386aa --- /dev/null +++ b/src/domains/grammar/components/ChatInput.jsx @@ -0,0 +1,212 @@ +import { useState } from 'react' +import { + Box, + TextField, + IconButton, + Select, + MenuItem, + FormControl, + CircularProgress, + Tooltip, +} from '@mui/material' +import { Send as SendIcon, School as SchoolIcon } from '@mui/icons-material' +import { useSettings } from '../../../contexts/SettingsContext' +import { + GRAMMAR_LEVELS, + GRAMMAR_LEVEL_COLORS, + TEXT_LIMITS, +} from '../constants/grammarConstants' + +export default function ChatInput({ onSend, loading = false, level, onLevelChange }) { + const { t, isKorean } = useSettings() + const [message, setMessage] = useState('') + + const handleSend = () => { + if (message.trim().length < TEXT_LIMITS.MIN_SENTENCE_LENGTH || loading) return + onSend?.(message.trim()) + setMessage('') + } + + const handleKeyDown = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSend() + } + } + + const canSend = message.trim().length >= TEXT_LIMITS.MIN_SENTENCE_LENGTH && !loading + + const levelOptions = [ + { value: GRAMMAR_LEVELS.BEGINNER, label: isKorean ? '초급' : 'Beginner' }, + { value: GRAMMAR_LEVELS.INTERMEDIATE, label: isKorean ? '중급' : 'Intermediate' }, + { value: GRAMMAR_LEVELS.ADVANCED, label: isKorean ? '고급' : 'Advanced' }, + ] + + return ( + + + {/* Level Selector */} + + + + + + + {/* Text Input */} + setMessage(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={ + isKorean + ? '영어 문장을 입력하세요...' + : 'Type your English sentence...' + } + disabled={loading} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: '16px', + backgroundColor: '#f9fafb', + '&:hover': { + backgroundColor: '#f3f4f6', + }, + '&.Mui-focused': { + backgroundColor: '#fff', + }, + '& fieldset': { + borderColor: '#e5e7eb', + }, + '&:hover fieldset': { + borderColor: '#d1d5db', + }, + '&.Mui-focused fieldset': { + borderColor: GRAMMAR_LEVEL_COLORS[level], + }, + }, + '& .MuiInputBase-input': { + py: 1.5, + px: 2, + '&::placeholder': { + color: '#9ca3af', + opacity: 1, + }, + }, + }} + /> + + {/* Send Button */} + + {loading ? ( + + ) : ( + + )} + + + + {/* Character count hint */} + + TEXT_LIMITS.MAX_SENTENCE_LENGTH + ? '#ef4444' + : '#9ca3af', + }} + > + {message.length > 0 && `${message.length}/${TEXT_LIMITS.MAX_SENTENCE_LENGTH}`} + + + + ) +} diff --git a/src/domains/grammar/components/ChatMessage.jsx b/src/domains/grammar/components/ChatMessage.jsx new file mode 100644 index 0000000..22454e8 --- /dev/null +++ b/src/domains/grammar/components/ChatMessage.jsx @@ -0,0 +1,255 @@ +import { Box, Typography, Chip, Collapse, IconButton } from '@mui/material' +import { + SmartToy as AiIcon, + Person as PersonIcon, + ExpandMore as ExpandMoreIcon, + CheckCircle as CheckIcon, + Error as ErrorIcon, +} from '@mui/icons-material' +import { useState } from 'react' +import { useSettings } from '../../../contexts/SettingsContext' +import { + GRAMMAR_ERROR_COLORS, + GRAMMAR_ERROR_BG_COLORS, + getScoreColor, +} from '../constants/grammarConstants' + +export default function ChatMessage({ message, isUser = false }) { + const { t, isKorean } = useSettings() + const [showDetails, setShowDetails] = useState(false) + + const { + content, + correctedContent, + grammarScore, + errors = [], + aiResponse, + conversationTip, + } = message + + const hasErrors = errors && errors.length > 0 + const scoreColor = grammarScore ? getScoreColor(grammarScore) : '#059669' + + if (isUser) { + return ( + + + {/* User Message Bubble */} + + + {content} + + + + {/* Grammar Score Badge */} + {grammarScore !== undefined && ( + + {grammarScore >= 90 ? ( + + ) : ( + + )} + + {grammarScore} + + {hasErrors && ( + setShowDetails(!showDetails)} + sx={{ p: 0.25 }} + > + + + )} + + )} + + {/* Correction Details */} + + + {/* Corrected Sentence */} + {correctedContent && correctedContent !== content && ( + + + {t('grammar.corrected')} + + + {correctedContent} + + + )} + + {/* Errors */} + + {errors.map((error, idx) => ( + + + + + + {error.original} + + {' → '} + + {error.corrected} + + + + + {error.explanation} + + + ))} + + + + + + {/* User Avatar */} + + + + + ) + } + + // AI Message + return ( + + {/* AI Avatar */} + + + + + + {/* AI Message Bubble */} + + + {aiResponse || content} + + + + {/* Conversation Tip */} + {conversationTip && ( + + + 💡 {conversationTip} + + + )} + + + ) +} diff --git a/src/domains/grammar/components/GrammarInput.jsx b/src/domains/grammar/components/GrammarInput.jsx new file mode 100644 index 0000000..da64a2d --- /dev/null +++ b/src/domains/grammar/components/GrammarInput.jsx @@ -0,0 +1,288 @@ +import { useState } from 'react' +import { + Box, + TextField, + Button, + ToggleButton, + ToggleButtonGroup, + Typography, + CircularProgress, + Paper, +} from '@mui/material' +import { + Spellcheck as SpellcheckIcon, + School as SchoolIcon, +} from '@mui/icons-material' +import { useSettings } from '../../../contexts/SettingsContext' +import { + GRAMMAR_LEVELS, + GRAMMAR_LEVEL_COLORS, + GRAMMAR_LEVEL_BG_COLORS, + TEXT_LIMITS, +} from '../constants/grammarConstants' + +export default function GrammarInput({ onCheck, loading = false }) { + const { t, isKorean } = useSettings() + const [sentence, setSentence] = useState('') + const [level, setLevel] = useState(GRAMMAR_LEVELS.BEGINNER) + const [error, setError] = useState('') + + const handleSentenceChange = (e) => { + const value = e.target.value + setSentence(value) + + if (value.length > TEXT_LIMITS.MAX_SENTENCE_LENGTH) { + setError(t('grammar.maxLength')) + } else { + setError('') + } + } + + const handleLevelChange = (_, newLevel) => { + if (newLevel) { + setLevel(newLevel) + } + } + + const handleCheck = () => { + if (sentence.trim().length < TEXT_LIMITS.MIN_SENTENCE_LENGTH) { + setError(t('grammar.minLength')) + return + } + + if (sentence.length > TEXT_LIMITS.MAX_SENTENCE_LENGTH) { + setError(t('grammar.maxLength')) + return + } + + setError('') + onCheck?.(sentence.trim(), level) + } + + const handleKeyDown = (e) => { + if (e.key === 'Enter' && !e.shiftKey && !loading) { + e.preventDefault() + handleCheck() + } + } + + const charCount = sentence.length + const isOverLimit = charCount > TEXT_LIMITS.MAX_SENTENCE_LENGTH + const canSubmit = + sentence.trim().length >= TEXT_LIMITS.MIN_SENTENCE_LENGTH && + !isOverLimit && + !loading + + const levelOptions = [ + { + value: GRAMMAR_LEVELS.BEGINNER, + label: t('grammar.beginner'), + desc: t('grammar.beginnerDesc'), + }, + { + value: GRAMMAR_LEVELS.INTERMEDIATE, + label: t('grammar.intermediate'), + desc: t('grammar.intermediateDesc'), + }, + { + value: GRAMMAR_LEVELS.ADVANCED, + label: t('grammar.advanced'), + desc: t('grammar.advancedDesc'), + }, + ] + + return ( + + {/* Level Selection */} + + + + + {t('grammar.selectLevel')} + + + + {levelOptions.map((option) => ( + + + + {option.label} + + + {option.desc} + + + + ))} + + + + {/* Text Input */} + + + + {/* Character Count */} + + + {charCount} / {TEXT_LIMITS.MAX_SENTENCE_LENGTH} + + + + + {/* Submit Button */} + + + ) +} diff --git a/src/domains/grammar/components/GrammarResult.jsx b/src/domains/grammar/components/GrammarResult.jsx new file mode 100644 index 0000000..4761230 --- /dev/null +++ b/src/domains/grammar/components/GrammarResult.jsx @@ -0,0 +1,407 @@ +import { Box, Typography, Paper, Chip, Collapse, IconButton, Divider } from '@mui/material' +import { + CheckCircle as CheckCircleIcon, + Error as ErrorIcon, + Lightbulb as LightbulbIcon, + ExpandMore as ExpandMoreIcon, + ArrowForward as ArrowForwardIcon, +} from '@mui/icons-material' +import { useState } from 'react' +import { useSettings } from '../../../contexts/SettingsContext' +import { + GRAMMAR_ERROR_COLORS, + GRAMMAR_ERROR_BG_COLORS, + getScoreGrade, + getScoreColor, +} from '../constants/grammarConstants' + +export default function GrammarResult({ result }) { + const { t, isKorean } = useSettings() + const [expandedError, setExpandedError] = useState(null) + + if (!result) return null + + const { + originalSentence, + correctedSentence, + score, + isCorrect, + errors = [], + feedback, + } = result + + const scoreGrade = getScoreGrade(score) + const scoreColor = getScoreColor(score) + + const toggleError = (index) => { + setExpandedError(expandedError === index ? null : index) + } + + // 문장에서 오류 부분을 하이라이트하는 함수 + const renderHighlightedSentence = (sentence, sentenceErrors, isCorrected = false) => { + if (!sentenceErrors || sentenceErrors.length === 0) { + return {sentence} + } + + // 오류를 시작 인덱스 기준으로 정렬 + const sortedErrors = [...sentenceErrors].sort((a, b) => a.startIndex - b.startIndex) + const parts = [] + let lastIndex = 0 + + sortedErrors.forEach((error, idx) => { + // 오류 이전 텍스트 + if (error.startIndex > lastIndex) { + parts.push( + + {sentence.slice(lastIndex, error.startIndex)} + + ) + } + + // 오류 부분 하이라이트 + const highlightText = isCorrected ? error.corrected : error.original + parts.push( + + {highlightText} + + ) + + lastIndex = error.endIndex + }) + + // 마지막 오류 이후 텍스트 + if (lastIndex < sentence.length) { + parts.push( + {sentence.slice(lastIndex)} + ) + } + + return ( + + {parts} + + ) + } + + return ( + + {/* Score Header */} + + + + {isCorrect ? ( + + + + ) : ( + + + + )} + + + {isCorrect + ? t('grammar.perfect') + : isKorean + ? scoreGrade.labelKo + : scoreGrade.label} + + + {isCorrect + ? t('grammar.noProblem') + : `${errors.length} ${t('grammar.errorCount')}`} + + + + + {/* Score Circle */} + + + {score} + + + {t('grammar.score')} + + + + + + {/* Original vs Corrected */} + + {/* Original Sentence */} + + + {t('grammar.original')} + + + {renderHighlightedSentence(originalSentence, errors, false)} + + + + {/* Arrow */} + {!isCorrect && ( + + + + + + )} + + {/* Corrected Sentence */} + {!isCorrect && ( + + + {t('grammar.corrected')} + + + + {correctedSentence} + + + + )} + + {/* Errors List */} + {errors.length > 0 && ( + <> + + + {t('grammar.errors')} + + + {errors.map((error, index) => ( + + toggleError(index)} + sx={{ + p: 2, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + cursor: 'pointer', + backgroundColor: expandedError === index + ? GRAMMAR_ERROR_BG_COLORS[error.type] + : 'transparent', + '&:hover': { + backgroundColor: GRAMMAR_ERROR_BG_COLORS[error.type], + }, + }} + > + + + + + {error.original} + + + + {error.corrected} + + + + + + + + + + + {error.explanation} + + + + + ))} + + + )} + + {/* Feedback */} + {feedback && ( + <> + + + + + + + {t('grammar.feedback')} + + + {feedback} + + + + + + )} + + + ) +} diff --git a/src/domains/grammar/components/SessionSidebar.jsx b/src/domains/grammar/components/SessionSidebar.jsx new file mode 100644 index 0000000..88d1683 --- /dev/null +++ b/src/domains/grammar/components/SessionSidebar.jsx @@ -0,0 +1,278 @@ +import { useState } from 'react' +import { + Box, + Typography, + List, + ListItem, + ListItemButton, + ListItemText, + IconButton, + Chip, + Divider, + Button, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + CircularProgress, +} from '@mui/material' +import { + Add as AddIcon, + Delete as DeleteIcon, + Chat as ChatIcon, + History as HistoryIcon, +} from '@mui/icons-material' +import { useSettings } from '../../../contexts/SettingsContext' +import { GRAMMAR_LEVEL_COLORS, GRAMMAR_LEVEL_BG_COLORS } from '../constants/grammarConstants' + +export default function SessionSidebar({ + sessions = [], + currentSessionId, + onSelectSession, + onNewSession, + onDeleteSession, + loading = false, +}) { + const { t, isKorean } = useSettings() + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [sessionToDelete, setSessionToDelete] = useState(null) + + const handleDeleteClick = (session, e) => { + e.stopPropagation() + setSessionToDelete(session) + setDeleteDialogOpen(true) + } + + const handleConfirmDelete = () => { + if (sessionToDelete) { + onDeleteSession?.(sessionToDelete.sessionId) + } + setDeleteDialogOpen(false) + setSessionToDelete(null) + } + + const formatDate = (dateString) => { + const date = new Date(dateString) + const now = new Date() + const diffMs = now - date + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMs / 3600000) + const diffDays = Math.floor(diffMs / 86400000) + + if (diffMins < 1) return isKorean ? '방금 전' : 'Just now' + if (diffMins < 60) return isKorean ? `${diffMins}분 전` : `${diffMins}m ago` + if (diffHours < 24) return isKorean ? `${diffHours}시간 전` : `${diffHours}h ago` + if (diffDays < 7) return isKorean ? `${diffDays}일 전` : `${diffDays}d ago` + return date.toLocaleDateString() + } + + const getLevelLabel = (level) => { + if (!level) return '' + const labels = { + BEGINNER: isKorean ? '초급' : 'Beginner', + INTERMEDIATE: isKorean ? '중급' : 'Intermediate', + ADVANCED: isKorean ? '고급' : 'Advanced', + } + return labels[level] || level + } + + return ( + + {/* Header */} + + + + + {/* Sessions List */} + + + + + + {isKorean ? '대화 기록' : 'History'} + + + + + {loading ? ( + + + + ) : sessions.length === 0 ? ( + + + + {isKorean ? '대화 기록이 없습니다' : 'No conversations yet'} + + + ) : ( + + {sessions.map((session) => ( + handleDeleteClick(session, e)} + sx={{ + opacity: 0, + transition: 'opacity 0.2s', + '.MuiListItem-root:hover &': { + opacity: 1, + }, + }} + > + + + } + > + onSelectSession?.(session.sessionId)} + sx={{ + borderRadius: '10px', + py: 1.5, + '&.Mui-selected': { + backgroundColor: '#ecfdf5', + '&:hover': { + backgroundColor: '#d1fae5', + }, + }, + '&:hover': { + backgroundColor: '#f3f4f6', + }, + }} + > + + + {session.topic || (isKorean ? '대화' : 'Chat')} + + {session.level && ( + + )} + + } + secondary={ + + + {session.lastMessage || `${session.messageCount} ${isKorean ? '메시지' : 'messages'}`} + + + {formatDate(session.updatedAt)} + + + } + /> + + + ))} + + )} + + + {/* Delete Confirmation Dialog */} + setDeleteDialogOpen(false)} + PaperProps={{ + sx: { borderRadius: '16px' }, + }} + > + + {isKorean ? '대화 삭제' : 'Delete Conversation'} + + + + {isKorean + ? '이 대화를 삭제하시겠습니까? 삭제된 대화는 복구할 수 없습니다.' + : 'Are you sure you want to delete this conversation? This action cannot be undone.'} + + + + + + + + + ) +} diff --git a/src/domains/grammar/constants/grammarConstants.js b/src/domains/grammar/constants/grammarConstants.js new file mode 100644 index 0000000..9a16f68 --- /dev/null +++ b/src/domains/grammar/constants/grammarConstants.js @@ -0,0 +1,190 @@ +/** + * Grammar domain constants + * Based on Backend GrammarErrorType enum + */ + +// 문법 오류 타입 +export const GRAMMAR_ERROR_TYPES = { + VERB_TENSE: 'VERB_TENSE', + SUBJECT_VERB_AGREEMENT: 'SUBJECT_VERB_AGREEMENT', + ARTICLE: 'ARTICLE', + PREPOSITION: 'PREPOSITION', + WORD_ORDER: 'WORD_ORDER', + PLURAL_SINGULAR: 'PLURAL_SINGULAR', + PRONOUN: 'PRONOUN', + SPELLING: 'SPELLING', + PUNCTUATION: 'PUNCTUATION', + WORD_CHOICE: 'WORD_CHOICE', + SENTENCE_STRUCTURE: 'SENTENCE_STRUCTURE', + OTHER: 'OTHER', +} + +// 문법 오류 타입 라벨 (한국어) +export const GRAMMAR_ERROR_LABELS_KO = { + VERB_TENSE: '동사 시제', + SUBJECT_VERB_AGREEMENT: '주어-동사 일치', + ARTICLE: '관사', + PREPOSITION: '전치사', + WORD_ORDER: '어순', + PLURAL_SINGULAR: '단수/복수', + PRONOUN: '대명사', + SPELLING: '철자', + PUNCTUATION: '구두점', + WORD_CHOICE: '단어 선택', + SENTENCE_STRUCTURE: '문장 구조', + OTHER: '기타', +} + +// 문법 오류 타입 라벨 (영어) +export const GRAMMAR_ERROR_LABELS_EN = { + VERB_TENSE: 'Verb Tense', + SUBJECT_VERB_AGREEMENT: 'Subject-Verb Agreement', + ARTICLE: 'Article', + PREPOSITION: 'Preposition', + WORD_ORDER: 'Word Order', + PLURAL_SINGULAR: 'Singular/Plural', + PRONOUN: 'Pronoun', + SPELLING: 'Spelling', + PUNCTUATION: 'Punctuation', + WORD_CHOICE: 'Word Choice', + SENTENCE_STRUCTURE: 'Sentence Structure', + OTHER: 'Other', +} + +// 문법 오류 타입별 색상 +export const GRAMMAR_ERROR_COLORS = { + VERB_TENSE: '#ef4444', + SUBJECT_VERB_AGREEMENT: '#f97316', + ARTICLE: '#eab308', + PREPOSITION: '#22c55e', + WORD_ORDER: '#06b6d4', + PLURAL_SINGULAR: '#3b82f6', + PRONOUN: '#8b5cf6', + SPELLING: '#ec4899', + PUNCTUATION: '#6b7280', + WORD_CHOICE: '#14b8a6', + SENTENCE_STRUCTURE: '#f59e0b', + OTHER: '#9ca3af', +} + +// 문법 오류 타입별 배경 색상 (밝은 버전) +export const GRAMMAR_ERROR_BG_COLORS = { + VERB_TENSE: '#fef2f2', + SUBJECT_VERB_AGREEMENT: '#fff7ed', + ARTICLE: '#fefce8', + PREPOSITION: '#f0fdf4', + WORD_ORDER: '#ecfeff', + PLURAL_SINGULAR: '#eff6ff', + PRONOUN: '#f5f3ff', + SPELLING: '#fdf2f8', + PUNCTUATION: '#f9fafb', + WORD_CHOICE: '#f0fdfa', + SENTENCE_STRUCTURE: '#fffbeb', + OTHER: '#f3f4f6', +} + +// 학습 레벨 +export const GRAMMAR_LEVELS = { + BEGINNER: 'BEGINNER', + INTERMEDIATE: 'INTERMEDIATE', + ADVANCED: 'ADVANCED', +} + +// 학습 레벨 라벨 (한국어) +export const GRAMMAR_LEVEL_LABELS_KO = { + BEGINNER: '초급', + INTERMEDIATE: '중급', + ADVANCED: '고급', +} + +// 학습 레벨 라벨 (영어) +export const GRAMMAR_LEVEL_LABELS_EN = { + BEGINNER: 'Beginner', + INTERMEDIATE: 'Intermediate', + ADVANCED: 'Advanced', +} + +// 학습 레벨 설명 (한국어) +export const GRAMMAR_LEVEL_DESC_KO = { + BEGINNER: '한국어 번역 포함, 쉬운 설명', + INTERMEDIATE: '영어 설명, 일반적인 패턴 학습', + ADVANCED: '상세한 문법 규칙, 뉘앙스 학습', +} + +// 학습 레벨 설명 (영어) +export const GRAMMAR_LEVEL_DESC_EN = { + BEGINNER: 'Korean translations, simple explanations', + INTERMEDIATE: 'English explanations, common patterns', + ADVANCED: 'Detailed rules, nuanced usage', +} + +// 학습 레벨 색상 +export const GRAMMAR_LEVEL_COLORS = { + BEGINNER: '#059669', + INTERMEDIATE: '#f97316', + ADVANCED: '#ef4444', +} + +// 학습 레벨 배경 색상 +export const GRAMMAR_LEVEL_BG_COLORS = { + BEGINNER: '#ecfdf5', + INTERMEDIATE: '#fff7ed', + ADVANCED: '#fef2f2', +} + +// 점수 등급 +export const 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 getScoreGrade = (score) => { + if (score >= 90) return SCORE_GRADES.EXCELLENT + if (score >= 70) return SCORE_GRADES.GOOD + if (score >= 50) return SCORE_GRADES.FAIR + return SCORE_GRADES.POOR +} + +// 점수에 따른 색상 반환 +export const getScoreColor = (score) => { + if (score >= 90) return '#059669' + if (score >= 70) return '#f97316' + if (score >= 50) return '#eab308' + return '#ef4444' +} + +// 메시지 역할 +export const MESSAGE_ROLES = { + USER: 'USER', + ASSISTANT: 'ASSISTANT', +} + +// 텍스트 제한 +export const TEXT_LIMITS = { + MIN_SENTENCE_LENGTH: 3, + MAX_SENTENCE_LENGTH: 500, + MAX_MESSAGE_LENGTH: 1000, +} + +export default { + GRAMMAR_ERROR_TYPES, + GRAMMAR_ERROR_LABELS_KO, + GRAMMAR_ERROR_LABELS_EN, + GRAMMAR_ERROR_COLORS, + GRAMMAR_ERROR_BG_COLORS, + GRAMMAR_LEVELS, + GRAMMAR_LEVEL_LABELS_KO, + GRAMMAR_LEVEL_LABELS_EN, + GRAMMAR_LEVEL_DESC_KO, + GRAMMAR_LEVEL_DESC_EN, + GRAMMAR_LEVEL_COLORS, + GRAMMAR_LEVEL_BG_COLORS, + SCORE_GRADES, + getScoreGrade, + getScoreColor, + MESSAGE_ROLES, + TEXT_LIMITS, +} diff --git a/src/domains/grammar/index.js b/src/domains/grammar/index.js new file mode 100644 index 0000000..c9cf947 --- /dev/null +++ b/src/domains/grammar/index.js @@ -0,0 +1,15 @@ +// Pages +export { default as WritingPage } from './pages/WritingPage' + +// Components +export { default as GrammarInput } from './components/GrammarInput' +export { default as GrammarResult } from './components/GrammarResult' +export { default as ChatMessage } from './components/ChatMessage' +export { default as ChatInput } from './components/ChatInput' +export { default as SessionSidebar } from './components/SessionSidebar' + +// Services +export * from './services/grammarService' + +// Constants +export * from './constants/grammarConstants' diff --git a/src/domains/grammar/pages/WritingPage.jsx b/src/domains/grammar/pages/WritingPage.jsx new file mode 100644 index 0000000..7ea3142 --- /dev/null +++ b/src/domains/grammar/pages/WritingPage.jsx @@ -0,0 +1,364 @@ +import { useState, useEffect, useRef } from 'react' +import { + Box, + Typography, + IconButton, + Drawer, + useMediaQuery, + useTheme, + Alert, +} from '@mui/material' +import { + Menu as MenuIcon, + Edit as EditIcon, + SmartToy as AiIcon, +} from '@mui/icons-material' +import { useSettings } from '../../../contexts/SettingsContext' +import ChatMessage from '../components/ChatMessage' +import ChatInput from '../components/ChatInput' +import SessionSidebar from '../components/SessionSidebar' +import { conversationService, sessionService } from '../services/grammarService' +import { GRAMMAR_LEVELS } from '../constants/grammarConstants' + +export default function WritingPage() { + const { t, isKorean } = useSettings() + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down('md')) + + const [sidebarOpen, setSidebarOpen] = useState(!isMobile) + const [sessions, setSessions] = useState([]) + const [currentSessionId, setCurrentSessionId] = useState(null) + const [messages, setMessages] = useState([]) + const [level, setLevel] = useState(GRAMMAR_LEVELS.BEGINNER) + const [loading, setLoading] = useState(false) + const [sessionsLoading, setSessionsLoading] = useState(true) + const [error, setError] = useState(null) + + const messagesEndRef = useRef(null) + + // Load sessions on mount + useEffect(() => { + loadSessions() + }, []) + + // Scroll to bottom when messages change + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages]) + + const loadSessions = async () => { + try { + setSessionsLoading(true) + const response = await sessionService.getList({ limit: 20 }) + setSessions(response.sessions || []) + } catch (err) { + console.error('Failed to load sessions:', err) + } finally { + setSessionsLoading(false) + } + } + + const loadSessionMessages = async (sessionId) => { + try { + setLoading(true) + const response = await sessionService.getDetail(sessionId) + if (response.session) { + setLevel(response.session.level || GRAMMAR_LEVELS.BEGINNER) + } + // Convert messages to our format + const formattedMessages = (response.messages || []).map((msg) => ({ + id: msg.messageId, + content: msg.content, + correctedContent: msg.correctedContent, + grammarScore: msg.grammarScore, + errors: msg.errorsJson ? JSON.parse(msg.errorsJson) : [], + aiResponse: msg.role === 'ASSISTANT' ? msg.content : null, + isUser: msg.role === 'USER', + createdAt: msg.createdAt, + })) + setMessages(formattedMessages) + } catch (err) { + console.error('Failed to load session messages:', err) + setError(isKorean ? '메시지를 불러오는데 실패했습니다' : 'Failed to load messages') + } finally { + setLoading(false) + } + } + + const handleSelectSession = (sessionId) => { + setCurrentSessionId(sessionId) + loadSessionMessages(sessionId) + if (isMobile) { + setSidebarOpen(false) + } + } + + const handleNewSession = () => { + setCurrentSessionId(null) + setMessages([]) + setError(null) + if (isMobile) { + setSidebarOpen(false) + } + } + + const handleDeleteSession = async (sessionId) => { + try { + await sessionService.delete(sessionId) + setSessions((prev) => prev.filter((s) => s.sessionId !== sessionId)) + if (currentSessionId === sessionId) { + handleNewSession() + } + } catch (err) { + console.error('Failed to delete session:', err) + } + } + + const handleSendMessage = async (message) => { + try { + setLoading(true) + setError(null) + + // Add user message immediately + const userMessage = { + id: `temp-${Date.now()}`, + content: message, + isUser: true, + createdAt: new Date().toISOString(), + } + setMessages((prev) => [...prev, userMessage]) + + // Send to API + const response = await conversationService.send(message, currentSessionId, level) + + // Update session ID if new + if (!currentSessionId && response.sessionId) { + setCurrentSessionId(response.sessionId) + // Reload sessions to show new one + loadSessions() + } + + // Update user message with grammar check results + setMessages((prev) => + prev.map((msg) => + msg.id === userMessage.id + ? { + ...msg, + correctedContent: response.grammarCheck?.correctedSentence, + grammarScore: response.grammarCheck?.score, + errors: response.grammarCheck?.errors || [], + } + : msg + ) + ) + + // Add AI response + const aiMessage = { + id: `ai-${Date.now()}`, + content: response.aiResponse, + aiResponse: response.aiResponse, + conversationTip: response.conversationTip, + isUser: false, + createdAt: new Date().toISOString(), + } + setMessages((prev) => [...prev, aiMessage]) + } catch (err) { + console.error('Failed to send message:', err) + setError(isKorean ? '메시지 전송에 실패했습니다' : 'Failed to send message') + // Remove temporary user message on error + setMessages((prev) => prev.filter((msg) => !msg.id.startsWith('temp-'))) + } finally { + setLoading(false) + } + } + + const sidebarContent = ( + + ) + + return ( + + {/* Sidebar - Desktop */} + {!isMobile && sidebarOpen && sidebarContent} + + {/* Sidebar - Mobile Drawer */} + {isMobile && ( + setSidebarOpen(false)} + PaperProps={{ + sx: { width: 280 }, + }} + > + {sidebarContent} + + )} + + {/* Main Chat Area */} + + {/* Header */} + + setSidebarOpen(!sidebarOpen)} sx={{ mr: 0.5 }}> + + + + + + + + + + {t('grammar.title')} + + + {t('grammar.subtitle')} + + + + + {/* Messages Area */} + + {error && ( + + {error} + + )} + + {messages.length === 0 ? ( + + + + + + {isKorean ? 'AI 문법 교정 도우미' : 'AI Grammar Assistant'} + + + {isKorean + ? '영어 문장을 입력하면 문법을 교정하고, 자연스러운 대화로 영어 실력을 향상시켜 드립니다.' + : 'Type an English sentence to get grammar corrections and improve your English through natural conversation.'} + + + + + {isKorean ? '예시 문장:' : 'Example sentences:'} + + + • "I go to school yesterday." + + + • "She have a beautiful car." + + + • "The informations is useful." + + + + ) : ( + <> + {messages.map((msg) => ( + + ))} +
+ + )} + + + {/* Input Area */} + + + + ) +} diff --git a/src/domains/grammar/services/grammarService.js b/src/domains/grammar/services/grammarService.js new file mode 100644 index 0000000..3de64c1 --- /dev/null +++ b/src/domains/grammar/services/grammarService.js @@ -0,0 +1,228 @@ +import grammarApi from '../../../api/grammarApi' + +// Mock 데이터 사용 여부 (true: 목 데이터 사용, false: 실제 API 호출) +const USE_MOCK = true + +// ============================================ +// Mock 데이터 +// ============================================ + +const mockGrammarCheckResponse = { + originalSentence: 'She go to the school yesterday.', + correctedSentence: 'She went to school yesterday.', + score: 60, + isCorrect: false, + errors: [ + { + type: 'VERB_TENSE', + original: 'go', + corrected: 'went', + explanation: '과거 시제에서는 "go"가 "went"로 변합니다.', + startIndex: 4, + endIndex: 6, + }, + { + type: 'ARTICLE', + original: 'the school', + corrected: 'school', + explanation: '일반적인 장소를 나타낼 때는 관사를 생략합니다.', + startIndex: 11, + endIndex: 21, + }, + ], + feedback: + '동사 시제와 관사 사용에 주의가 필요합니다. 과거의 일을 말할 때는 과거 시제를 사용하세요.', +} + +const mockConversationHistory = [ + { + messageId: 'msg-1', + role: 'USER', + content: 'Hello, how are you?', + correctedContent: 'Hello, how are you?', + grammarScore: 100, + createdAt: new Date(Date.now() - 300000).toISOString(), + }, + { + messageId: 'msg-2', + role: 'ASSISTANT', + content: "I'm doing great, thank you for asking! How about you?", + createdAt: new Date(Date.now() - 240000).toISOString(), + }, + { + messageId: 'msg-3', + role: 'USER', + content: 'I am fine. I go to work today.', + correctedContent: 'I am fine. I went to work today.', + grammarScore: 85, + errorsJson: JSON.stringify([ + { + type: 'VERB_TENSE', + original: 'go', + corrected: 'went', + explanation: '과거의 일을 말할 때는 과거 시제를 사용합니다.', + }, + ]), + createdAt: new Date(Date.now() - 180000).toISOString(), + }, + { + messageId: 'msg-4', + role: 'ASSISTANT', + content: "That's great! What kind of work do you do?", + createdAt: new Date(Date.now() - 120000).toISOString(), + }, +] + +const mockSessions = [ + { + sessionId: 'session-1', + userId: 'user1', + level: 'BEGINNER', + topic: 'Daily Conversation', + messageCount: 8, + lastMessage: 'Thank you for practicing with me!', + createdAt: new Date(Date.now() - 86400000).toISOString(), + updatedAt: new Date(Date.now() - 3600000).toISOString(), + }, + { + sessionId: 'session-2', + userId: 'user1', + level: 'INTERMEDIATE', + topic: 'Business English', + messageCount: 12, + lastMessage: 'I will schedule a meeting for next week.', + createdAt: new Date(Date.now() - 172800000).toISOString(), + updatedAt: new Date(Date.now() - 7200000).toISOString(), + }, + { + sessionId: 'session-3', + userId: 'user1', + level: 'BEGINNER', + topic: 'Travel Conversation', + messageCount: 6, + lastMessage: 'Where is the nearest subway station?', + createdAt: new Date(Date.now() - 259200000).toISOString(), + updatedAt: new Date(Date.now() - 86400000).toISOString(), + }, +] + +// ============================================ +// API with Mock fallback +// ============================================ + +const withMock = (apiCall, mockData) => { + if (USE_MOCK) { + return new Promise((resolve) => { + setTimeout(() => resolve(mockData), 800) + }) + } + return apiCall().catch(() => mockData) +} + +/** + * 문법 검사 API - Backend: POST /grammar/check + */ +export const grammarCheckService = { + // POST /grammar/check - 문법 검사 + check: (sentence, level = 'BEGINNER') => + withMock( + () => grammarApi.post('/grammar/check', { sentence, level }), + { + ...mockGrammarCheckResponse, + originalSentence: sentence, + correctedSentence: + sentence.length > 10 + ? sentence.replace(/go /g, 'went ').replace(/the school/g, 'school') + : sentence, + isCorrect: sentence.length <= 10, + score: sentence.length <= 10 ? 100 : Math.floor(Math.random() * 40) + 60, + errors: + sentence.length <= 10 + ? [] + : mockGrammarCheckResponse.errors.slice(0, Math.floor(Math.random() * 2) + 1), + } + ), +} + +/** + * 대화형 문법 교정 API - Backend: POST /grammar/conversation + */ +export const conversationService = { + // POST /grammar/conversation - AI 대화 + send: (message, sessionId = null, level = 'BEGINNER') => + withMock( + () => grammarApi.post('/grammar/conversation', { message, sessionId, level }), + { + sessionId: sessionId || `session-${Date.now()}`, + grammarCheck: { + originalSentence: message, + correctedSentence: message.replace(/go /g, 'went '), + score: Math.floor(Math.random() * 30) + 70, + isCorrect: !message.includes('go '), + errors: message.includes('go ') + ? [ + { + type: 'VERB_TENSE', + original: 'go', + corrected: 'went', + explanation: '과거 시제를 사용해야 합니다.', + startIndex: message.indexOf('go '), + endIndex: message.indexOf('go ') + 2, + }, + ] + : [], + feedback: message.includes('go ') + ? '동사 시제에 주의하세요!' + : '훌륭한 문장입니다!', + }, + aiResponse: + "That's interesting! Could you tell me more about it?", + conversationTip: + 'Try using more descriptive adjectives in your next sentence.', + } + ), +} + +/** + * 세션 관리 API - Backend: GET/DELETE /grammar/sessions + */ +export const sessionService = { + // GET /grammar/sessions - 세션 목록 조회 + getList: ({ limit = 10, cursor } = {}) => + withMock( + () => grammarApi.get('/grammar/sessions', { params: { limit, cursor } }), + { + sessions: mockSessions.slice(0, limit), + nextCursor: null, + hasMore: false, + } + ), + + // GET /grammar/sessions/{sessionId} - 세션 상세 조회 + getDetail: (sessionId, { messageLimit = 50 } = {}) => + withMock( + () => + grammarApi.get(`/grammar/sessions/${sessionId}`, { + params: { messageLimit }, + }), + { + session: + mockSessions.find((s) => s.sessionId === sessionId) || mockSessions[0], + messages: mockConversationHistory.slice(0, messageLimit), + } + ), + + // DELETE /grammar/sessions/{sessionId} - 세션 삭제 + delete: (sessionId) => + withMock(() => grammarApi.delete(`/grammar/sessions/${sessionId}`), { + code: 'SUCCESS', + message: 'Session deleted successfully', + }), +} + +// 단일 export로 모든 서비스 묶기 +export default { + grammarCheckService, + conversationService, + sessionService, +} diff --git a/src/i18n/translations.js b/src/i18n/translations.js index e4b4f87..e96c160 100644 --- a/src/i18n/translations.js +++ b/src/i18n/translations.js @@ -297,6 +297,58 @@ export const translations = { message: '요청하신 페이지가 존재하지 않거나 이동되었습니다.', backHome: '홈으로 돌아가기', }, + + // Grammar Check + grammar: { + title: '문법 교정', + subtitle: 'AI가 문법을 검사하고 피드백을 제공합니다', + inputPlaceholder: '검사할 영어 문장을 입력하세요...', + checkButton: '문법 검사', + checking: '검사 중...', + selectLevel: '학습 레벨', + beginner: '초급', + beginnerDesc: '한국어 번역 포함, 쉬운 설명', + intermediate: '중급', + intermediateDesc: '영어 설명, 일반적인 패턴 학습', + advanced: '고급', + advancedDesc: '상세한 문법 규칙, 뉘앙스 학습', + result: '검사 결과', + original: '원문', + corrected: '교정문', + score: '점수', + perfect: '완벽해요!', + noProblem: '문법 오류가 없습니다', + errors: '발견된 오류', + errorCount: '개 오류 발견', + feedback: '피드백', + suggestions: '개선 제안', + tryAgain: '다시 검사', + newSentence: '새 문장', + history: '검사 기록', + noHistory: '검사 기록이 없습니다', + minLength: '최소 3자 이상 입력하세요', + maxLength: '최대 500자까지 입력 가능합니다', + errorTypes: { + VERB_TENSE: '동사 시제', + SUBJECT_VERB_AGREEMENT: '주어-동사 일치', + ARTICLE: '관사', + PREPOSITION: '전치사', + WORD_ORDER: '어순', + PLURAL_SINGULAR: '단수/복수', + PRONOUN: '대명사', + SPELLING: '철자', + PUNCTUATION: '구두점', + WORD_CHOICE: '단어 선택', + SENTENCE_STRUCTURE: '문장 구조', + OTHER: '기타', + }, + scoreGrade: { + excellent: '훌륭해요!', + good: '잘했어요!', + fair: '괜찮아요', + poor: '더 연습해요', + }, + }, }, en: { @@ -592,6 +644,58 @@ export const translations = { message: "The page you're looking for doesn't exist or has been moved.", backHome: 'Back to Home', }, + + // Grammar Check + grammar: { + title: 'Grammar Check', + subtitle: 'AI checks your grammar and provides feedback', + inputPlaceholder: 'Enter an English sentence to check...', + checkButton: 'Check Grammar', + checking: 'Checking...', + selectLevel: 'Learning Level', + beginner: 'Beginner', + beginnerDesc: 'Korean translations, simple explanations', + intermediate: 'Intermediate', + intermediateDesc: 'English explanations, common patterns', + advanced: 'Advanced', + advancedDesc: 'Detailed rules, nuanced usage', + result: 'Results', + original: 'Original', + corrected: 'Corrected', + score: 'Score', + perfect: 'Perfect!', + noProblem: 'No grammar errors found', + errors: 'Errors Found', + errorCount: 'errors found', + feedback: 'Feedback', + suggestions: 'Suggestions', + tryAgain: 'Check Again', + newSentence: 'New Sentence', + history: 'History', + noHistory: 'No check history', + minLength: 'Enter at least 3 characters', + maxLength: 'Maximum 500 characters allowed', + errorTypes: { + VERB_TENSE: 'Verb Tense', + SUBJECT_VERB_AGREEMENT: 'Subject-Verb Agreement', + ARTICLE: 'Article', + PREPOSITION: 'Preposition', + WORD_ORDER: 'Word Order', + PLURAL_SINGULAR: 'Singular/Plural', + PRONOUN: 'Pronoun', + SPELLING: 'Spelling', + PUNCTUATION: 'Punctuation', + WORD_CHOICE: 'Word Choice', + SENTENCE_STRUCTURE: 'Sentence Structure', + OTHER: 'Other', + }, + scoreGrade: { + excellent: 'Excellent!', + good: 'Good job!', + fair: 'Not bad', + poor: 'Keep practicing', + }, + }, }, }