From bc58205fabe5fb390c443aa94c8af1db184a40c0 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Sat, 24 Jan 2026 01:31:20 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[Feat]=20:=20AI=20=ED=94=84=EB=A6=AC?= =?UTF-8?q?=ED=86=A0=ED=82=B9(Speaking)=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#195)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : AI 말하기 연습 화면 구현 * refactor : Rest API 변경에 따른 화면 구현 변경 * feature : AI와 대화하기 UI & STT서비스 구현 --- src/App.jsx | 161 +++++----- src/api/axios.js | 21 +- src/api/speakingApi.js | 41 +++ .../components/SpeakingChatMessage.jsx | 163 ++++++++++ .../speaking/components/SpeakingInput.jsx | 294 ++++++++++++++++++ .../speaking/constants/speakingConstants.js | 31 ++ src/domains/speaking/hooks/useSpeaking.js | 119 +++++++ src/domains/speaking/index.js | 15 + src/domains/speaking/pages/SpeakingPage.jsx | 222 +++++++++++++ src/domains/speaking/services/speakingApi.js | 68 ++++ .../speaking/services/speakingService.js | 29 ++ 11 files changed, 1075 insertions(+), 89 deletions(-) create mode 100644 src/api/speakingApi.js create mode 100644 src/domains/speaking/components/SpeakingChatMessage.jsx create mode 100644 src/domains/speaking/components/SpeakingInput.jsx create mode 100644 src/domains/speaking/constants/speakingConstants.js create mode 100644 src/domains/speaking/hooks/useSpeaking.js create mode 100644 src/domains/speaking/index.js create mode 100644 src/domains/speaking/pages/SpeakingPage.jsx create mode 100644 src/domains/speaking/services/speakingApi.js create mode 100644 src/domains/speaking/services/speakingService.js diff --git a/src/App.jsx b/src/App.jsx index dab5d55..d5f3895 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,6 +1,6 @@ -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 { 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 { ChevronRight as ChevronRightIcon, Create as WritingCategoryIcon, @@ -19,6 +19,7 @@ import { } from '@mui/icons-material' import MainLayout from './layouts/MainLayout' import FreetalkPeoplePage from './domains/freetalk/pages/FreetalkPeoplePage' +import { SpeakingPage } from './domains/speaking' import ChatRoomPage from './domains/freetalk/pages/ChatRoomPage' import ChatRoomModal from './domains/freetalk/components/ChatRoomModal' import VocabDashboard from './domains/vocab/pages/VocabDashboard' @@ -26,8 +27,8 @@ import DailyLearning from './domains/vocab/pages/DailyLearning' import TestPage from './domains/vocab/pages/TestPage' import WordListPage from './domains/vocab/pages/WordListPage' import StatsPage from './domains/vocab/pages/StatsPage' -import {WritingPage} from './domains/grammar' -import {BadgeSection} from './domains/badge' +import { WritingPage } from './domains/grammar' +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' @@ -41,8 +42,8 @@ import LoginPage from './pages/Login' import SignUpPage from './pages/SignUp' -function ProtectedRoute({children}) { - const {isAuthenticated, isLoading} = useAuth() +function ProtectedRoute({ children }) { + const { isAuthenticated, isLoading } = useAuth() if (isLoading) { return ( @@ -52,21 +53,21 @@ function ProtectedRoute({children}) { alignItems: 'center', justifyContent: 'center' }}> - + ) } if (!isAuthenticated) { - return + return } return children } // 이미 로그인된 경우 대시보드로 -function PublicRoute({children}) { - const {isAuthenticated, isLoading} = useAuth() +function PublicRoute({ children }) { + const { isAuthenticated, isLoading } = useAuth() if (isLoading) { return ( @@ -76,13 +77,13 @@ function PublicRoute({children}) { alignItems: 'center', justifyContent: 'center' }}> - + ) } if (isAuthenticated) { - return + return } return children @@ -92,6 +93,7 @@ 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) @@ -286,9 +288,9 @@ function Dashboard() { } return ( - + {/* Header */} - + - + - + {t('dashboard.greeting')} @@ -323,7 +325,7 @@ function Dashboard() { const hasChildren = mode.children && mode.children.length > 0 return ( - + handleCardHover(mode.id)} onMouseLeave={handleCardLeave} @@ -344,8 +346,8 @@ function Dashboard() { minHeight: isExpanded ? 'auto' : 140, }} > - - + + {/* Icon */} - + {/* Text */} - + )} - + {mode.description} @@ -441,14 +443,14 @@ function Dashboard() { boxShadow: '0 2px 8px -2px rgba(0,0,0,0.1)', }} > - + + sx={{ mb: 0.5 }}> {child.title} + sx={{ lineHeight: 1.3 }}> {child.description} @@ -655,22 +657,13 @@ function Dashboard() { // Placeholder Pages function OpicPage() { return ( - + OPIC Practice Level-based training ) } -function FreetalkAiPage() { - return ( - - AI Conversation - Free conversation with AI - - ) -} - function ReportsPage() { const {isKorean} = useSettings() @@ -735,9 +728,9 @@ function ReportsPage() { } return ( - + {/* 헤더 */} - + - + @@ -765,9 +758,9 @@ function ReportsPage() { {/* 통계 요약 카드 */} - - - + + + {isKorean ? '총 학습일' : 'Study Days'} @@ -779,8 +772,8 @@ function ReportsPage() { - - + + {isKorean ? '학습한 단어' : 'Words Learned'} @@ -792,8 +785,8 @@ function ReportsPage() { - - + + {isKorean ? '테스트 완료' : 'Tests Taken'} @@ -805,8 +798,8 @@ function ReportsPage() { - - + + {isKorean ? '평균 점수' : 'Average Score'} @@ -852,12 +845,12 @@ function ReportsPage() { )} {/* 연속 학습 */} - + {isKorean ? '연속 학습 기록' : 'Study Streak'} - + - + {/* 배지 섹션 */} - + ) } function SettingsPage() { - const {settings, setTtsVoice, setLanguage, t} = useSettings() + const { settings, setTtsVoice, setLanguage, t } = useSettings() const languageOptions = [ - {value: 'ko', label: '한국어', flag: '🇰🇷'}, - {value: 'en', label: 'English', flag: '🇺🇸'}, + { value: 'ko', label: '한국어', flag: '🇰🇷' }, + { value: 'en', label: 'English', flag: '🇺🇸' }, ] return ( - - + + - + - + {t('settings.title')} @@ -939,24 +932,24 @@ function SettingsPage() { {/* Language Settings */} - + - + {t('settings.language')} - + {t('settings.languageDesc')} - + {languageOptions.map((option) => ( - + setLanguage(option.value)} sx={{ @@ -974,7 +967,7 @@ function SettingsPage() { }, }} > - + {option.flag} {/* TTS Voice Settings */} - + - + {t('settings.ttsVoice')} - + {t('settings.ttsVoiceDesc')} - + - + setTtsVoice('FEMALE')} sx={{ @@ -1039,7 +1032,7 @@ function SettingsPage() { mb: 1.5, }} > - 👩 + 👩 - + setTtsVoice('MALE')} sx={{ @@ -1081,7 +1074,7 @@ function SettingsPage() { mb: 1.5, }} > - 👨 + 👨 @@ -1120,10 +1113,10 @@ function NotFound() { > 404 - + {t('notFound.title')} - + {t('notFound.message')} + + )} + + {/* 알림 목록 */} + {notifications.length === 0 ? ( + + + {isKorean ? '알림이 없습니다' : 'No notifications'} + + + ) : ( + + {notifications.map((notification) => { + const config = NotificationConfig[notification.type] || { + icon: '🔔', + color: '#6b7280', + bgColor: '#f3f4f6', + } + const message = getNotificationMessage(notification, isKorean) + const timeAgo = formatTimeAgo(notification.createdAt, isKorean) + + return ( + handleNotificationClick(notification)} + sx={{ + py: 2, + px: 2.5, + alignItems: 'flex-start', + backgroundColor: notification.isRead + ? 'transparent' + : alpha(config.color, 0.04), + '&:hover': { + backgroundColor: alpha(config.color, 0.08), + }, + }} + > + + {config.icon} + + + + + {message} + + + {timeAgo} + + + + {!notification.isRead && ( + + )} + + ) + })} + + )} + + ) +} + +export default NotificationMenu diff --git a/src/domains/notification/components/NotificationToast.jsx b/src/domains/notification/components/NotificationToast.jsx new file mode 100644 index 0000000..347e8f8 --- /dev/null +++ b/src/domains/notification/components/NotificationToast.jsx @@ -0,0 +1,123 @@ +import { Snackbar, Box, Typography, IconButton, alpha } from '@mui/material' +import { Close as CloseIcon } from '@mui/icons-material' +import { useSettings } from '../../../contexts/SettingsContext' +import { useNotificationContext } from '../contexts/NotificationContext' +import { NotificationConfig, getNotificationMessage, formatTimeAgo } from '../types/notificationTypes' + +const AUTO_HIDE_DURATION = 5000 + +export function NotificationToast() { + const { currentToast, closeToast } = useNotificationContext() + const { isKorean } = useSettings() + + if (!currentToast) return null + + const config = NotificationConfig[currentToast.type] || { + icon: '🔔', + color: '#6b7280', + bgColor: '#f3f4f6', + titleKo: '알림', + titleEn: 'Notification', + } + + const title = isKorean ? config.titleKo : config.titleEn + const message = getNotificationMessage(currentToast, isKorean) + + return ( + + + + {config.icon} + + + + + {title} + + + {message} + + + + + + + + + ) +} + +export default NotificationToast diff --git a/src/domains/notification/contexts/NotificationContext.jsx b/src/domains/notification/contexts/NotificationContext.jsx new file mode 100644 index 0000000..e03ba18 --- /dev/null +++ b/src/domains/notification/contexts/NotificationContext.jsx @@ -0,0 +1,137 @@ +import { createContext, useContext, useState, useCallback, useMemo, useEffect } from 'react' +import { useAuth } from '../../../contexts/AuthContext' +import { useNotifications } from '../hooks/useNotifications' +import { NotificationType, NotificationConfig, getNotificationMessage } from '../types/notificationTypes' + +const NotificationContext = createContext(null) + +const MAX_NOTIFICATIONS = 50 + +export function NotificationProvider({ children }) { + const { user, isAuthenticated } = useAuth() + const [notifications, setNotifications] = useState([]) + const [unreadCount, setUnreadCount] = useState(0) + const [toastQueue, setToastQueue] = useState([]) + const [currentToast, setCurrentToast] = useState(null) + + // 알림 수신 핸들러 + const handleNotification = useCallback((notification) => { + // 클라이언트에서 isRead 속성 추가 + const enrichedNotification = { + ...notification, + isRead: false, + receivedAt: new Date().toISOString(), + } + + // 알림 목록에 추가 (최신순, 최대 개수 제한) + setNotifications((prev) => { + const updated = [enrichedNotification, ...prev] + return updated.slice(0, MAX_NOTIFICATIONS) + }) + + // 읽지 않은 알림 카운트 증가 + setUnreadCount((prev) => prev + 1) + + // 토스트 큐에 추가 + setToastQueue((prev) => [...prev, enrichedNotification]) + }, []) + + // SSE 연결 + const userId = isAuthenticated && user?.username ? user.username : null + console.log('[NotificationContext] Auth state:', { isAuthenticated, user, userId }) + const { isConnected, isEnabled, error, reconnect } = useNotifications(userId, handleNotification) + + // 토스트 표시 로직 - 큐에서 하나씩 처리 + useEffect(() => { + if (toastQueue.length > 0 && !currentToast) { + const [next, ...rest] = toastQueue + setCurrentToast(next) + setToastQueue(rest) + } + }, [toastQueue, currentToast]) + + // 토스트 닫기 + const closeToast = useCallback(() => { + setCurrentToast(null) + }, []) + + // 알림 읽음 처리 + const markAsRead = useCallback((notificationId) => { + setNotifications((prev) => + prev.map((n) => + n.notificationId === notificationId ? { ...n, isRead: true } : n + ) + ) + setUnreadCount((prev) => Math.max(0, prev - 1)) + }, []) + + // 모든 알림 읽음 처리 + const markAllAsRead = useCallback(() => { + setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true }))) + setUnreadCount(0) + }, []) + + // 알림 삭제 + const removeNotification = useCallback((notificationId) => { + setNotifications((prev) => { + const notification = prev.find((n) => n.notificationId === notificationId) + if (notification && !notification.isRead) { + setUnreadCount((count) => Math.max(0, count - 1)) + } + return prev.filter((n) => n.notificationId !== notificationId) + }) + }, []) + + // 모든 알림 삭제 + const clearAllNotifications = useCallback(() => { + setNotifications([]) + setUnreadCount(0) + }, []) + + const value = useMemo( + () => ({ + notifications, + unreadCount, + isConnected, + isEnabled, + connectionError: error, + currentToast, + closeToast, + markAsRead, + markAllAsRead, + removeNotification, + clearAllNotifications, + reconnect, + }), + [ + notifications, + unreadCount, + isConnected, + isEnabled, + error, + currentToast, + closeToast, + markAsRead, + markAllAsRead, + removeNotification, + clearAllNotifications, + reconnect, + ] + ) + + return ( + + {children} + + ) +} + +export function useNotificationContext() { + const context = useContext(NotificationContext) + if (!context) { + throw new Error('useNotificationContext must be used within NotificationProvider') + } + return context +} + +export { NotificationType, NotificationConfig, getNotificationMessage } diff --git a/src/domains/notification/hooks/useNotifications.js b/src/domains/notification/hooks/useNotifications.js new file mode 100644 index 0000000..0948050 --- /dev/null +++ b/src/domains/notification/hooks/useNotifications.js @@ -0,0 +1,173 @@ +import { useEffect, useCallback, useRef, useState } from 'react' + +const NOTIFICATION_URL = import.meta.env.VITE_NOTIFICATION_URL +const NOTIFICATION_ENABLED = import.meta.env.VITE_NOTIFICATION_ENABLED === 'true' + +const MAX_RETRY_COUNT = 5 +const RETRY_DELAY_MS = 3000 + +/** + * SSE를 통한 실시간 알림 연결 훅 + * @param {string|null} userId - 로그인한 사용자 ID + * @param {function} onNotification - 알림 수신 시 콜백 + * @returns {{ isConnected: boolean, error: string|null, disconnect: function, reconnect: function }} + */ +export function useNotifications(userId, onNotification) { + const eventSourceRef = useRef(null) + const retryCountRef = useRef(0) + const retryTimeoutRef = useRef(null) + + const [isConnected, setIsConnected] = useState(false) + const [error, setError] = useState(null) + + const disconnect = useCallback(() => { + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current) + retryTimeoutRef.current = null + } + + if (eventSourceRef.current) { + eventSourceRef.current.close() + eventSourceRef.current = null + } + + setIsConnected(false) + }, []) + + const connect = useCallback(() => { + console.log('[Notifications] connect() called', { userId, NOTIFICATION_URL, NOTIFICATION_ENABLED }) + + if (!NOTIFICATION_ENABLED) { + console.log('[Notifications] Disabled by VITE_NOTIFICATION_ENABLED flag') + return + } + + if (!userId) { + console.warn('[Notifications] Missing userId') + return + } + + if (!NOTIFICATION_URL) { + console.warn('[Notifications] Missing NOTIFICATION_URL env variable') + return + } + + // 기존 연결 정리 + disconnect() + + const url = `${NOTIFICATION_URL}?userId=${encodeURIComponent(userId)}` + console.log('[Notifications] Connecting to:', url) + + try { + const eventSource = new EventSource(url) + eventSourceRef.current = eventSource + + eventSource.onopen = () => { + console.log('[Notifications] 연결됨!') + setIsConnected(true) + setError(null) + retryCountRef.current = 0 + } + + eventSource.onmessage = (event) => { + console.log('[Notifications] 알림:', event.data) + + // Heartbeat 무시 + if (event.data === 'HEARTBEAT') { + return + } + + // Stream end 처리 + if (event.data === 'STREAM_END') { + console.log('[Notifications] Stream ended, reconnecting...') + scheduleReconnect() + return + } + + try { + const notification = JSON.parse(event.data) + console.log('[Notifications] Parsed:', notification) + onNotification?.(notification) + } catch (e) { + console.error('[Notifications] Failed to parse:', e, event.data) + } + } + + eventSource.onerror = (err) => { + console.log('[Notifications] 에러:', err) + console.log('[Notifications] readyState:', eventSource.readyState) + setIsConnected(false) + + // EventSource가 자동으로 재연결을 시도하지만, + // 연속 실패 시 수동 재연결 로직 적용 + if (eventSource.readyState === EventSource.CLOSED) { + scheduleReconnect() + } + } + } catch (err) { + console.error('[Notifications] Failed to create EventSource:', err) + setError('알림 연결에 실패했습니다.') + scheduleReconnect() + } + }, [userId, onNotification, disconnect]) + + const scheduleReconnect = useCallback(() => { + if (retryCountRef.current >= MAX_RETRY_COUNT) { + setError('알림 서버 연결에 실패했습니다. 새로고침해주세요.') + console.error('[Notifications] Max retry count reached') + return + } + + retryCountRef.current += 1 + const delay = RETRY_DELAY_MS * retryCountRef.current + + console.log(`[Notifications] Retry ${retryCountRef.current}/${MAX_RETRY_COUNT} in ${delay}ms`) + + retryTimeoutRef.current = setTimeout(() => { + connect() + }, delay) + }, [connect]) + + // userId 변경 시 연결/해제 + // StrictMode에서 중복 연결 방지를 위한 debounce + useEffect(() => { + let mounted = true + let connectTimeout = null + + if (userId) { + // 100ms 지연으로 StrictMode 중복 호출 방지 + connectTimeout = setTimeout(() => { + if (mounted) { + connect() + } + }, 100) + } else { + disconnect() + } + + return () => { + mounted = false + if (connectTimeout) { + clearTimeout(connectTimeout) + } + disconnect() + } + }, [userId, connect, disconnect]) + + const reconnect = useCallback(() => { + retryCountRef.current = 0 + setError(null) + connect() + }, [connect]) + + return { + isConnected, + isEnabled: NOTIFICATION_ENABLED, + error, + disconnect, + reconnect, + } +} + +export { NOTIFICATION_ENABLED } +export default useNotifications diff --git a/src/domains/notification/index.js b/src/domains/notification/index.js new file mode 100644 index 0000000..c67fbf5 --- /dev/null +++ b/src/domains/notification/index.js @@ -0,0 +1,17 @@ +// Context +export { NotificationProvider, useNotificationContext } from './contexts/NotificationContext' + +// Components +export { NotificationToast } from './components/NotificationToast' +export { NotificationMenu } from './components/NotificationMenu' + +// Hooks +export { useNotifications } from './hooks/useNotifications' + +// Types & Utils +export { + NotificationType, + NotificationConfig, + getNotificationMessage, + formatTimeAgo, +} from './types/notificationTypes' diff --git a/src/domains/notification/types/notificationTypes.js b/src/domains/notification/types/notificationTypes.js new file mode 100644 index 0000000..7b40be5 --- /dev/null +++ b/src/domains/notification/types/notificationTypes.js @@ -0,0 +1,238 @@ +/** + * 알림 타입 상수 + * @enum {string} + */ +export const NotificationType = { + BADGE_EARNED: 'BADGE_EARNED', + DAILY_COMPLETE: 'DAILY_COMPLETE', + STREAK_REMINDER: 'STREAK_REMINDER', + TEST_COMPLETE: 'TEST_COMPLETE', + NEWS_QUIZ_COMPLETE: 'NEWS_QUIZ_COMPLETE', + GAME_END: 'GAME_END', + GAME_STREAK: 'GAME_STREAK', + OPIC_COMPLETE: 'OPIC_COMPLETE', +} + +/** + * @typedef {Object} BadgeEarnedPayload + * @property {string} badgeType - 배지 타입 코드 + * @property {string} badgeName - 배지 이름 + * @property {string} description - 배지 설명 + * @property {string} iconUrl - 배지 아이콘 URL + */ + +/** + * @typedef {Object} DailyCompletePayload + * @property {string} date - 학습 완료 날짜 (YYYY-MM-DD) + * @property {number} wordsLearned - 오늘 학습한 단어 수 + * @property {number} totalWords - 총 학습 단어 수 + * @property {number} currentStreak - 현재 연속 학습 일수 + */ + +/** + * @typedef {Object} StreakReminderPayload + * @property {number} currentStreak - 현재 연속 학습 일수 + * @property {string} message - 리마인더 메시지 + */ + +/** + * @typedef {Object} TestCompletePayload + * @property {string} testId - 테스트 ID + * @property {number} score - 점수 (0-100) + * @property {number} correctCount - 맞힌 문제 수 + * @property {number} totalCount - 전체 문제 수 + * @property {boolean} isPerfect - 만점 여부 + */ + +/** + * @typedef {Object} NewsQuizCompletePayload + * @property {string} articleId - 뉴스 기사 ID + * @property {string} articleTitle - 기사 제목 + * @property {number} score - 점수 (0-100) + * @property {number} correctCount - 맞힌 문제 수 + * @property {number} totalCount - 전체 문제 수 + * @property {boolean} isPerfect - 만점 여부 + */ + +/** + * @typedef {Object} GameEndPayload + * @property {string} roomId - 게임 방 ID + * @property {string} gameSessionId - 게임 세션 ID + * @property {number} rank - 최종 순위 + * @property {number} totalPlayers - 전체 플레이어 수 + * @property {number} score - 획득 점수 + * @property {boolean} isWinner - 1등 여부 + */ + +/** + * @typedef {Object} GameStreakPayload + * @property {string} roomId - 게임 방 ID + * @property {number} streakCount - 연속 정답 횟수 + * @property {number} bonusPoints - 보너스 점수 + */ + +/** + * @typedef {Object} OpicCompletePayload + * @property {string} sessionId - 세션 ID + * @property {string} estimatedLevel - 예상 등급 (IM1, IM2, IH, AL 등) + * @property {number} questionsAnswered - 답변한 문제 수 + * @property {string} feedbackSummary - 피드백 요약 + */ + +/** + * @typedef {Object} Notification + * @property {string} notificationId - 알림 ID ("notif-xxxxxxxx" 형식) + * @property {string} type - 알림 타입 + * @property {string} userId - 대상 사용자 ID + * @property {Object} payload - 타입별 상세 데이터 + * @property {string} createdAt - ISO-8601 형식 생성 시간 + * @property {boolean} [isRead] - 읽음 여부 (클라이언트 관리) + */ + +/** + * 알림 타입별 아이콘 및 스타일 설정 + */ +export const NotificationConfig = { + [NotificationType.BADGE_EARNED]: { + icon: '🏆', + color: '#f59e0b', + bgColor: '#fffbeb', + titleKo: '배지 획득!', + titleEn: 'Badge Earned!', + }, + [NotificationType.DAILY_COMPLETE]: { + icon: '✅', + color: '#10b981', + bgColor: '#ecfdf5', + titleKo: '오늘의 학습 완료!', + titleEn: 'Daily Learning Complete!', + }, + [NotificationType.STREAK_REMINDER]: { + icon: '⏰', + color: '#f97316', + bgColor: '#fff7ed', + titleKo: '학습 리마인더', + titleEn: 'Study Reminder', + }, + [NotificationType.TEST_COMPLETE]: { + icon: '📝', + color: '#8b5cf6', + bgColor: '#f5f3ff', + titleKo: '테스트 완료', + titleEn: 'Test Complete', + }, + [NotificationType.NEWS_QUIZ_COMPLETE]: { + icon: '📰', + color: '#06b6d4', + bgColor: '#ecfeff', + titleKo: '뉴스 퀴즈 완료', + titleEn: 'News Quiz Complete', + }, + [NotificationType.GAME_END]: { + icon: '🎮', + color: '#ec4899', + bgColor: '#fdf2f8', + titleKo: '게임 종료', + titleEn: 'Game Over', + }, + [NotificationType.GAME_STREAK]: { + icon: '🔥', + color: '#ef4444', + bgColor: '#fef2f2', + titleKo: '연속 정답!', + titleEn: 'Answer Streak!', + }, + [NotificationType.OPIC_COMPLETE]: { + icon: '🎤', + color: '#059669', + bgColor: '#ecfdf5', + titleKo: 'OPIc 연습 완료', + titleEn: 'OPIc Practice Complete', + }, +} + +/** + * 알림 payload에서 표시할 메시지 생성 + * @param {Notification} notification + * @param {boolean} isKorean + * @returns {string} + */ +export function getNotificationMessage(notification, isKorean = true) { + const { type, payload } = notification + + switch (type) { + case NotificationType.BADGE_EARNED: + return isKorean + ? `"${payload.badgeName}" 배지를 획득했습니다!` + : `You earned the "${payload.badgeName}" badge!` + + case NotificationType.DAILY_COMPLETE: + return isKorean + ? `${payload.wordsLearned}개 단어 학습 완료! (${payload.currentStreak}일 연속)` + : `${payload.wordsLearned} words learned! (${payload.currentStreak} day streak)` + + case NotificationType.STREAK_REMINDER: + return payload.message + + case NotificationType.TEST_COMPLETE: + return isKorean + ? `테스트 점수: ${payload.score}점 (${payload.correctCount}/${payload.totalCount})` + : `Test score: ${payload.score} (${payload.correctCount}/${payload.totalCount})` + + case NotificationType.NEWS_QUIZ_COMPLETE: + return isKorean + ? `"${payload.articleTitle}" 퀴즈 완료! ${payload.score}점` + : `"${payload.articleTitle}" quiz done! Score: ${payload.score}` + + case NotificationType.GAME_END: + return isKorean + ? `${payload.rank}등으로 게임 종료! (${payload.score}점)` + : `Game over! Rank: #${payload.rank} (${payload.score} pts)` + + case NotificationType.GAME_STREAK: + return isKorean + ? `${payload.streakCount}연속 정답! +${payload.bonusPoints}점 보너스` + : `${payload.streakCount} streak! +${payload.bonusPoints} bonus` + + case NotificationType.OPIC_COMPLETE: + return isKorean + ? `예상 등급: ${payload.estimatedLevel} (${payload.questionsAnswered}문제)` + : `Estimated level: ${payload.estimatedLevel} (${payload.questionsAnswered} questions)` + + default: + return isKorean ? '새 알림이 도착했습니다.' : 'New notification received.' + } +} + +/** + * 시간 경과 표시 포맷 + * @param {string} createdAt - ISO-8601 날짜 문자열 + * @param {boolean} isKorean + * @returns {string} + */ +export function formatTimeAgo(createdAt, isKorean = true) { + const now = new Date() + const created = new Date(createdAt) + const diffMs = now - created + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMs / 3600000) + const diffDays = Math.floor(diffMs / 86400000) + + if (diffMins < 1) { + return isKorean ? '방금 전' : 'Just now' + } + if (diffMins < 60) { + return isKorean ? `${diffMins}분 전` : `${diffMins}m ago` + } + if (diffHours < 24) { + return isKorean ? `${diffHours}시간 전` : `${diffHours}h ago` + } + if (diffDays < 7) { + return isKorean ? `${diffDays}일 전` : `${diffDays}d ago` + } + + return created.toLocaleDateString(isKorean ? 'ko-KR' : 'en-US', { + month: 'short', + day: 'numeric', + }) +} diff --git a/src/layouts/MainLayout/Header/index.jsx b/src/layouts/MainLayout/Header/index.jsx index f93dfa8..f52deba 100644 --- a/src/layouts/MainLayout/Header/index.jsx +++ b/src/layouts/MainLayout/Header/index.jsx @@ -5,7 +5,6 @@ import { Avatar, Badge, Box, - Chip, Divider, IconButton, Menu, @@ -29,6 +28,7 @@ import {useThemeMode} from '../../../contexts/ThemeContext' import {useSettings, useTranslation} from '../../../contexts/SettingsContext' import {LANGUAGE_LABELS, LANGUAGES} from '../../../i18n/translations' import {useAuth} from '../../../contexts/AuthContext' +import {useNotificationContext, NotificationMenu} from '../../../domains/notification' const Header = ({onMenuClick, sidebarOpen}) => { const theme = useTheme() @@ -42,6 +42,7 @@ const Header = ({onMenuClick, sidebarOpen}) => { const [notificationAnchor, setNotificationAnchor] = useState(null) const [langAnchor, setLangAnchor] = useState(null) const {logout} = useAuth() + const {unreadCount} = useNotificationContext() const handleProfileMenuOpen = (event) => { setAnchorEl(event.currentTarget) @@ -206,7 +207,7 @@ const Header = ({onMenuClick, sidebarOpen}) => { }} > { fontSize: 10, minWidth: 18, height: 18, + display: unreadCount > 0 ? 'flex' : 'none', }, }} > @@ -298,75 +300,11 @@ const Header = ({onMenuClick, sidebarOpen}) => { {/* Notification Menu */} - - - - {t('nav.notifications')} - - - - - {[ - { - text: language === 'ko' ? '면접 연습 세션이 완료되었습니다.' : 'Interview session completed.', - time: language === 'ko' ? '10분 전' : '10 min ago' - }, - { - text: language === 'ko' ? 'OPIC 모의고사 결과가 도착했습니다.' : 'OPIC test results arrived.', - time: language === 'ko' ? '1시간 전' : '1 hour ago' - }, - { - text: language === 'ko' ? '새로운 학습 리포트가 생성되었습니다.' : 'New learning report generated.', - time: language === 'ko' ? '어제' : 'Yesterday' - }, - ].map((item, index) => ( - - - - {item.text} - - - {item.time} - - - - ))} - + /> {/* Profile Menu */} - + + + + From 232f3c987f5148d42f512b89da0bf0fa5b4c3ae3 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Sat, 24 Jan 2026 11:40:44 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix=20:=20=EC=97=B0=EC=86=8D=20=EC=84=A0?= =?UTF-8?q?=EC=96=B8=EB=90=9C=20=EB=B3=80=EC=88=98=20t=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20(#199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : AI 말하기 연습 화면 구현 * refactor : Rest API 변경에 따른 화면 구현 변경 * feature : AI와 대화하기 UI & STT서비스 구현 * [Feat] : AI 프리토킹(Speaking) 기능 구현 (#195) (#196) * feat : AI 말하기 연습 화면 구현 * refactor : Rest API 변경에 따른 화면 구현 변경 * feature : AI와 대화하기 UI & STT서비스 구현 --- src/App.jsx | 109 ++++++++++++++++++++++++++-------------------------- 1 file changed, 55 insertions(+), 54 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index a603d00..3298967 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -32,12 +32,12 @@ 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' +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' import LoginPage from './pages/Login' import SignUpPage from './pages/SignUp' @@ -93,7 +93,8 @@ function PublicRoute({ children }) { function Dashboard() { const navigate = useNavigate() const [expandedCard, setExpandedCard] = useState(null) - const {t, isKorean} = useSettings() + const { t } = useSettings() + const { t, isKorean } = useSettings() const [activityData, setActivityData] = useState(null) const [loadingActivity, setLoadingActivity] = useState(true) @@ -466,14 +467,14 @@ function Dashboard() { {/* Today's Activity Stats */} - + {loadingActivity ? ( {[...Array(4)].map((_, i) => ( - - - - + + + + @@ -482,7 +483,7 @@ function Dashboard() { ) : ( {/* 오늘 외운 단어 */} - + navigate('/vocab')} sx={{ @@ -496,7 +497,7 @@ function Dashboard() { }, }} > - + - + {activityData?.todayWords || 0} @@ -527,7 +528,7 @@ function Dashboard() { {/* 읽은 뉴스 */} - + navigate('/news')} sx={{ @@ -541,7 +542,7 @@ function Dashboard() { }, }} > - + - + {activityData?.newsRead || 0} @@ -567,7 +568,7 @@ function Dashboard() { {/* 총 학습 단어 */} - + navigate('/vocab/words')} sx={{ @@ -581,7 +582,7 @@ function Dashboard() { }, }} > - + - + {activityData?.totalWords || 0} @@ -607,7 +608,7 @@ function Dashboard() { {/* 연속 학습 */} - + navigate('/reports')} sx={{ @@ -621,7 +622,7 @@ function Dashboard() { }, }} > - + - + {Math.max(activityData?.vocabStreak || 0, activityData?.newsStreak || 0)} @@ -665,7 +666,7 @@ function OpicPage() { function ReportsPage() { - const {isKorean} = useSettings() + const { isKorean } = useSettings() const [loading, setLoading] = useState(true) const [stats, setStats] = useState({ totalStudyDays: 0, @@ -684,7 +685,7 @@ function ReportsPage() { setLoading(true) const [vocabStatsRes, vocabHistoryRes, newsStatsRes] = await Promise.allSettled([ statsService.getOverall().catch(() => null), - statsService.getDaily(null, {limit: 30}).catch(() => null), + statsService.getDaily(null, { limit: 30 }).catch(() => null), getNewsStats().catch(() => null), ]) @@ -718,9 +719,9 @@ function ReportsPage() { if (loading) { return ( - + - + ) @@ -814,13 +815,13 @@ function ReportsPage() { {/* 뉴스 학습 통계 */} {(stats.newsRead > 0 || stats.newsQuizScore > 0) && ( - + {isKorean ? '뉴스 학습' : 'News Learning'} - - + + {stats.newsRead} @@ -829,8 +830,8 @@ function ReportsPage() { - - + + {stats.newsQuizScore}% @@ -1160,27 +1161,27 @@ function App() { }> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> {/* 404 */} From 05e792887bc0c0185acf0c77623920ce4e2c1a32 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Sat, 24 Jan 2026 11:51:10 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor=20:=20AI=20=EB=A7=90=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20routing=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : AI 말하기 연습 화면 구현 * refactor : Rest API 변경에 따른 화면 구현 변경 * feature : AI와 대화하기 UI & STT서비스 구현 * refactor : AI 말하기 라우팅 페이지 수정 --- src/App.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App.jsx b/src/App.jsx index 45f8415..e8a4800 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1164,7 +1164,7 @@ function App() { } /> } /> } /> - } /> + } /> } /> } /> } />