import React, { useState, useRef, useEffect } from 'react'; import ChatView from './views/ChatView'; import CreativeStudio from './views/CreativeStudio'; import SpeakingPracticeView from './views/SpeakingPracticeView'; import ReadingView from './views/ReadingView'; import TranslationView from './views/TranslationView'; import OCRView from './views/OCRView'; import ListeningView from './views/ListeningView'; import ToastContainer, { ToastMessage } from './components/Toast'; import ConfirmModal from './components/ConfirmModal'; import Onboarding from './components/Onboarding'; import { MessageCircle, Palette, Mic2, Settings, Globe, Sparkles, BookOpen, Languages, Download, Upload, FileText, X, ScanText, Key, Save, Trash2, Menu, BrainCircuit, Link, Headphones } from 'lucide-react'; import { AppMode, Language, ChatMessage, TranslationRecord, AppDataBackup, Role, MessageType, ReadingLessonRecord, AVAILABLE_CHAT_MODELS, ChatSession, OCRRecord, ListeningLessonRecord } from './types'; import { translations } from './utils/localization'; import { USER_API_KEY_STORAGE, USER_BASE_URL_STORAGE } from './services/geminiService'; const STORAGE_KEYS = { CHAT_SESSIONS: 'sakura_chat_sessions', ACTIVE_SESSION: 'sakura_active_session_id', TRANSLATION_HISTORY: 'sakura_translation_history', READING_HISTORY: 'sakura_reading_history', LISTENING_HISTORY: 'sakura_listening_history', OCR_HISTORY: 'sakura_ocr_history', LANGUAGE: 'sakura_language', SELECTED_MODEL: 'sakura_selected_model', HAS_SEEN_ONBOARDING: 'sakura_has_seen_onboarding' }; // Robust helper for safe JSON parsing const safeJSONParse = (key: string, fallback: T): T => { try { const item = localStorage.getItem(key); if (!item) return fallback; const parsed = JSON.parse(item); // Validate Array type consistency if fallback is an array if (Array.isArray(fallback)) { return Array.isArray(parsed) ? (parsed as unknown as T) : fallback; } return parsed as T; } catch (e) { console.warn(`Failed to load ${key}, falling back to default.`, e); return fallback; } }; const App: React.FC = () => { const [currentView, setCurrentView] = useState(AppMode.CHAT); // Safe Language Initialization const [language, setLanguage] = useState(() => { // Cast to string to prevent TS from inferring the literal type 'zh' from fallback const lang = safeJSONParse(STORAGE_KEYS.LANGUAGE, 'zh') as string; if (lang === 'en' || lang === 'ja' || lang === 'zh') return lang as Language; return 'zh'; }); // Safe History Initialization const [chatSessions, setChatSessions] = useState(() => safeJSONParse(STORAGE_KEYS.CHAT_SESSIONS, [])); const [activeSessionId, setActiveSessionId] = useState(() => safeJSONParse(STORAGE_KEYS.ACTIVE_SESSION, '')); const [translationHistory, setTranslationHistory] = useState(() => safeJSONParse(STORAGE_KEYS.TRANSLATION_HISTORY, [])); const [readingHistory, setReadingHistory] = useState(() => safeJSONParse(STORAGE_KEYS.READING_HISTORY, [])); const [listeningHistory, setListeningHistory] = useState(() => safeJSONParse(STORAGE_KEYS.LISTENING_HISTORY, [])); const [ocrHistory, setOcrHistory] = useState(() => safeJSONParse(STORAGE_KEYS.OCR_HISTORY, [])); const [selectedModel, setSelectedModel] = useState(() => safeJSONParse(STORAGE_KEYS.SELECTED_MODEL, AVAILABLE_CHAT_MODELS[0].id)); const [hasSeenOnboarding, setHasSeenOnboarding] = useState(() => !!localStorage.getItem(STORAGE_KEYS.HAS_SEEN_ONBOARDING)); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [userApiKey, setUserApiKey] = useState(''); const [userBaseUrl, setUserBaseUrl] = useState(''); const [toasts, setToasts] = useState([]); const [confirmState, setConfirmState] = useState<{isOpen: boolean, title: string, message: string, onConfirm: () => void}>({ isOpen: false, title: '', message: '', onConfirm: () => {} }); // Ensure 't' is always valid even if language switch glitches const t = translations[language] || translations['zh']; // Safe Storage Helper to prevent white screen on QuotaExceededError const saveToStorage = (key: string, data: any) => { try { const json = JSON.stringify(data); localStorage.setItem(key, json); } catch (e: any) { if (e.name === 'QuotaExceededError' || e.message?.includes('exceeded the quota')) { console.warn(`Storage quota exceeded for key: ${key}`); addToast('error', t.common.storageFull); // Strategy: Try to strip audioUrl/imagePreview from bulky items if (key === STORAGE_KEYS.CHAT_SESSIONS && Array.isArray(data)) { try { const leanData = (data as ChatSession[]).map(s => ({ ...s, messages: s.messages.map(m => ({ ...m, metadata: { ...m.metadata, audioUrl: undefined } // Remove audio cache })) })); localStorage.setItem(key, JSON.stringify(leanData)); addToast('info', t.common.storageOptimized); } catch (err) {} } else if (key === STORAGE_KEYS.OCR_HISTORY && Array.isArray(data)) { try { const leanData = (data as OCRRecord[]).map(r => ({ ...r, imagePreview: '' })); // Remove image localStorage.setItem(key, JSON.stringify(leanData)); addToast('info', t.common.storageOptimized); } catch (err) {} } } } }; useEffect(() => { saveToStorage(STORAGE_KEYS.CHAT_SESSIONS, chatSessions); }, [chatSessions]); useEffect(() => { saveToStorage(STORAGE_KEYS.ACTIVE_SESSION, activeSessionId); }, [activeSessionId]); useEffect(() => { saveToStorage(STORAGE_KEYS.TRANSLATION_HISTORY, translationHistory); }, [translationHistory]); useEffect(() => { saveToStorage(STORAGE_KEYS.READING_HISTORY, readingHistory); }, [readingHistory]); useEffect(() => { saveToStorage(STORAGE_KEYS.LISTENING_HISTORY, listeningHistory); }, [listeningHistory]); useEffect(() => { saveToStorage(STORAGE_KEYS.OCR_HISTORY, ocrHistory); }, [ocrHistory]); useEffect(() => { saveToStorage(STORAGE_KEYS.LANGUAGE, language); }, [language]); useEffect(() => { saveToStorage(STORAGE_KEYS.SELECTED_MODEL, selectedModel); }, [selectedModel]); useEffect(() => { // Only update welcome message if session is empty/default if (activeSessionId) { const activeSession = chatSessions.find(s => s.id === activeSessionId); if (activeSession && activeSession.messages.length === 1 && activeSession.messages[0].role === Role.MODEL) { const newWelcome = t.chat.welcome; const newTitle = t.chat.newChat; setChatSessions(prev => prev.map(s => s.id === activeSessionId ? { ...s, title: newTitle, messages: [{ ...s.messages[0], content: newWelcome }] } : s)); } } }, [language]); const hasInitialized = useRef(false); useEffect(() => { if (!hasInitialized.current) { if (chatSessions.length === 0) createNewSession(); else if (!activeSessionId) setActiveSessionId(chatSessions[0].id); const storedKey = localStorage.getItem(USER_API_KEY_STORAGE); const storedUrl = localStorage.getItem(USER_BASE_URL_STORAGE); const envKey = process.env.API_KEY; if (storedKey) setUserApiKey(storedKey); if (storedUrl) setUserBaseUrl(storedUrl); if (!storedKey && (!envKey || envKey.length === 0)) { setIsSettingsOpen(true); setTimeout(() => addToast('info', t.settings.apiKeyMissing), 500); } hasInitialized.current = true; } }, []); const createNewSession = () => { const newId = Date.now().toString(); const welcomeMsg: ChatMessage = { id: 'welcome', role: Role.MODEL, type: MessageType.TEXT, content: t.chat.welcome, timestamp: Date.now() }; setChatSessions(prev => [{ id: newId, title: t.chat.newChat, messages: [welcomeMsg], createdAt: Date.now(), updatedAt: Date.now() }, ...prev]); setActiveSessionId(newId); }; const updateSessionMessages = (sessionId: string, messages: ChatMessage[]) => { setChatSessions(prev => prev.map(s => { if (s.id === sessionId) { let title = s.title; if (messages.length > 1) { const firstUserMsg = messages.find(m => m.role === Role.USER); if (firstUserMsg) title = firstUserMsg.content.slice(0, 30) + (firstUserMsg.content.length > 30 ? '...' : ''); } return { ...s, messages, title, updatedAt: Date.now() }; } return s; })); }; const updateReadingLesson = (record: ReadingLessonRecord) => { setReadingHistory(prev => prev.map(item => item.id === record.id ? record : item)); }; const updateListeningLesson = (record: ListeningLessonRecord) => { setListeningHistory(prev => prev.map(item => item.id === record.id ? record : item)); }; const deleteSession = (sessionId: string) => { setConfirmState({ isOpen: true, title: t.common.confirm, message: t.chat.deleteConfirm, onConfirm: () => { const remaining = chatSessions.filter(s => s.id !== sessionId); setChatSessions(remaining); if (activeSessionId === sessionId) { if (remaining.length > 0) setActiveSessionId(remaining[0].id); else createNewSession(); } setConfirmState(prev => ({ ...prev, isOpen: false })); } }); }; const clearAllChatSessions = () => { setConfirmState({ isOpen: true, title: t.common.confirm, message: t.common.clearHistoryConfirm, onConfirm: () => { setChatSessions([]); createNewSession(); setConfirmState(prev => ({ ...prev, isOpen: false })); } }); }; const deleteReadingLesson = (id: string) => { setConfirmState({ isOpen: true, title: t.common.confirm, message: t.common.deleteItemConfirm, onConfirm: () => { setReadingHistory(prev => prev.filter(item => item.id !== id)); setConfirmState(prev => ({ ...prev, isOpen: false })); } }); }; const clearReadingHistory = () => { setConfirmState({ isOpen: true, title: t.common.confirm, message: t.common.clearHistoryConfirm, onConfirm: () => { setReadingHistory([]); setConfirmState(prev => ({ ...prev, isOpen: false })); } }); }; const deleteListeningLesson = (id: string) => { setConfirmState({ isOpen: true, title: t.common.confirm, message: t.common.deleteItemConfirm, onConfirm: () => { setListeningHistory(prev => prev.filter(item => item.id !== id)); setConfirmState(prev => ({ ...prev, isOpen: false })); } }); }; const clearListeningHistory = () => { setConfirmState({ isOpen: true, title: t.common.confirm, message: t.common.clearHistoryConfirm, onConfirm: () => { setListeningHistory([]); setConfirmState(prev => ({ ...prev, isOpen: false })); } }); }; const deleteOCRRecord = (id: string) => { setConfirmState({ isOpen: true, title: t.common.confirm, message: t.common.deleteItemConfirm, onConfirm: () => { setOcrHistory(prev => prev.filter(item => item.id !== id)); setConfirmState(prev => ({ ...prev, isOpen: false })); } }); }; const clearOCRHistory = () => { setConfirmState({ isOpen: true, title: t.common.confirm, message: t.common.clearHistoryConfirm, onConfirm: () => { setOcrHistory([]); setConfirmState(prev => ({ ...prev, isOpen: false })); } }); }; const deleteTranslationRecord = (id: string) => { setConfirmState({ isOpen: true, title: t.common.confirm, message: t.common.deleteItemConfirm, onConfirm: () => { setTranslationHistory(prev => prev.filter(item => item.id !== id)); setConfirmState(prev => ({ ...prev, isOpen: false })); } }); }; const clearTranslationHistory = () => { setConfirmState({ isOpen: true, title: t.common.confirm, message: t.common.clearHistoryConfirm, onConfirm: () => { setTranslationHistory([]); setConfirmState(prev => ({ ...prev, isOpen: false })); } }); }; const addToast = (type: 'success' | 'error' | 'info', message: string) => { const id = Date.now().toString(); setToasts(prev => [...prev, { id, type, message }]); }; const removeToast = (id: string) => setToasts(prev => prev.filter(t => t.id !== id)); const handleSaveSettings = () => { if (userApiKey.trim()) { localStorage.setItem(USER_API_KEY_STORAGE, userApiKey.trim()); } if (userBaseUrl.trim()) { localStorage.setItem(USER_BASE_URL_STORAGE, userBaseUrl.trim()); } else { localStorage.removeItem(USER_BASE_URL_STORAGE); } addToast('success', t.settings.keySaved); }; const handleClearSettings = () => { localStorage.removeItem(USER_API_KEY_STORAGE); localStorage.removeItem(USER_BASE_URL_STORAGE); setUserApiKey(''); setUserBaseUrl(''); addToast('info', t.settings.keyRemoved); }; const toggleLanguage = () => { if (language === 'en') setLanguage('ja'); else if (language === 'ja') setLanguage('zh'); else setLanguage('en'); }; const handleViewChange = (mode: AppMode) => { setCurrentView(mode); setIsSidebarOpen(false); }; const completeOnboarding = () => { localStorage.setItem(STORAGE_KEYS.HAS_SEEN_ONBOARDING, 'true'); setHasSeenOnboarding(true); }; const handleBackup = () => { const backup: AppDataBackup = { version: 1, createdAt: Date.now(), language, chatSessions, translationHistory, readingHistory, listeningHistory, ocrHistory }; const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `sakura-backup-${new Date().toISOString().slice(0,10)}.json`; a.click(); URL.revokeObjectURL(url); }; const handleRestore = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (event) => { try { const data = JSON.parse(event.target?.result as string) as any; if (data.chatSessions && Array.isArray(data.chatSessions)) { setChatSessions(data.chatSessions); setActiveSessionId(data.chatSessions[0]?.id || ''); setTranslationHistory(data.translationHistory || []); setReadingHistory(data.readingHistory || []); setListeningHistory(data.listeningHistory || []); setOcrHistory(data.ocrHistory || []); setLanguage(data.language || 'en'); addToast('success', t.settings.successRestore); setIsSettingsOpen(false); } else { throw new Error(); } } catch (err) { addToast('error', t.settings.errorRestore); } }; reader.readAsText(file); }; const exportChatText = () => { const session = chatSessions.find(s => s.id === activeSessionId); if (!session) return; const text = session.messages.map(m => `[${new Date(m.timestamp).toLocaleString()}] ${m.role}: ${m.content}`).join('\n\n'); const blob = new Blob([text], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `chat.txt`; a.click(); }; const exportTranslationCSV = () => { const header = "Timestamp,Source,Target,SrcText,TgtText\n"; const rows = translationHistory.map(t => `"${new Date(t.timestamp).toISOString()}","${t.sourceLang}","${t.targetLang}","${t.sourceText.replace(/"/g, '""')}","${t.targetText.replace(/"/g, '""')}"`).join('\n'); const blob = new Blob([header + rows], { type: 'text/csv' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `translations.csv`; a.click(); }; const exportReadingHistory = () => { const blob = new Blob([JSON.stringify(readingHistory, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `reading-history.json`; a.click(); URL.revokeObjectURL(url); }; const exportOCRHistory = () => { const blob = new Blob([JSON.stringify(ocrHistory, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `ocr-history.json`; a.click(); URL.revokeObjectURL(url); }; const NavButton = ({ mode, icon: Icon, label, colorClass }: any) => { const isActive = currentView === mode; return ( ); }; return (
setConfirmState(prev => ({...prev, isOpen: false}))} /> {!hasSeenOnboarding && } {isSidebarOpen &&
setIsSidebarOpen(false)} />}
Sakura Sensei
{currentView === AppMode.CHAT && } {currentView === AppMode.TRANSLATION && setTranslationHistory(prev => [...prev, rec])} clearHistory={clearTranslationHistory} onDeleteHistoryItem={deleteTranslationRecord} />} {currentView === AppMode.SPEAKING && } {currentView === AppMode.CREATIVE && } {currentView === AppMode.READING && setReadingHistory(prev => [...prev, rec])} onClearHistory={clearReadingHistory} onDeleteHistoryItem={deleteReadingLesson} onUpdateHistory={updateReadingLesson} />} {currentView === AppMode.LISTENING && setListeningHistory(prev => [...prev, rec])} onClearHistory={clearListeningHistory} onDeleteHistoryItem={deleteListeningLesson} onUpdateHistory={updateListeningLesson} />} {currentView === AppMode.OCR && setOcrHistory(prev => [...prev, rec])} onClearHistory={clearOCRHistory} onDeleteHistoryItem={deleteOCRRecord} addToast={addToast} />}
{isSettingsOpen && (

{t.settings.title}

{t.settings.apiKeyTitle}

{t.settings.apiKeyDesc}

setUserApiKey(e.target.value)} placeholder={t.settings.apiKeyPlaceholder} className="w-full p-3 rounded-lg border border-amber-200 bg-white text-sm outline-none focus:ring-2 focus:ring-amber-400 transition-all" />
setUserBaseUrl(e.target.value)} placeholder={t.settings.baseUrlPlaceholder} className="w-full p-3 pl-9 rounded-lg border border-amber-200 bg-white text-sm outline-none focus:ring-2 focus:ring-amber-400 transition-all" />

{t.settings.modelTitle}

{t.settings.backupTitle}

{t.settings.backupBtn}
{t.settings.backupDesc}
{t.settings.restoreBtn}
{t.settings.restoreDesc}

{t.settings.exportTitle}

)}
); }; export default App;