diff --git a/.github/workflows/github-jira-pr-sync.yml b/.github/workflows/github-jira-pr-sync.yml index 303bf7e..d99c58e 100644 --- a/.github/workflows/github-jira-pr-sync.yml +++ b/.github/workflows/github-jira-pr-sync.yml @@ -2,11 +2,11 @@ name: PR-Jira Sync on: pull_request_target: - types: [ opened, reopened, edited, closed ] + types: [opened, reopened, edited, closed] branches: - develop - main - + workflow_dispatch: inputs: process_all_open_prs: @@ -138,7 +138,7 @@ jobs: issue_number: context.payload.pull_request.number, body: `Jira: [${jiraKey}](${jiraUrl})` }); - + # PR 수정 시 Jira 업데이트 update-jira-on-pr-edit: if: github.event_name == 'pull_request_target' && github.event.action == 'edited' @@ -205,7 +205,7 @@ jobs: JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} DESC_JSON: ${{ steps.parse.outputs.description }} - + with: script: | const jiraKey = '${{ steps.get-jira-key.outputs.jira_key }}'; @@ -417,31 +417,31 @@ jobs: repo: context.repo.repo, issue_number: pr.number }); - + const hasJiraLink = comments.data.some(c => c.body.includes('Jira:')); if (hasJiraLink) { console.log(`PR #${pr.number} already has Jira link, skipping`); continue; } - + console.log(`Processing PR #${pr.number}: ${pr.title}`); - + // 제목에서 type 파싱 const typeMatch = pr.title.match(/^(\w+)(?:\([^)]*\))?:/); const type = typeMatch ? typeMatch[1].toLowerCase() : 'task'; const jiraType = typeMap[type] || 'Task'; - + // 본문 파싱 const body = pr.body || ''; const sections = {}; const sectionNames = ['목적', '변경 요약', '수용 기준 검증']; - + for (const name of sectionNames) { const regex = new RegExp(`${name}\\n([\\s\\S]*?)(?=\\n(?:목적|변경 요약|수용 기준 검증|브레이킹|테스트|참조)|$)`); const match = body.match(regex); if (match) sections[name] = match[1].trim(); } - + const jiraResponse = await fetch( `${process.env.JIRA_BASE_URL}/rest/api/3/issue`, { @@ -472,9 +472,9 @@ jobs: }) } ); - + const jiraData = await jiraResponse.json(); - + if (jiraData.key) { await github.rest.issues.createComment({ owner: context.repo.owner, @@ -486,6 +486,6 @@ jobs: } else { console.log(`Failed to create Jira for PR #${pr.number}:`, jiraData); } - + await new Promise(resolve => setTimeout(resolve, 1000)); } diff --git a/src/App.jsx b/src/App.jsx index bb70c3a..dab5d55 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,4 +1,4 @@ -import {useState} from 'react' +import {useState, useEffect} from 'react' import {Navigate, Route, Routes, useNavigate} from 'react-router-dom' import {Box, Button, Card, CardContent, CircularProgress, Collapse, Container, Grid, Typography} from '@mui/material' import { @@ -9,6 +9,7 @@ import { LibraryBooks as WordListIcon, MenuBook as VocabIcon, Mic as SpeakingIcon, + Newspaper as NewsIcon, People as PeopleIcon, Quiz as QuizIcon, School as LearnIcon, @@ -30,6 +31,9 @@ import {BadgeSection} from './domains/badge' import CatchmindLobbyPage from './domains/games/pages/CatchmindLobbyPage' import CatchmindWaitingPage from './domains/games/pages/CatchmindWaitingPage' import CatchmindPlayPage from './domains/games/pages/CatchmindPlayPage' +import {NewsListPage, NewsDetailPage, NewsQuizPage, NewsWordsPage, NewsStatsPage} from './domains/news' +import {dailyService, statsService} from './domains/vocab/services/vocabService' +import {getNewsStats, getDashboardStats} from './domains/news/services/newsService' import {useChat} from './contexts/ChatContext' import {useSettings} from './contexts/SettingsContext' import {useAuth} from './contexts/AuthContext' @@ -88,7 +92,79 @@ function PublicRoute({children}) { function Dashboard() { const navigate = useNavigate() const [expandedCard, setExpandedCard] = useState(null) - const {t} = useSettings() + const {t, isKorean} = useSettings() + const [activityData, setActivityData] = useState(null) + const [loadingActivity, setLoadingActivity] = useState(true) + + // 최근 활동 데이터 로드 - 통합 대시보드 API 사용 + useEffect(() => { + const fetchActivityData = async () => { + try { + setLoadingActivity(true) + + // 통합 대시보드 API 호출 + const dashboardRes = await getDashboardStats().catch(() => null) + const dashboard = dashboardRes?.data || dashboardRes + + console.log('📊 Dashboard Stats:', dashboard) + + if (dashboard) { + // 통합 API 응답 구조: + // { today, overall, weeklyProgress, levelDistribution } + setActivityData({ + todayWords: dashboard.today?.wordsLearned || 0, + totalWords: dashboard.overall?.totalWordsLearned || 0, + dailyTotal: dashboard.today?.wordsTotal || 25, + vocabStreak: dashboard.overall?.currentStreak || 0, + newsRead: dashboard.today?.newsRead || 0, + totalNewsRead: dashboard.overall?.totalNewsRead || 0, + quizzesTaken: dashboard.today?.quizzesTaken || 0, + totalQuizzes: dashboard.overall?.totalQuizzes || 0, + averageAccuracy: dashboard.overall?.averageAccuracy || 0, + longestStreak: dashboard.overall?.longestStreak || 0, + weeklyProgress: dashboard.weeklyProgress || [], + levelDistribution: dashboard.levelDistribution || {}, + }) + } else { + // 통합 API 실패 시 개별 API 폴백 + const [dailyRes, statsRes, newsRes] = await Promise.allSettled([ + dailyService.getWords().catch(() => null), + statsService.getOverall().catch(() => null), + getNewsStats().catch(() => null), + ]) + + const dailyData = dailyRes.status === 'fulfilled' ? dailyRes.value : null + const statsData = statsRes.status === 'fulfilled' ? statsRes.value : null + const newsData = newsRes.status === 'fulfilled' ? newsRes.value?.data : null + + const daily = dailyData?.data || dailyData + const todayLearned = daily?.progress?.learned || daily?.dailyStudy?.learnedCount || 0 + const totalWords = daily?.progress?.total || daily?.dailyStudy?.totalWords || 0 + + const stats = statsData?.data || statsData + const vocabStreak = stats?.currentStreak || stats?.streakDays || 0 + const totalLearned = stats?.newWordsLearned || stats?.totalLearned || 0 + + setActivityData({ + todayWords: todayLearned, + totalWords: totalLearned, + dailyTotal: totalWords, + vocabStreak: vocabStreak, + newsRead: newsData?.todayRead || 0, + totalNewsRead: newsData?.totalRead || 0, + newsStreak: newsData?.currentStreak || 0, + quizScore: newsData?.averageQuizScore || 0, + }) + } + } catch (err) { + console.error('Failed to fetch activity data:', err) + } finally { + setLoadingActivity(false) + } + } + + fetchActivityData() + }, []) const learningModes = [ { @@ -120,8 +196,8 @@ function Dashboard() { title: t('dashboard.writingTitle'), description: t('dashboard.writingDesc'), icon: WritingCategoryIcon, - color: '#10b981', - bgColor: '#ecfdf5', + color: '#8b5cf6', + bgColor: '#f5f3ff', children: [ { id: 'chat-people', @@ -137,6 +213,13 @@ function Dashboard() { path: '/writing', description: t('dashboard.compositionDesc') }, + { + id: 'news-learning', + title: t('dashboard.newsTitle') || '뉴스 학습', + icon: NewsIcon, + path: '/news', + description: t('dashboard.newsDesc') || '실제 뉴스로 영어 학습' + }, ], }, { @@ -175,8 +258,8 @@ function Dashboard() { title: t('games.title'), description: t('games.description'), icon: GameIcon, - color: '#8b5cf6', - bgColor: '#f3e8ff', + color: '#06b6d4', + bgColor: '#ecfeff', children: [ { id: 'catchmind', @@ -381,43 +464,189 @@ function Dashboard() { })} - {/* Recent Activity */} + {/* Today's Activity Stats */} - - {t('dashboard.recentActivity')} - - - - - - - - {t('dashboard.noHistory')} - - - {t('dashboard.startLearning')} - - - - + {loadingActivity ? ( + + {[...Array(4)].map((_, i) => ( + + + + + + + + ))} + + ) : ( + + {/* 오늘 외운 단어 */} + + navigate('/vocab')} + sx={{ + borderRadius: '16px', + height: '100%', + cursor: 'pointer', + transition: 'all 0.2s ease', + '&:hover': { + transform: 'translateY(-4px)', + boxShadow: '0 12px 24px -8px rgba(249, 115, 22, 0.2)', + }, + }} + > + + + + + + {activityData?.todayWords || 0} + {activityData?.dailyTotal > 0 && ( + + /{activityData.dailyTotal} + + )} + + + {isKorean ? '오늘 외운 단어' : 'Words Today'} + + + + + {/* 읽은 뉴스 */} + + navigate('/news')} + sx={{ + borderRadius: '16px', + height: '100%', + cursor: 'pointer', + transition: 'all 0.2s ease', + '&:hover': { + transform: 'translateY(-4px)', + boxShadow: '0 12px 24px -8px rgba(139, 92, 246, 0.2)', + }, + }} + > + + + + + + {activityData?.newsRead || 0} + + + {isKorean ? '오늘 읽은 뉴스' : 'News Today'} + + + + + {/* 총 학습 단어 */} + + navigate('/vocab/words')} + sx={{ + borderRadius: '16px', + height: '100%', + cursor: 'pointer', + transition: 'all 0.2s ease', + '&:hover': { + transform: 'translateY(-4px)', + boxShadow: '0 12px 24px -8px rgba(249, 115, 22, 0.2)', + }, + }} + > + + + + + + {activityData?.totalWords || 0} + + + {isKorean ? '총 학습 단어' : 'Total Words'} + + + + + {/* 연속 학습 */} + + navigate('/reports')} + sx={{ + borderRadius: '16px', + height: '100%', + cursor: 'pointer', + transition: 'all 0.2s ease', + '&:hover': { + transform: 'translateY(-4px)', + boxShadow: '0 12px 24px -8px rgba(236, 72, 153, 0.2)', + }, + }} + > + + + + + + {Math.max(activityData?.vocabStreak || 0, activityData?.newsStreak || 0)} + + + {isKorean ? '연속 학습' : 'Day Streak'} + + + + + + )} ) @@ -445,15 +674,64 @@ function FreetalkAiPage() { function ReportsPage() { const {isKorean} = useSettings() + const [loading, setLoading] = useState(true) + const [stats, setStats] = useState({ + totalStudyDays: 0, + totalWords: 0, + totalTests: 0, + averageScore: 0, + currentStreak: 0, + bestStreak: 0, + newsRead: 0, + newsQuizScore: 0, + }) + + useEffect(() => { + const fetchReportData = async () => { + try { + setLoading(true) + const [vocabStatsRes, vocabHistoryRes, newsStatsRes] = await Promise.allSettled([ + statsService.getOverall().catch(() => null), + statsService.getDaily(null, {limit: 30}).catch(() => null), + getNewsStats().catch(() => null), + ]) + + const vocabStats = vocabStatsRes.status === 'fulfilled' ? (vocabStatsRes.value?.data || vocabStatsRes.value) : null + const vocabHistory = vocabHistoryRes.status === 'fulfilled' ? (vocabHistoryRes.value?.data || vocabHistoryRes.value) : null + const newsStats = newsStatsRes.status === 'fulfilled' ? newsStatsRes.value?.data : null + + // 학습일 계산 (히스토리에서 학습한 날 수) + const historyData = vocabHistory?.history || vocabHistory?.dailyStats || [] + const studyDays = historyData.filter(d => (d.newWordsLearned || d.learnedCount || 0) > 0).length + + setStats({ + totalStudyDays: studyDays || 0, + totalWords: vocabStats?.newWordsLearned || vocabStats?.totalLearned || 0, + totalTests: vocabStats?.testsCompleted || vocabStats?.testCount || 0, + averageScore: Math.round(vocabStats?.successRate || vocabStats?.averageAccuracy || 0), + currentStreak: vocabStats?.currentStreak || vocabStats?.streakDays || 0, + bestStreak: vocabStats?.longestStreak || 0, + newsRead: newsStats?.totalRead || 0, + newsQuizScore: newsStats?.averageQuizScore || 0, + }) + } catch (err) { + console.error('Failed to fetch report data:', err) + } finally { + setLoading(false) + } + } + + fetchReportData() + }, []) - // 더미 통계 데이터 - const stats = { - totalStudyDays: 15, - totalWords: 285, - totalTests: 12, - averageScore: 82, - currentStreak: 5, - bestStreak: 8, + if (loading) { + return ( + + + + + + ) } return ( @@ -542,6 +820,37 @@ function ReportsPage() { + {/* 뉴스 학습 통계 */} + {(stats.newsRead > 0 || stats.newsQuizScore > 0) && ( + + + {isKorean ? '뉴스 학습' : 'News Learning'} + + + + + + {stats.newsRead} + + + {isKorean ? '읽은 기사' : 'Articles Read'} + + + + + + + {stats.newsQuizScore}% + + + {isKorean ? '퀴즈 평균' : 'Quiz Average'} + + + + + + )} + {/* 연속 학습 */} @@ -875,6 +1184,11 @@ function App() { }/> }/> }/> + }/> + }/> + }/> + }/> + }/> {/* 404 */} diff --git a/src/api/chatApi.js b/src/api/chatApi.js index 0c79422..a41d0fe 100644 --- a/src/api/chatApi.js +++ b/src/api/chatApi.js @@ -1,7 +1,7 @@ import axios from 'axios' const chatApi = axios.create({ - baseURL: import.meta.env.VITE_CHAT_API_URL, + baseURL: import.meta.env.VITE_CHAT_API_URL || import.meta.env.VITE_API_URL, timeout: 10000, headers: { 'Content-Type': 'application/json', diff --git a/src/api/vocabApi.js b/src/api/vocabApi.js index 8ab1c6f..0c78af4 100644 --- a/src/api/vocabApi.js +++ b/src/api/vocabApi.js @@ -1,7 +1,7 @@ import axios from 'axios' const vocabApi = axios.create({ - baseURL: import.meta.env.VITE_VOCAB_API_URL, + baseURL: import.meta.env.VITE_VOCAB_API_URL || import.meta.env.VITE_API_URL, timeout: 10000, headers: { 'Content-Type': 'application/json', diff --git a/src/domains/badge/components/BadgeCard.jsx b/src/domains/badge/components/BadgeCard.jsx index 947d69a..5b40c9c 100644 --- a/src/domains/badge/components/BadgeCard.jsx +++ b/src/domains/badge/components/BadgeCard.jsx @@ -3,7 +3,10 @@ import {Box, Fade, LinearProgress, Tooltip, Typography,} from '@mui/material' import {EmojiEvents as TrophyIcon, Lock as LockIcon,} from '@mui/icons-material' import {useSettings} from '../../../contexts/SettingsContext' import {useThemeMode} from '../../../contexts/ThemeContext' -import {BADGE_CATEGORY_COLORS, BADGE_DESCRIPTIONS_EN, BADGE_NAMES_EN,} from '../constants/badgeConstants' +import {BADGE_CATEGORY_COLORS, BADGE_DESCRIPTIONS_EN, BADGE_NAMES_EN, NEWS_BADGE_TYPES,} from '../constants/badgeConstants' + +// 뉴스 뱃지 색상 +const NEWS_BADGE_COLOR = '#10b981' export default function BadgeCard({badge, size = 'medium'}) { const {isKorean} = useSettings() @@ -13,6 +16,8 @@ export default function BadgeCard({badge, size = 'medium'}) { const isEarned = badge.earned const progress = Math.min((badge.progress / badge.threshold) * 100, 100) + const isNewsBadge = NEWS_BADGE_TYPES.includes(badge.badgeType) || badge.badgeType?.startsWith('NEWS_') + const badgeColor = isNewsBadge ? NEWS_BADGE_COLOR : BADGE_CATEGORY_COLORS[badge.category] // Size configurations const sizes = { @@ -66,7 +71,7 @@ export default function BadgeCard({badge, size = 'medium'}) { backgroundColor: '#e5e7eb', '& .MuiLinearProgress-bar': { borderRadius: 3, - background: `linear-gradient(135deg, ${BADGE_CATEGORY_COLORS[badge.category]} 0%, ${BADGE_CATEGORY_COLORS[badge.category]}99 100%)`, + background: `linear-gradient(135deg, ${badgeColor} 0%, ${badgeColor}99 100%)`, }, }} /> @@ -143,11 +148,11 @@ export default function BadgeCard({badge, size = 'medium'}) { justifyContent: 'center', position: 'relative', background: isEarned - ? `linear-gradient(135deg, ${BADGE_CATEGORY_COLORS[badge.category]}20 0%, ${BADGE_CATEGORY_COLORS[badge.category]}10 100%)` + ? `linear-gradient(135deg, ${badgeColor}20 0%, ${badgeColor}10 100%)` : isDark ? '#3f3f46' : '#f3f4f6', - border: isEarned ? `3px solid ${BADGE_CATEGORY_COLORS[badge.category]}` : '3px solid #d1d5db', + border: isEarned ? `3px solid ${badgeColor}` : '3px solid #d1d5db', boxShadow: isEarned - ? `0 8px 24px -4px ${BADGE_CATEGORY_COLORS[badge.category]}40` + ? `0 8px 24px -4px ${badgeColor}40` : 'none', overflow: 'hidden', }} @@ -171,7 +176,7 @@ export default function BadgeCard({badge, size = 'medium'}) { { + const { articleId } = useParams() + const navigate = useNavigate() + const { isKorean } = useSettings() + const audioRef = useRef(null) + + const [article, setArticle] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // States + const [isBookmarked, setIsBookmarked] = useState(false) + const [isRead, setIsRead] = useState(false) + const [isPlaying, setIsPlaying] = useState(false) + const [audioLoading, setAudioLoading] = useState(false) + const [selectedVoice, setSelectedVoice] = useState('Joanna') + const [collectedWords, setCollectedWords] = useState(new Set()) + const [snackbar, setSnackbar] = useState({ open: false, message: '', severity: 'success' }) + + useEffect(() => { + const fetchArticle = async () => { + try { + setLoading(true) + const response = await getNewsDetail(articleId) + console.log('📰 Article Response:', response) + console.log('📰 Article Data:', response.data) + + // 새 API 응답 구조: data.article (중첩) 또는 기존 data 직접 + const articleData = response.data?.article || response.data + setArticle(articleData) + + // 북마크/읽음 상태 초기화 (새 API에서 제공) + if (response.data?.isBookmarked !== undefined) { + setIsBookmarked(response.data.isBookmarked) + } + if (response.data?.isRead !== undefined) { + setIsRead(response.data.isRead) + } + + setError(null) + } catch (err) { + console.error('Failed to fetch article:', err) + setError(err.message) + } finally { + setLoading(false) + } + } + + if (articleId) { + fetchArticle() + } + }, [articleId]) + + const handleBookmark = async () => { + try { + await toggleBookmark(articleId) + setIsBookmarked(!isBookmarked) + setSnackbar({ + open: true, + message: isBookmarked + ? (isKorean ? '북마크가 해제되었습니다' : 'Bookmark removed') + : (isKorean ? '북마크에 추가되었습니다' : 'Added to bookmarks'), + severity: 'success', + }) + } catch (err) { + console.error('Failed to toggle bookmark:', err) + } + } + + const handleMarkAsRead = async () => { + if (isRead) return + try { + const response = await markAsRead(articleId) + setIsRead(true) + + // 새 뱃지 획득 시 알림 + if (response.data?.newBadges?.length > 0) { + setSnackbar({ + open: true, + message: `${isKorean ? '새 뱃지 획득!' : 'New badge earned!'} ${response.data.newBadges[0].name}`, + severity: 'success', + }) + } else { + setSnackbar({ + open: true, + message: isKorean ? '읽기 완료!' : 'Read complete!', + severity: 'success', + }) + } + } catch (err) { + console.error('Failed to mark as read:', err) + } + } + + const handlePlayAudio = async () => { + if (isPlaying && audioRef.current) { + audioRef.current.pause() + setIsPlaying(false) + return + } + + try { + setAudioLoading(true) + const response = await getAudioUrl(articleId, selectedVoice) + const audioUrl = response.data?.audioUrl + + if (audioUrl) { + if (!audioRef.current) { + audioRef.current = new Audio() + } + audioRef.current.src = audioUrl + audioRef.current.onended = () => setIsPlaying(false) + await audioRef.current.play() + setIsPlaying(true) + } + } catch (err) { + console.error('Failed to play audio:', err) + setSnackbar({ + open: true, + message: isKorean ? '오디오 재생 실패' : 'Failed to play audio', + severity: 'error', + }) + } finally { + setAudioLoading(false) + } + } + + const handleCollectWord = async (keyword) => { + if (collectedWords.has(keyword.word)) return + + try { + // 단어 수집 - 백엔드에서 자동으로 단어장에 NEWS 카테고리로 추가됨 + const response = await collectWord( + articleId, + keyword.word, + `From article: ${article.title}` + ) + setCollectedWords(prev => new Set([...prev, keyword.word])) + + if (response.data?.newBadges?.length > 0) { + setSnackbar({ + open: true, + message: `${isKorean ? '새 뱃지 획득!' : 'New badge earned!'} ${response.data.newBadges[0].name}`, + severity: 'success', + }) + } else { + setSnackbar({ + open: true, + message: isKorean ? '단어가 수집되었습니다' : 'Word collected', + severity: 'success', + }) + } + } catch (err) { + console.error('Failed to collect word:', err) + setSnackbar({ + open: true, + message: isKorean ? '이미 수집된 단어입니다' : 'Word already collected', + severity: 'warning', + }) + } + } + + const getLevelLabel = (level) => { + const labels = { + BEGINNER: isKorean ? '초급' : 'Beginner', + INTERMEDIATE: isKorean ? '중급' : 'Intermediate', + ADVANCED: isKorean ? '고급' : 'Advanced', + } + return labels[level] || level + } + + const getCategoryLabel = (categoryId) => { + const category = NEWS_CATEGORIES.find(c => c.id === categoryId) + return category ? (isKorean ? category.label : category.labelEn) : categoryId + } + + const getCategoryColor = (categoryId) => { + const category = NEWS_CATEGORIES.find(c => c.id === categoryId) + return category?.color || '#6b7280' + } + + if (loading) { + return ( + + + + + + + + ) + } + + if (error || !article) { + return ( + + {error || 'Article not found'} + + + ) + } + + const levelColor = LEVEL_COLORS[article.level] || LEVEL_COLORS.INTERMEDIATE + + return ( + + {/* Top Bar */} + + + + + + + {isBookmarked ? ( + + ) : ( + + )} + + + + + + {audioLoading ? ( + + ) : isPlaying ? ( + + ) : ( + + )} + + + + + + {/* Tags */} + + + + + + {/* Title */} + + {article.title || (isKorean ? '제목 없음' : 'No Title')} + + + {/* Source & Date */} + + {article.source || 'Unknown'} | {article.publishedAt ? new Date(article.publishedAt).toLocaleDateString() : (isKorean ? '날짜 없음' : 'No Date')} + + + {/* Image */} + {article.imageUrl && ( + + )} + + {/* Summary */} + {article.summary && ( + + + + {isKorean ? '요약' : 'Summary'} + + + {article.summary} + + + + )} + + + + {/* Keywords - 단어 카드 (keywords 배열 사용) */} + {article.keywords && article.keywords.length > 0 && ( + + + + + {isKorean ? '핵심 단어' : 'Key Vocabulary'} + + + + + {article.keywords.map((keyword, index) => { + const isCollected = collectedWords.has(keyword.word) + const keywordLevelColor = LEVEL_COLORS[keyword.level] || LEVEL_COLORS.INTERMEDIATE + + return ( + + {/* 난이도 뱃지 (카드 우상단) */} + + + {/* 수집 완료 표시 */} + {isCollected && ( + + + + )} + + + {/* 단어 */} + + {keyword.word} + + + {/* 뜻 */} + + {isKorean && keyword.meaningKo ? keyword.meaningKo : keyword.meaning} + + + {/* 영어 정의 (한국어 모드일 때) */} + {isKorean && keyword.meaningKo && keyword.meaning && ( + + {keyword.meaning} + + )} + + {/* 예문 (있을 경우) */} + {keyword.example && ( + + + "{keyword.example}" + + + )} + + {/* 수집 버튼 */} + !isCollected && handleCollectWord(keyword)} + sx={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: 0.5, + py: 1, + borderRadius: '8px', + cursor: isCollected ? 'default' : 'pointer', + backgroundColor: isCollected ? '#D1FAE5' : '#F3F4F6', + color: isCollected ? '#059669' : '#6B7280', + transition: 'all 0.2s ease', + '&:hover': !isCollected && { + backgroundColor: '#E5E7EB', + }, + }} + > + {isCollected ? ( + <> + + + {isKorean ? '수집됨' : 'Collected'} + + + ) : ( + <> + + + {isKorean ? '단어 수집' : 'Collect'} + + + )} + + + + ) + })} + + + )} + + + + {/* Actions */} + + + + + + + {/* Read Complete Button */} + + + {/* Snackbar */} + setSnackbar(prev => ({ ...prev, open: false }))} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + + {snackbar.message} + + + + ) +} + +export default NewsDetailPage diff --git a/src/domains/news/pages/NewsListPage.jsx b/src/domains/news/pages/NewsListPage.jsx new file mode 100644 index 0000000..5d70748 --- /dev/null +++ b/src/domains/news/pages/NewsListPage.jsx @@ -0,0 +1,435 @@ +import { useState, useEffect, useCallback } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Box, + Card, + CardContent, + CardMedia, + Chip, + CircularProgress, + Container, + FormControl, + Grid, + IconButton, + InputAdornment, + MenuItem, + Select, + Skeleton, + TextField, + Typography, +} from '@mui/material' +import { + Bookmark as BookmarkIcon, + BookmarkBorder as BookmarkBorderIcon, + Newspaper as NewsIcon, + Search as SearchIcon, + Visibility as ViewIcon, +} from '@mui/icons-material' +import { useSettings } from '../../../contexts/SettingsContext' +import { getNewsList, NEWS_CATEGORIES, LEVEL_COLORS, toggleBookmark } from '../services/newsService' + +const NewsListPage = () => { + const navigate = useNavigate() + const { t, isKorean } = useSettings() + + const [articles, setArticles] = useState([]) + const [loading, setLoading] = useState(true) + const [loadingMore, setLoadingMore] = useState(false) + const [hasMore, setHasMore] = useState(true) + const [cursor, setCursor] = useState(null) + const [error, setError] = useState(null) + + // Filters + const [levelFilter, setLevelFilter] = useState('') + const [categoryFilter, setCategoryFilter] = useState('') + const [searchQuery, setSearchQuery] = useState('') + + // 북마크 상태 + const [bookmarkedIds, setBookmarkedIds] = useState(new Set()) + + const fetchNews = useCallback(async (isLoadMore = false) => { + try { + if (isLoadMore) { + setLoadingMore(true) + } else { + setLoading(true) + setCursor(null) + } + + const response = await getNewsList({ + level: levelFilter || undefined, + category: categoryFilter || undefined, + limit: 10, + cursor: isLoadMore ? cursor : undefined, + }) + + const newArticles = response.data?.articles || [] + + // 북마크 상태 초기화 (API에서 isBookmarked 제공) + const bookmarked = new Set() + newArticles.forEach(article => { + if (article.isBookmarked) { + bookmarked.add(article.articleId) + } + }) + + if (isLoadMore) { + setArticles(prev => [...prev, ...newArticles]) + setBookmarkedIds(prev => new Set([...prev, ...bookmarked])) + } else { + setArticles(newArticles) + setBookmarkedIds(bookmarked) + } + + setCursor(response.data?.nextCursor || null) + setHasMore(response.data?.hasMore || false) + setError(null) + } catch (err) { + console.error('Failed to fetch news:', err) + setError(err.message) + } finally { + setLoading(false) + setLoadingMore(false) + } + }, [levelFilter, categoryFilter, cursor]) + + useEffect(() => { + fetchNews(false) + }, [levelFilter, categoryFilter]) + + // 무한 스크롤 + useEffect(() => { + const handleScroll = () => { + if ( + window.innerHeight + document.documentElement.scrollTop + >= document.documentElement.offsetHeight - 100 + && hasMore + && !loadingMore + && !loading + ) { + fetchNews(true) + } + } + + window.addEventListener('scroll', handleScroll) + return () => window.removeEventListener('scroll', handleScroll) + }, [hasMore, loadingMore, loading, fetchNews]) + + const handleBookmark = async (e, articleId) => { + e.stopPropagation() + try { + await toggleBookmark(articleId) + setBookmarkedIds(prev => { + const newSet = new Set(prev) + if (newSet.has(articleId)) { + newSet.delete(articleId) + } else { + newSet.add(articleId) + } + return newSet + }) + } catch (err) { + console.error('Failed to toggle bookmark:', err) + } + } + + const getLevelLabel = (level) => { + const labels = { + BEGINNER: isKorean ? '초급' : 'Beginner', + INTERMEDIATE: isKorean ? '중급' : 'Intermediate', + ADVANCED: isKorean ? '고급' : 'Advanced', + } + return labels[level] || level + } + + const getCategoryLabel = (categoryId) => { + const category = NEWS_CATEGORIES.find(c => c.id === categoryId) + return category ? (isKorean ? category.label : category.labelEn) : categoryId + } + + const getCategoryColor = (categoryId) => { + const category = NEWS_CATEGORIES.find(c => c.id === categoryId) + return category?.color || '#6b7280' + } + + const filteredArticles = articles.filter(article => { + if (!searchQuery) return true + return ( + article.title?.toLowerCase().includes(searchQuery.toLowerCase()) || + article.summary?.toLowerCase().includes(searchQuery.toLowerCase()) + ) + }) + + const formatDate = (dateString) => { + const date = new Date(dateString) + const now = new Date() + const diff = now - date + const hours = Math.floor(diff / (1000 * 60 * 60)) + const days = Math.floor(hours / 24) + + if (hours < 1) return isKorean ? '방금 전' : 'Just now' + if (hours < 24) return isKorean ? `${hours}시간 전` : `${hours}h ago` + if (days < 7) return isKorean ? `${days}일 전` : `${days}d ago` + return date.toLocaleDateString() + } + + return ( + + {/* Header */} + + + + + + + + {isKorean ? '뉴스 영어 학습' : 'News English Learning'} + + + {isKorean ? '실제 뉴스로 영어 실력을 키워보세요' : 'Improve your English with real news'} + + + + + + {/* Filters */} + + + + + + + + + + setSearchQuery(e.target.value)} + sx={{ + flex: 1, + minWidth: 200, + '& .MuiOutlinedInput-root': { borderRadius: '12px' }, + }} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + {/* Error State */} + {error && ( + + {error} + + )} + + {/* News Grid */} + + {loading ? ( + // Skeleton loading + [...Array(6)].map((_, i) => ( + + + + + + + + + + + )) + ) : filteredArticles.length === 0 ? ( + + + + + {isKorean ? '뉴스가 없습니다' : 'No news articles found'} + + + + ) : ( + filteredArticles.map((article) => { + const levelColor = LEVEL_COLORS[article.level] || LEVEL_COLORS.INTERMEDIATE + const isBookmarked = bookmarkedIds.has(article.articleId) + + return ( + + navigate(`/news/${article.articleId}`)} + sx={{ + borderRadius: '16px', + height: '100%', + cursor: 'pointer', + transition: 'all 0.2s ease', + '&:hover': { + transform: 'translateY(-4px)', + boxShadow: '0 12px 24px -8px rgba(0,0,0,0.15)', + }, + }} + > + {/* Image */} + + + + {/* Tags */} + + + + + + {/* Title */} + + {article.title} + + + {/* Summary */} + + {article.summary} + + + {/* Meta */} + + + + {article.source} + + + {formatDate(article.publishedAt)} + + + + + {article.readCount} + + + + + handleBookmark(e, article.articleId)} + sx={{ color: isBookmarked ? '#f97316' : 'text.secondary' }} + > + {isBookmarked ? : } + + + + + + ) + }) + )} + + + {/* Load More Indicator */} + {loadingMore && ( + + + + )} + + {/* No More Items */} + {!hasMore && articles.length > 0 && ( + + + {isKorean ? '모든 뉴스를 불러왔습니다' : 'All news loaded'} + + + )} + + ) +} + +export default NewsListPage diff --git a/src/domains/news/pages/NewsQuizPage.jsx b/src/domains/news/pages/NewsQuizPage.jsx new file mode 100644 index 0000000..507f14f --- /dev/null +++ b/src/domains/news/pages/NewsQuizPage.jsx @@ -0,0 +1,463 @@ +import { useState, useEffect, useRef } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Container, + LinearProgress, + Typography, +} from '@mui/material' +import { + ArrowBack as BackIcon, + ArrowForward as NextIcon, + CheckCircle as CorrectIcon, + Cancel as WrongIcon, + Timer as TimerIcon, +} from '@mui/icons-material' +import { useSettings } from '../../../contexts/SettingsContext' +import { getQuiz, submitQuiz } from '../services/newsService' + +const QUIZ_TYPE_LABELS = { + COMPREHENSION: { ko: '독해력', en: 'Comprehension' }, + WORD_MATCH: { ko: '단어 매칭', en: 'Word Match' }, + FILL_BLANK: { ko: '빈칸 채우기', en: 'Fill Blank' }, +} + +const NewsQuizPage = () => { + const { articleId } = useParams() + const navigate = useNavigate() + const { isKorean } = useSettings() + const timerRef = useRef(null) + + const [quizData, setQuizData] = useState(null) + const [loading, setLoading] = useState(true) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const [currentQuestion, setCurrentQuestion] = useState(0) + const [answers, setAnswers] = useState({}) + const [selectedAnswer, setSelectedAnswer] = useState(null) + const [showFeedback, setShowFeedback] = useState(false) + const [timeElapsed, setTimeElapsed] = useState(0) + const [quizResult, setQuizResult] = useState(null) + + useEffect(() => { + const fetchQuiz = async () => { + try { + setLoading(true) + const response = await getQuiz(articleId) + setQuizData(response.data) + setError(null) + } catch (err) { + console.error('Failed to fetch quiz:', err) + setError(err.message) + } finally { + setLoading(false) + } + } + + if (articleId) { + fetchQuiz() + } + }, [articleId]) + + // Timer + useEffect(() => { + if (!loading && !quizResult) { + timerRef.current = setInterval(() => { + setTimeElapsed(prev => prev + 1) + }, 1000) + } + + return () => { + if (timerRef.current) { + clearInterval(timerRef.current) + } + } + }, [loading, quizResult]) + + const handleSelectAnswer = (answer) => { + if (showFeedback) return + setSelectedAnswer(answer) + } + + const handleNext = () => { + if (!selectedAnswer) return + + const question = quizData.questions[currentQuestion] + setAnswers(prev => ({ + ...prev, + [question.questionId]: selectedAnswer, + })) + + if (currentQuestion < quizData.questions.length - 1) { + setCurrentQuestion(prev => prev + 1) + setSelectedAnswer(answers[quizData.questions[currentQuestion + 1]?.questionId] || null) + setShowFeedback(false) + } + } + + const handleSubmit = async () => { + if (!selectedAnswer) return + + const question = quizData.questions[currentQuestion] + const finalAnswers = { + ...answers, + [question.questionId]: selectedAnswer, + } + + const answersArray = Object.entries(finalAnswers).map(([questionId, answer]) => ({ + questionId, + answer, + })) + + try { + setSubmitting(true) + clearInterval(timerRef.current) + + const response = await submitQuiz(articleId, answersArray, timeElapsed) + setQuizResult(response.data) + } catch (err) { + console.error('Failed to submit quiz:', err) + setError(err.message) + } finally { + setSubmitting(false) + } + } + + const formatTime = (seconds) => { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` + } + + if (loading) { + return ( + + + + ) + } + + if (error || !quizData) { + return ( + + {error || 'Quiz not found'} + + + ) + } + + // 퀴즈 결과 화면 + if (quizResult) { + return ( + + + + {isKorean ? '퀴즈 완료!' : 'Quiz Complete!'} + + + {/* Score Circle */} + = 80 + ? 'linear-gradient(135deg, #10B981 0%, #34D399 100%)' + : quizResult.score >= 60 + ? 'linear-gradient(135deg, #F59E0B 0%, #FBBF24 100%)' + : 'linear-gradient(135deg, #EF4444 0%, #F87171 100%)', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + mx: 'auto', + my: 4, + boxShadow: '0 12px 24px -8px rgba(0,0,0,0.2)', + }} + > + + {quizResult.score} + + + / {quizResult.totalPoints} + + + + {/* Stats */} + + + + {quizResult.results.filter(r => r.correct).length} + + + {isKorean ? '정답' : 'Correct'} + + + + + {quizResult.results.filter(r => !r.correct).length} + + + {isKorean ? '오답' : 'Incorrect'} + + + + + {formatTime(timeElapsed)} + + + {isKorean ? '소요 시간' : 'Time'} + + + + + {/* New Badges */} + {quizResult.newBadges?.length > 0 && ( + + + + {isKorean ? '새 뱃지 획득!' : 'New Badge Earned!'} + + {quizResult.newBadges.map((badge, index) => ( + + 🏆 + + {badge.name} + + {badge.description} + + + + ))} + + + )} + + + {/* Results Detail */} + + {isKorean ? '문제별 결과' : 'Question Results'} + + + {quizResult.results.map((result, index) => ( + + + {result.correct ? ( + + ) : ( + + )} + + + Q{index + 1} + + + {result.correctAnswer} + + {!result.correct && result.userAnswer && ( + + {isKorean ? '내 답:' : 'Your answer:'} {result.userAnswer} + + )} + + + + ))} + + + {/* Actions */} + + + + + + ) + } + + const question = quizData.questions[currentQuestion] + const progress = ((currentQuestion + 1) / quizData.questions.length) * 100 + const earnedPoints = Object.keys(answers).length * 20 + + return ( + + {/* Header */} + + + + + {currentQuestion + 1} / {quizData.questions.length} + + + + + {formatTime(timeElapsed)} + + + + {/* Progress */} + + + + + + {earnedPoints} / {quizData.totalPoints} {isKorean ? '점' : 'pts'} + + + + {/* Question */} + + + + + + {question.question} + + + {/* Options */} + + {question.options.map((option, index) => { + const isSelected = selectedAnswer === option + const optionLabels = ['A', 'B', 'C', 'D'] + + return ( + handleSelectAnswer(option)} + sx={{ + borderRadius: '12px', + cursor: 'pointer', + border: '2px solid', + borderColor: isSelected ? '#3B82F6' : 'transparent', + backgroundColor: isSelected ? '#EFF6FF' : '#F9FAFB', + transition: 'all 0.2s ease', + '&:hover': { + borderColor: isSelected ? '#3B82F6' : '#E5E7EB', + backgroundColor: isSelected ? '#EFF6FF' : '#F3F4F6', + }, + }} + > + + + {optionLabels[index]} + + + {option} + + + + ) + })} + + + + + {/* Navigation */} + + {currentQuestion < quizData.questions.length - 1 ? ( + + ) : ( + + )} + + + ) +} + +export default NewsQuizPage diff --git a/src/domains/news/pages/NewsStatsPage.jsx b/src/domains/news/pages/NewsStatsPage.jsx new file mode 100644 index 0000000..b950f2a --- /dev/null +++ b/src/domains/news/pages/NewsStatsPage.jsx @@ -0,0 +1,308 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Box, + Button, + Card, + CardContent, + CircularProgress, + Container, + Grid, + LinearProgress, + Typography, +} from '@mui/material' +import { + ArrowBack as BackIcon, + BarChart as StatsIcon, + LocalFireDepartment as StreakIcon, + MenuBook as ReadIcon, + Quiz as QuizIcon, + LibraryBooks as WordsIcon, + Bookmark as BookmarkIcon, +} from '@mui/icons-material' +import { useSettings } from '../../../contexts/SettingsContext' +import { getNewsStats } from '../services/newsService' + +const NewsStatsPage = () => { + const navigate = useNavigate() + const { isKorean } = useSettings() + + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + const fetchStats = async () => { + try { + setLoading(true) + const response = await getNewsStats() + setStats(response.data) + setError(null) + } catch (err) { + console.error('Failed to fetch stats:', err) + setError(err.message) + } finally { + setLoading(false) + } + } + + fetchStats() + }, []) + + if (loading) { + return ( + + + + ) + } + + if (error) { + return ( + + {error} + + + ) + } + + const statCards = [ + { + icon: ReadIcon, + label: isKorean ? '읽은 기사' : 'Articles Read', + value: stats?.totalRead || 0, + subLabel: isKorean ? `오늘 ${stats?.todayRead || 0}개` : `Today: ${stats?.todayRead || 0}`, + color: '#3B82F6', + bgColor: '#EFF6FF', + }, + { + icon: QuizIcon, + label: isKorean ? '퀴즈 완료' : 'Quizzes Completed', + value: stats?.totalQuizzes || 0, + subLabel: isKorean ? `평균 ${stats?.averageQuizScore || 0}점` : `Avg: ${stats?.averageQuizScore || 0}pts`, + color: '#10B981', + bgColor: '#ECFDF5', + }, + { + icon: WordsIcon, + label: isKorean ? '수집 단어' : 'Words Collected', + value: stats?.totalWordsCollected || 0, + subLabel: isKorean ? '뉴스에서 수집' : 'From news', + color: '#8B5CF6', + bgColor: '#F5F3FF', + }, + { + icon: BookmarkIcon, + label: isKorean ? '북마크' : 'Bookmarks', + value: stats?.bookmarkCount || 0, + subLabel: isKorean ? '저장된 기사' : 'Saved articles', + color: '#F97316', + bgColor: '#FFF7ED', + }, + ] + + return ( + + {/* Header */} + + + + + + + + + + + + {isKorean ? '뉴스 학습 통계' : 'News Learning Stats'} + + + {isKorean ? '나의 학습 현황을 확인하세요' : 'Check your learning progress'} + + + + + + {/* Streak Card */} + + + + + + + {stats?.currentStreak || 0} + + + {isKorean ? '일 연속 학습 중!' : 'Day Streak!'} + + + {isKorean ? `최고 기록: ${stats?.longestStreak || 0}일` : `Best: ${stats?.longestStreak || 0} days`} + + + + + {/* Stats Grid */} + + {statCards.map((stat, index) => { + const Icon = stat.icon + return ( + + + + + + + + {stat.value} + + + {stat.label} + + + {stat.subLabel} + + + + + ) + })} + + + {/* Quiz Performance */} + + + + {isKorean ? '퀴즈 성과' : 'Quiz Performance'} + + + + + + {isKorean ? '평균 점수' : 'Average Score'} + + + {stats?.averageQuizScore || 0}% + + + + + + + + + + {stats?.perfectQuizzes || 0} + + + {isKorean ? '만점 횟수' : 'Perfect Scores'} + + + + + + + {stats?.totalQuizzes || 0} + + + {isKorean ? '총 퀴즈' : 'Total Quizzes'} + + + + + + + + {/* Last Activity */} + {stats?.lastReadDate && ( + + + + {isKorean ? '마지막 학습' : 'Last Activity'} + + + {new Date(stats.lastReadDate).toLocaleDateString()} + + + + )} + + ) +} + +export default NewsStatsPage diff --git a/src/domains/news/pages/NewsWordsPage.jsx b/src/domains/news/pages/NewsWordsPage.jsx new file mode 100644 index 0000000..281108e --- /dev/null +++ b/src/domains/news/pages/NewsWordsPage.jsx @@ -0,0 +1,303 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Container, + IconButton, + Tab, + Tabs, + Typography, + Snackbar, + Alert, +} from '@mui/material' +import { + ArrowBack as BackIcon, + CheckCircle as SyncedIcon, + Delete as DeleteIcon, + Sync as SyncIcon, + LibraryBooks as WordsIcon, +} from '@mui/icons-material' +import { useSettings } from '../../../contexts/SettingsContext' +import { getCollectedWords, deleteCollectedWord, syncToVocab } from '../services/newsService' + +const NewsWordsPage = () => { + const navigate = useNavigate() + const { isKorean } = useSettings() + + const [words, setWords] = useState([]) + const [stats, setStats] = useState({ totalWords: 0, syncedToVocab: 0 }) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [activeTab, setActiveTab] = useState(0) // 0: 전체, 1: 미연동, 2: 연동완료 + const [snackbar, setSnackbar] = useState({ open: false, message: '', severity: 'success' }) + + useEffect(() => { + fetchWords() + }, []) + + const fetchWords = async () => { + try { + setLoading(true) + const response = await getCollectedWords() + setWords(response.data?.words || []) + setStats(response.data?.stats || { totalWords: 0, syncedToVocab: 0 }) + setError(null) + } catch (err) { + console.error('Failed to fetch words:', err) + setError(err.message) + } finally { + setLoading(false) + } + } + + const handleDelete = async (word, articleId) => { + try { + await deleteCollectedWord(articleId, word) + setWords(prev => prev.filter(w => w.word !== word)) + setStats(prev => ({ ...prev, totalWords: prev.totalWords - 1 })) + setSnackbar({ + open: true, + message: isKorean ? '단어가 삭제되었습니다' : 'Word deleted', + severity: 'success', + }) + } catch (err) { + console.error('Failed to delete word:', err) + setSnackbar({ + open: true, + message: isKorean ? '삭제 실패' : 'Failed to delete', + severity: 'error', + }) + } + } + + const handleSync = async (word, articleId) => { + try { + await syncToVocab(word, articleId) + setWords(prev => + prev.map(w => + w.word === word ? { ...w, syncedToVocab: true } : w + ) + ) + setStats(prev => ({ ...prev, syncedToVocab: prev.syncedToVocab + 1 })) + setSnackbar({ + open: true, + message: isKorean ? 'Vocabulary에 연동되었습니다' : 'Synced to Vocabulary', + severity: 'success', + }) + } catch (err) { + console.error('Failed to sync word:', err) + setSnackbar({ + open: true, + message: isKorean ? '연동 실패' : 'Failed to sync', + severity: 'error', + }) + } + } + + const filteredWords = words.filter(word => { + if (activeTab === 0) return true + if (activeTab === 1) return !word.syncedToVocab + if (activeTab === 2) return word.syncedToVocab + return true + }) + + if (loading) { + return ( + + + + ) + } + + return ( + + {/* Header */} + + + + + + + + + + + + {isKorean ? '수집한 단어' : 'Collected Words'} + + + {stats.totalWords}{isKorean ? '개' : ' words'} | {stats.syncedToVocab}{isKorean ? '개 연동됨' : ' synced'} + + + + + + {/* Tabs */} + setActiveTab(newValue)} + sx={{ + mb: 3, + '& .MuiTab-root': { + borderRadius: '12px', + minHeight: 40, + textTransform: 'none', + fontWeight: 600, + }, + }} + > + + + + + + {/* Error State */} + {error && ( + + {error} + + )} + + {/* Empty State */} + {filteredWords.length === 0 && !error && ( + + + + {activeTab === 0 + ? (isKorean ? '수집한 단어가 없습니다' : 'No collected words') + : activeTab === 1 + ? (isKorean ? '미연동 단어가 없습니다' : 'No unsynced words') + : (isKorean ? '연동된 단어가 없습니다' : 'No synced words') + } + + + + )} + + {/* Words List */} + + {filteredWords.map((word, index) => ( + + + + + + + {word.word} + + + {word.pronunciation} + + {word.syncedToVocab && ( + } + label={isKorean ? '연동됨' : 'Synced'} + size="small" + sx={{ + backgroundColor: '#D1FAE5', + color: '#10B981', + fontWeight: 600, + }} + /> + )} + + + {word.meaning} + + + "{word.context}" + + + {isKorean ? '출처:' : 'From:'} {word.articleTitle} + + + + + {!word.syncedToVocab && ( + handleSync(word.word, word.articleId)} + sx={{ + backgroundColor: '#EFF6FF', + '&:hover': { backgroundColor: '#DBEAFE' }, + }} + > + + + )} + handleDelete(word.word, word.articleId)} + sx={{ + backgroundColor: '#FEF2F2', + '&:hover': { backgroundColor: '#FEE2E2' }, + }} + > + + + + + + + ))} + + + {/* Snackbar */} + setSnackbar(prev => ({ ...prev, open: false }))} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + + {snackbar.message} + + + + ) +} + +export default NewsWordsPage diff --git a/src/domains/news/services/newsService.js b/src/domains/news/services/newsService.js new file mode 100644 index 0000000..a417fd8 --- /dev/null +++ b/src/domains/news/services/newsService.js @@ -0,0 +1,251 @@ +/** + * News API Service + * 뉴스 영어 학습 관련 API 호출 + */ + +const API_URL = import.meta.env.VITE_API_URL + +/** + * API 요청 헬퍼 + */ +const fetchWithAuth = async (endpoint, options = {}) => { + const token = localStorage.getItem('accessToken') + const response = await fetch(`${API_URL}${endpoint}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(token && { Authorization: `Bearer ${token}` }), + ...options.headers, + }, + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error(error.message || 'API request failed') + } + + return response.json() +} + +/** + * 뉴스 목록 조회 + */ +export const getNewsList = async ({ level, category, limit = 10, cursor } = {}) => { + const params = new URLSearchParams() + if (level) params.append('level', level) + if (category) params.append('category', category) + if (limit) params.append('limit', limit) + if (cursor) params.append('cursor', cursor) + + const query = params.toString() ? `?${params.toString()}` : '' + return fetchWithAuth(`/news${query}`) +} + +/** + * 오늘의 뉴스 조회 + */ +export const getTodayNews = async ({ limit = 10, cursor } = {}) => { + const params = new URLSearchParams() + if (limit) params.append('limit', limit) + if (cursor) params.append('cursor', cursor) + + const query = params.toString() ? `?${params.toString()}` : '' + return fetchWithAuth(`/news/today${query}`) +} + +/** + * 추천 뉴스 조회 + */ +export const getRecommendedNews = async ({ limit = 10, cursor } = {}) => { + const params = new URLSearchParams() + if (limit) params.append('limit', limit) + if (cursor) params.append('cursor', cursor) + + const query = params.toString() ? `?${params.toString()}` : '' + return fetchWithAuth(`/news/recommended${query}`) +} + +/** + * 뉴스 상세 조회 + */ +export const getNewsDetail = async (articleId) => { + return fetchWithAuth(`/news/${articleId}`) +} + +/** + * 읽기 완료 기록 + */ +export const markAsRead = async (articleId) => { + return fetchWithAuth(`/news/${articleId}/read`, { + method: 'POST', + }) +} + +/** + * 북마크 토글 + */ +export const toggleBookmark = async (articleId) => { + return fetchWithAuth(`/news/${articleId}/bookmark`, { + method: 'POST', + }) +} + +/** + * 북마크 목록 조회 + */ +export const getBookmarks = async ({ limit = 20 } = {}) => { + const params = new URLSearchParams() + if (limit) params.append('limit', limit) + + const query = params.toString() ? `?${params.toString()}` : '' + return fetchWithAuth(`/news/bookmarks${query}`) +} + +/** + * TTS 오디오 URL 조회 + */ +export const getAudioUrl = async (articleId, voice = 'Joanna') => { + const params = new URLSearchParams({ voice }) + return fetchWithAuth(`/news/${articleId}/audio?${params.toString()}`) +} + +/** + * 퀴즈 조회 + */ +export const getQuiz = async (articleId) => { + return fetchWithAuth(`/news/${articleId}/quiz`) +} + +/** + * 퀴즈 제출 + */ +export const submitQuiz = async (articleId, answers, timeTaken) => { + return fetchWithAuth(`/news/${articleId}/quiz`, { + method: 'POST', + body: JSON.stringify({ answers, timeTaken }), + }) +} + +/** + * 퀴즈 기록 조회 + */ +export const getQuizHistory = async () => { + return fetchWithAuth('/news/quiz/history') +} + +/** + * 단어 수집 + * - 단어 수집 시 자동으로 단어장(user-words)에 NEWS 카테고리로 추가됨 + * - meaning, example은 기사 키워드에서 자동 추출됨 + */ +export const collectWord = async (articleId, word, context) => { + return fetchWithAuth(`/news/${articleId}/words`, { + method: 'POST', + body: JSON.stringify({ word, context }), + }) +} + +/** + * 수집 단어 목록 조회 + */ +export const getCollectedWords = async () => { + return fetchWithAuth('/news/words') +} + +/** + * 수집 단어 삭제 + */ +export const deleteCollectedWord = async (articleId, word) => { + return fetchWithAuth(`/news/${articleId}/words/${encodeURIComponent(word)}`, { + method: 'DELETE', + }) +} + +/** + * Vocabulary 연동 + */ +export const syncToVocab = async (word, articleId) => { + return fetchWithAuth(`/news/words/${encodeURIComponent(word)}/sync`, { + method: 'POST', + body: JSON.stringify({ articleId }), + }) +} + +/** + * 학습 통계 조회 + */ +export const getNewsStats = async () => { + return fetchWithAuth('/news/stats') +} + +/** + * 대시보드 통합 통계 조회 + * GET /stats/dashboard + * Response: { + * today: { wordsLearned, articlesRead, quizScore, studyTime }, + * overall: { totalWords, totalArticles, averageQuizScore, totalStudyTime }, + * weeklyProgress: [{ date, wordsLearned, articlesRead }], + * levelDistribution: { BEGINNER, INTERMEDIATE, ADVANCED } + * } + */ +export const getDashboardStats = async () => { + return fetchWithAuth('/stats/dashboard') +} + +/** + * 카테고리 목록 + */ +export const NEWS_CATEGORIES = [ + { id: 'WORLD', label: '국제', labelEn: 'World', color: '#14B8A6' }, + { id: 'POLITICS', label: '정치', labelEn: 'Politics', color: '#6366F1' }, + { id: 'BUSINESS', label: '경제', labelEn: 'Business', color: '#F59E0B' }, + { id: 'TECH', label: '기술', labelEn: 'Tech', color: '#3B82F6' }, + { id: 'SCIENCE', label: '과학', labelEn: 'Science', color: '#06B6D4' }, + { id: 'HEALTH', label: '건강', labelEn: 'Health', color: '#10B981' }, + { id: 'SPORTS', label: '스포츠', labelEn: 'Sports', color: '#EF4444' }, + { id: 'ENTERTAINMENT', label: '엔터테인먼트', labelEn: 'Entertainment', color: '#EC4899' }, + { id: 'LIFESTYLE', label: '라이프스타일', labelEn: 'Lifestyle', color: '#F97316' }, +] + +/** + * 난이도별 색상 + */ +export const LEVEL_COLORS = { + BEGINNER: { main: '#10B981', bg: '#D1FAE5' }, + INTERMEDIATE: { main: '#3B82F6', bg: '#DBEAFE' }, + ADVANCED: { main: '#8B5CF6', bg: '#EDE9FE' }, +} + +/** + * TTS 음성 옵션 + */ +export const TTS_VOICES = [ + { id: 'Joanna', label: 'Joanna (미국 여성)', accent: 'US' }, + { id: 'Matthew', label: 'Matthew (미국 남성)', accent: 'US' }, + { id: 'Ivy', label: 'Ivy (미국 아동)', accent: 'US' }, + { id: 'Amy', label: 'Amy (영국 여성)', accent: 'UK' }, + { id: 'Brian', label: 'Brian (영국 남성)', accent: 'UK' }, +] + +export default { + getNewsList, + getTodayNews, + getRecommendedNews, + getNewsDetail, + markAsRead, + toggleBookmark, + getBookmarks, + getAudioUrl, + getQuiz, + submitQuiz, + getQuizHistory, + collectWord, + getCollectedWords, + deleteCollectedWord, + syncToVocab, + getNewsStats, + getDashboardStats, + NEWS_CATEGORIES, + LEVEL_COLORS, + TTS_VOICES, +} diff --git a/src/domains/vocab/pages/WordListPage.jsx b/src/domains/vocab/pages/WordListPage.jsx index 8aa1e35..be858c3 100644 --- a/src/domains/vocab/pages/WordListPage.jsx +++ b/src/domains/vocab/pages/WordListPage.jsx @@ -19,6 +19,7 @@ import { Clear as ClearIcon, ErrorOutline as ErrorIcon, LibraryBooks as WordListIcon, + Newspaper as NewsIcon, Search as SearchIcon, Star as StarIcon, StarBorder as StarBorderIcon, @@ -57,10 +58,23 @@ export default function WordListPage() { const loadMoreRef = useRef(null) const initialFilter = searchParams.get('filter') + const initialCategory = searchParams.get('category') const [filterMode, setFilterMode] = useState(initialFilter || 'all') + const [categoryFilter, setCategoryFilter] = useState(initialCategory || 'all') const [searchText, setSearchText] = useState('') + // 카테고리 목록 + const categories = [ + { id: 'all', label: t('wordList.categoryAll'), color: '#059669' }, + { id: 'DAILY', label: '일상', labelEn: 'Daily', color: '#10B981' }, + { id: 'BUSINESS', label: '비즈니스', labelEn: 'Business', color: '#F59E0B' }, + { id: 'ACADEMIC', label: '학술', labelEn: 'Academic', color: '#8B5CF6' }, + { id: 'TRAVEL', label: '여행', labelEn: 'Travel', color: '#06B6D4' }, + { id: 'TECHNOLOGY', label: '기술', labelEn: 'Tech', color: '#3B82F6' }, + { id: 'NEWS', label: '뉴스', labelEn: 'News', color: '#EC4899', icon: NewsIcon }, + ] + const [userWords, setUserWords] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -86,6 +100,11 @@ export default function WordListPage() { cursor: reset ? undefined : cursor, } + // 카테고리 필터 + if (categoryFilter && categoryFilter !== 'all') { + params.category = categoryFilter + } + if (filterMode === 'bookmarked') { params.bookmarked = true } else if (filterMode === 'incorrect') { @@ -105,14 +124,14 @@ export default function WordListPage() { } finally { setLoading(false) } - }, [loading, cursor, filterMode]) + }, [loading, cursor, filterMode, categoryFilter]) useEffect(() => { setUserWords([]) setCursor(null) setHasMore(true) fetchUserWords(true) - }, [filterMode]) + }, [filterMode, categoryFilter]) useEffect(() => { if (loading || !hasMore) return @@ -285,7 +304,54 @@ export default function WordListPage() { }} /> - {/* 필터 탭 */} + {/* 카테고리 필터 */} + + + {t('wordList.category') || '카테고리'} + + + {categories.map((cat) => { + const isSelected = categoryFilter === cat.id + const IconComponent = cat.icon + + return ( + : undefined} + onClick={() => setCategoryFilter(cat.id)} + sx={{ + fontWeight: 600, + fontSize: 12, + backgroundColor: isSelected + ? (cat.color || '#059669') + : (isDark ? '#3f3f46' : '#F3F4F6'), + color: isSelected ? 'white' : (isDark ? '#a1a1aa' : '#6B7280'), + border: '2px solid', + borderColor: isSelected ? (cat.color || '#059669') : 'transparent', + transition: 'all 0.2s ease', + '&:hover': { + backgroundColor: isSelected + ? (cat.color || '#059669') + : (isDark ? '#52525b' : '#E5E7EB'), + }, + '& .MuiChip-icon': { + color: isSelected ? 'white' : (cat.color || '#6B7280'), + }, + }} + /> + ) + })} + + + + {/* 상태 필터 탭 */} )} + {word.category === 'NEWS' && ( + } + label="뉴스" + size="small" + sx={{ + height: 22, + fontSize: 11, + fontWeight: 600, + backgroundColor: '#FCE7F3', + color: '#EC4899', + '& .MuiChip-icon': { + color: '#EC4899', + }, + }} + /> + )} diff --git a/src/domains/vocab/services/vocabService.js b/src/domains/vocab/services/vocabService.js index 2426831..4f15560 100644 --- a/src/domains/vocab/services/vocabService.js +++ b/src/domains/vocab/services/vocabService.js @@ -415,15 +415,16 @@ export const userWordService = { * 나의 단어장 API - 북마크/오답 필터링 */ export const myWordService = { - // GET /user-words/review - 나의 단어 목록 (필터링) - getList: (userId, {bookmarked, incorrectOnly, limit = 20, cursor} = {}) => + // GET /user-words - 나의 단어 목록 (필터링) + // category: NEWS, DAILY, BUSINESS, ACADEMIC, TRAVEL, TECHNOLOGY + getList: (userId, {category, bookmarked, incorrectOnly, limit = 20, cursor} = {}) => withMock( () => vocabApi.get('/vocab/user-words', { - params: {userId, bookmarked, incorrectOnly, limit, cursor} + params: {userId, category, bookmarked, incorrectOnly, limit, cursor} }), { userWords: mockUserWords - .filter(w => (!bookmarked || w.bookmarked) && (!incorrectOnly || w.incorrectCount > 0)) + .filter(w => (!category || w.category === category) && (!bookmarked || w.bookmarked) && (!incorrectOnly || w.incorrectCount > 0)) .slice(0, limit), hasMore: false, } diff --git a/src/i18n/translations.js b/src/i18n/translations.js index 6ef4b88..5a8b7af 100644 --- a/src/i18n/translations.js +++ b/src/i18n/translations.js @@ -54,6 +54,8 @@ export const translations = { chatPeopleDesc: '다른 학습자와 대화', writingPractice: '작문 연습', writingPracticeDesc: '문법 교정 & 피드백', + newsLearning: '뉴스 학습', + newsLearningDesc: '실제 뉴스로 영어 학습', vocabLearn: '단어 외우기', vocabLearnDesc: '매일 55개 단어 학습', vocabTest: '시험 보기', @@ -103,6 +105,8 @@ export const translations = { quizDesc: '4지선다 테스트', wordListTitle: '단어장', wordListDesc: '내 모든 단어', + newsTitle: '뉴스 학습', + newsDesc: '실제 뉴스로 영어 학습', }, // Vocab Dashboard @@ -197,6 +201,8 @@ export const translations = { title: '나의 단어장', wordsCount: '개의 단어', searchPlaceholder: '단어 검색...', + category: '카테고리', + categoryAll: '전체', filterAll: '전체', filterBookmarked: '북마크', filterIncorrect: '틀린 단어', @@ -383,6 +389,68 @@ export const translations = { catchmindTitle: '캐치마인드', catchmindDesc: '그림 맞추기 게임', }, + + // News + news: { + title: '뉴스 영어 학습', + subtitle: '실제 뉴스로 영어 실력을 키워보세요', + todayNews: '오늘의 뉴스', + recommended: '추천 뉴스', + allNews: '전체 뉴스', + readArticle: '기사 읽기', + takeQuiz: '퀴즈 풀기', + collectWord: '단어 수집', + markAsRead: '읽기 완료', + readComplete: '읽기 완료됨', + bookmark: '북마크', + listen: '듣기', + keywords: '핵심 단어', + summary: '요약', + viewOriginal: '원문 보기', + quizComplete: '퀴즈 완료!', + score: '점수', + correct: '정답', + incorrect: '오답', + time: '소요 시간', + newBadge: '새 뱃지 획득!', + collectedWords: '수집한 단어', + syncToVocab: 'Vocabulary 연동', + synced: '연동됨', + notSynced: '미연동', + stats: '학습 통계', + articlesRead: '읽은 기사', + quizzesCompleted: '퀴즈 완료', + wordsCollected: '수집 단어', + streak: '연속 학습', + levels: { + BEGINNER: '초급', + INTERMEDIATE: '중급', + ADVANCED: '고급', + }, + categories: { + TECH: '기술', + BUSINESS: '비즈니스', + SPORTS: '스포츠', + ENTERTAINMENT: '엔터테인먼트', + WORLD: '세계', + CULTURE: '문화', + SCIENCE: '과학', + }, + }, + + // Recent Activity + recentActivity: { + title: '최근 활동', + noActivity: '아직 활동 기록이 없습니다', + startLearning: '학습을 시작해서 진도를 확인하세요', + todayWords: '오늘 외운 단어', + words: '개', + newsRead: '읽은 뉴스', + articles: '개', + quizScore: '퀴즈 점수', + streak: '연속 학습', + days: '일', + }, }, en: { @@ -435,6 +503,8 @@ export const translations = { chatPeopleDesc: 'Practice with learners', writingPractice: 'Composition', writingPracticeDesc: 'Grammar & feedback', + newsLearning: 'News Learning', + newsLearningDesc: 'Learn English with real news', vocabLearn: 'Learn Words', vocabLearnDesc: '55 words per day', vocabTest: 'Take Quiz', @@ -484,6 +554,8 @@ export const translations = { quizDesc: 'Multiple choice test', wordListTitle: 'Word List', wordListDesc: 'All your words', + newsTitle: 'News Learning', + newsDesc: 'Learn English with real news', }, // Vocab Dashboard @@ -578,6 +650,8 @@ export const translations = { title: 'My Word List', wordsCount: 'words', searchPlaceholder: 'Search words...', + category: 'Category', + categoryAll: 'All', filterAll: 'All', filterBookmarked: 'Bookmarked', filterIncorrect: 'Incorrect', @@ -764,6 +838,68 @@ export const translations = { catchmindTitle: 'Catchmind', catchmindDesc: 'Drawing guessing game', }, + + // News + news: { + title: 'News English Learning', + subtitle: 'Improve your English with real news', + todayNews: "Today's News", + recommended: 'Recommended', + allNews: 'All News', + readArticle: 'Read Article', + takeQuiz: 'Take Quiz', + collectWord: 'Collect Word', + markAsRead: 'Mark as Read', + readComplete: 'Read Complete', + bookmark: 'Bookmark', + listen: 'Listen', + keywords: 'Key Vocabulary', + summary: 'Summary', + viewOriginal: 'View Original', + quizComplete: 'Quiz Complete!', + score: 'Score', + correct: 'Correct', + incorrect: 'Incorrect', + time: 'Time', + newBadge: 'New Badge Earned!', + collectedWords: 'Collected Words', + syncToVocab: 'Sync to Vocabulary', + synced: 'Synced', + notSynced: 'Not Synced', + stats: 'Learning Stats', + articlesRead: 'Articles Read', + quizzesCompleted: 'Quizzes Completed', + wordsCollected: 'Words Collected', + streak: 'Streak', + levels: { + BEGINNER: 'Beginner', + INTERMEDIATE: 'Intermediate', + ADVANCED: 'Advanced', + }, + categories: { + TECH: 'Tech', + BUSINESS: 'Business', + SPORTS: 'Sports', + ENTERTAINMENT: 'Entertainment', + WORLD: 'World', + CULTURE: 'Culture', + SCIENCE: 'Science', + }, + }, + + // Recent Activity + recentActivity: { + title: 'Recent Activity', + noActivity: 'No activity yet', + startLearning: 'Start learning to see your progress', + todayWords: 'Words Today', + words: '', + newsRead: 'News Read', + articles: '', + quizScore: 'Quiz Score', + streak: 'Streak', + days: ' days', + }, }, } diff --git a/src/layouts/MainLayout/HorizontalNav/index.jsx b/src/layouts/MainLayout/HorizontalNav/index.jsx index 3e11a9f..0c6e3ff 100644 --- a/src/layouts/MainLayout/HorizontalNav/index.jsx +++ b/src/layouts/MainLayout/HorizontalNav/index.jsx @@ -10,6 +10,7 @@ import { LibraryBooks as WordListIcon, MenuBook as VocabIcon, Mic as SpeakingIcon, + Newspaper as NewsIcon, People as PeopleIcon, Quiz as QuizIcon, School as LearnIcon, @@ -82,13 +83,20 @@ const HorizontalNav = () => { path: '/writing', desc: t('sidebar.writingPracticeDesc') }, + { + id: 'news-learning', + label: t('sidebar.newsLearning'), + icon: NewsIcon, + path: '/news', + desc: t('sidebar.newsLearningDesc') + }, ], }, { id: 'vocab', label: t('sidebar.vocab'), icon: VocabIcon, - color: '#059669', + color: '#f97316', children: [ { id: 'vocab-daily', @@ -124,7 +132,7 @@ const HorizontalNav = () => { id: 'games', label: t('games.title'), icon: GameIcon, - color: '#8b5cf6', + color: '#06b6d4', children: [ { id: 'catchmind', diff --git a/src/layouts/MainLayout/Sidebar/index.jsx b/src/layouts/MainLayout/Sidebar/index.jsx index 2046c00..fa9dbc5 100644 --- a/src/layouts/MainLayout/Sidebar/index.jsx +++ b/src/layouts/MainLayout/Sidebar/index.jsx @@ -29,6 +29,7 @@ import { LibraryBooks as WordListIcon, MenuBook as VocabIcon, Mic as SpeakingIcon, + Newspaper as NewsIcon, People as PeopleIcon, Quiz as QuizIcon, School as LearnIcon, @@ -108,14 +109,21 @@ const Sidebar = ({open, collapsed, onToggleCollapse, onClose}) => { path: '/writing', description: t('sidebar.writingPracticeDesc'), }, + { + id: 'news-learning', + label: t('sidebar.newsLearning'), + icon: NewsIcon, + path: '/news', + description: t('sidebar.newsLearningDesc'), + }, ], }, { id: 'vocab', label: t('sidebar.vocab'), icon: VocabIcon, - color: '#059669', - bgColor: '#ecfdf5', + color: '#f97316', + bgColor: '#fff7ed', children: [ { id: 'vocab-daily', @@ -151,8 +159,8 @@ const Sidebar = ({open, collapsed, onToggleCollapse, onClose}) => { id: 'games', label: t('games.title'), icon: GameIcon, - color: '#8b5cf6', - bgColor: '#f3e8ff', + color: '#06b6d4', + bgColor: '#ecfeff', children: [ { id: 'catchmind',