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 */}
+
+ }
+ onClick={() => navigate('/news')}
+ sx={{ color: 'text.secondary' }}
+ >
+ {isKorean ? '목록' : 'Back'}
+
+
+
+
+
+ {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 */}
+
+ }
+ onClick={() => navigate(`/news/${articleId}/quiz`)}
+ sx={{
+ flex: 1,
+ borderRadius: '12px',
+ py: 1.5,
+ background: 'linear-gradient(135deg, #3B82F6 0%, #60A5FA 100%)',
+ }}
+ >
+ {isKorean ? '퀴즈 풀기' : 'Take Quiz'}
+
+
+ }
+ onClick={() => window.open(article.originalUrl, '_blank')}
+ sx={{
+ flex: 1,
+ borderRadius: '12px',
+ py: 1.5,
+ }}
+ >
+ {isKorean ? '원문 보기' : 'View Original'}
+
+
+
+ {/* Read Complete Button */}
+ : null}
+ onClick={handleMarkAsRead}
+ disabled={isRead}
+ sx={{
+ mt: 2,
+ borderRadius: '12px',
+ py: 1.5,
+ backgroundColor: isRead ? 'transparent' : '#10B981',
+ borderColor: isRead ? '#10B981' : 'transparent',
+ color: isRead ? '#10B981' : 'white',
+ '&:hover': {
+ backgroundColor: isRead ? 'transparent' : '#059669',
+ },
+ }}
+ >
+ {isRead
+ ? (isKorean ? '읽기 완료됨' : 'Read Complete')
+ : (isKorean ? '읽기 완료' : 'Mark as Read')
+ }
+
+
+ {/* 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 */}
+
+ }
+ onClick={() => navigate(`/news/${articleId}`)}
+ sx={{ color: 'text.secondary' }}
+ >
+ {isKorean ? '종료' : 'Exit'}
+
+
+
+ {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 ? (
+ }
+ onClick={handleNext}
+ disabled={!selectedAnswer}
+ sx={{
+ borderRadius: '12px',
+ px: 6,
+ py: 1.5,
+ background: 'linear-gradient(135deg, #3B82F6 0%, #60A5FA 100%)',
+ }}
+ >
+ {isKorean ? '다음' : 'Next'}
+
+ ) : (
+
+ )}
+
+
+ )
+}
+
+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 */}
+
+ }
+ onClick={() => navigate('/news')}
+ sx={{ color: 'text.secondary' }}
+ >
+ {isKorean ? '뉴스 목록' : 'News List'}
+
+
+
+
+
+
+
+
+
+
+ {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 */}
+
+ }
+ onClick={() => navigate('/news')}
+ sx={{ color: 'text.secondary' }}
+ >
+ {isKorean ? '뉴스 목록' : 'News List'}
+
+
+
+
+
+
+
+
+
+
+ {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',