546 lines
32 KiB
TypeScript
546 lines
32 KiB
TypeScript
|
|
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'
|
|
};
|
|
|
|
const App: React.FC = () => {
|
|
const [currentView, setCurrentView] = useState<AppMode>(AppMode.CHAT);
|
|
// Default to 'zh' (Chinese)
|
|
const [language, setLanguage] = useState<Language>(() => (localStorage.getItem(STORAGE_KEYS.LANGUAGE) as Language) || 'zh');
|
|
const [chatSessions, setChatSessions] = useState<ChatSession[]>(() => {
|
|
try {
|
|
const stored = localStorage.getItem(STORAGE_KEYS.CHAT_SESSIONS);
|
|
if (stored) return JSON.parse(stored);
|
|
} catch (e) { console.error("Failed to load chat sessions", e); }
|
|
return [];
|
|
});
|
|
const [activeSessionId, setActiveSessionId] = useState<string>(() => localStorage.getItem(STORAGE_KEYS.ACTIVE_SESSION) || '');
|
|
const [translationHistory, setTranslationHistory] = useState<TranslationRecord[]>(() => {
|
|
const s = localStorage.getItem(STORAGE_KEYS.TRANSLATION_HISTORY); return s ? JSON.parse(s) : [];
|
|
});
|
|
const [readingHistory, setReadingHistory] = useState<ReadingLessonRecord[]>(() => {
|
|
const s = localStorage.getItem(STORAGE_KEYS.READING_HISTORY); return s ? JSON.parse(s) : [];
|
|
});
|
|
const [listeningHistory, setListeningHistory] = useState<ListeningLessonRecord[]>(() => {
|
|
const s = localStorage.getItem(STORAGE_KEYS.LISTENING_HISTORY); return s ? JSON.parse(s) : [];
|
|
});
|
|
const [ocrHistory, setOcrHistory] = useState<OCRRecord[]>(() => {
|
|
const s = localStorage.getItem(STORAGE_KEYS.OCR_HISTORY); return s ? JSON.parse(s) : [];
|
|
});
|
|
const [selectedModel, setSelectedModel] = useState<string>(() => localStorage.getItem(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<ToastMessage[]>([]);
|
|
|
|
const [confirmState, setConfirmState] = useState<{isOpen: boolean, title: string, message: string, onConfirm: () => void}>({
|
|
isOpen: false, title: '', message: '', onConfirm: () => {}
|
|
});
|
|
|
|
const t = translations[language];
|
|
|
|
// 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) {
|
|
// Check for QuotaExceededError
|
|
if (e.name === 'QuotaExceededError' || e.message?.includes('exceeded the quota')) {
|
|
console.warn(`Storage quota exceeded for key: ${key}`);
|
|
addToast('error', translations[language].common.storageFull);
|
|
|
|
// Attempt to recover by stripping heavy data (e.g., audioUrl from chats, base64 images from OCR)
|
|
if (key === STORAGE_KEYS.CHAT_SESSIONS && Array.isArray(data)) {
|
|
try {
|
|
// Create a lightweight version by removing audioUrl base64 strings
|
|
const leanData = (data as ChatSession[]).map(s => ({
|
|
...s,
|
|
messages: s.messages.map(m => ({
|
|
...m,
|
|
metadata: {
|
|
...m.metadata,
|
|
audioUrl: undefined // Remove cached audio to save space
|
|
}
|
|
}))
|
|
}));
|
|
localStorage.setItem(key, JSON.stringify(leanData));
|
|
addToast('info', translations[language].common.storageOptimized);
|
|
} catch (retryError) {
|
|
console.error("Failed to save even lean chat data", retryError);
|
|
}
|
|
} else if (key === STORAGE_KEYS.OCR_HISTORY && Array.isArray(data)) {
|
|
try {
|
|
// Create lightweight version by removing imagePreview
|
|
const leanData = (data as OCRRecord[]).map(r => ({
|
|
...r,
|
|
imagePreview: '' // Remove heavy base64 image
|
|
}));
|
|
localStorage.setItem(key, JSON.stringify(leanData));
|
|
addToast('info', translations[language].common.storageOptimized);
|
|
} catch (retryError) {
|
|
console.error("Failed to save even lean OCR data", retryError);
|
|
}
|
|
}
|
|
} else {
|
|
console.error("LocalStorage save failed", e);
|
|
}
|
|
}
|
|
};
|
|
|
|
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(() => {
|
|
const activeSession = chatSessions.find(s => s.id === activeSessionId);
|
|
if (activeSession && activeSession.messages.length === 1 && activeSession.messages[0].role === Role.MODEL) {
|
|
const newWelcome = translations[language].chat.welcome;
|
|
const newTitle = translations[language].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', translations[language].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: translations[language].chat.welcome, timestamp: Date.now() };
|
|
setChatSessions(prev => [{ id: newId, title: translations[language].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: translations[language].common.confirm,
|
|
message: translations[language].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: translations[language].common.confirm,
|
|
message: translations[language].common.clearHistoryConfirm,
|
|
onConfirm: () => {
|
|
setChatSessions([]);
|
|
createNewSession();
|
|
setConfirmState(prev => ({ ...prev, isOpen: false }));
|
|
}
|
|
});
|
|
};
|
|
|
|
const deleteReadingLesson = (id: string) => {
|
|
setConfirmState({
|
|
isOpen: true,
|
|
title: translations[language].common.confirm,
|
|
message: translations[language].common.deleteItemConfirm,
|
|
onConfirm: () => {
|
|
setReadingHistory(prev => prev.filter(item => item.id !== id));
|
|
setConfirmState(prev => ({ ...prev, isOpen: false }));
|
|
}
|
|
});
|
|
};
|
|
|
|
const clearReadingHistory = () => {
|
|
setConfirmState({
|
|
isOpen: true,
|
|
title: translations[language].common.confirm,
|
|
message: translations[language].common.clearHistoryConfirm,
|
|
onConfirm: () => {
|
|
setReadingHistory([]);
|
|
setConfirmState(prev => ({ ...prev, isOpen: false }));
|
|
}
|
|
});
|
|
};
|
|
|
|
const deleteListeningLesson = (id: string) => {
|
|
setConfirmState({
|
|
isOpen: true,
|
|
title: translations[language].common.confirm,
|
|
message: translations[language].common.deleteItemConfirm,
|
|
onConfirm: () => {
|
|
setListeningHistory(prev => prev.filter(item => item.id !== id));
|
|
setConfirmState(prev => ({ ...prev, isOpen: false }));
|
|
}
|
|
});
|
|
};
|
|
|
|
const clearListeningHistory = () => {
|
|
setConfirmState({
|
|
isOpen: true,
|
|
title: translations[language].common.confirm,
|
|
message: translations[language].common.clearHistoryConfirm,
|
|
onConfirm: () => {
|
|
setListeningHistory([]);
|
|
setConfirmState(prev => ({ ...prev, isOpen: false }));
|
|
}
|
|
});
|
|
};
|
|
|
|
const deleteOCRRecord = (id: string) => {
|
|
setConfirmState({
|
|
isOpen: true,
|
|
title: translations[language].common.confirm,
|
|
message: translations[language].common.deleteItemConfirm,
|
|
onConfirm: () => {
|
|
setOcrHistory(prev => prev.filter(item => item.id !== id));
|
|
setConfirmState(prev => ({ ...prev, isOpen: false }));
|
|
}
|
|
});
|
|
};
|
|
|
|
const clearOCRHistory = () => {
|
|
setConfirmState({
|
|
isOpen: true,
|
|
title: translations[language].common.confirm,
|
|
message: translations[language].common.clearHistoryConfirm,
|
|
onConfirm: () => {
|
|
setOcrHistory([]);
|
|
setConfirmState(prev => ({ ...prev, isOpen: false }));
|
|
}
|
|
});
|
|
};
|
|
|
|
const deleteTranslationRecord = (id: string) => {
|
|
setConfirmState({
|
|
isOpen: true,
|
|
title: translations[language].common.confirm,
|
|
message: translations[language].common.deleteItemConfirm,
|
|
onConfirm: () => {
|
|
setTranslationHistory(prev => prev.filter(item => item.id !== id));
|
|
setConfirmState(prev => ({ ...prev, isOpen: false }));
|
|
}
|
|
});
|
|
};
|
|
|
|
const clearTranslationHistory = () => {
|
|
setConfirmState({
|
|
isOpen: true,
|
|
title: translations[language].common.confirm,
|
|
message: translations[language].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<HTMLInputElement>) => {
|
|
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 (
|
|
<button
|
|
onClick={() => handleViewChange(mode)}
|
|
className={`w-full relative group flex items-center p-3 rounded-2xl transition-all duration-200 mb-2 active:scale-90 ${isActive ? `bg-gradient-to-br ${colorClass} text-white shadow-lg ring-2 ring-offset-2 ring-indigo-100` : 'bg-white text-slate-500 hover:bg-slate-50 border border-slate-100 hover:border-indigo-100'}`}
|
|
>
|
|
<div className={`p-2 rounded-xl ${isActive ? 'bg-white/20' : 'bg-slate-100 group-hover:bg-white group-hover:shadow-sm'} transition-all duration-300`}><Icon size={20} /></div>
|
|
<span className="ml-3 font-bold text-sm">{label}</span>
|
|
{isActive && <div className="absolute right-3 w-2 h-2 rounded-full bg-white/50 animate-pulse"></div>}
|
|
</button>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-[100dvh] w-screen bg-slate-50 overflow-hidden font-sans relative">
|
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
|
<ConfirmModal isOpen={confirmState.isOpen} title={confirmState.title} message={confirmState.message} language={language} onConfirm={confirmState.onConfirm} onCancel={() => setConfirmState(prev => ({...prev, isOpen: false}))} />
|
|
|
|
{!hasSeenOnboarding && <Onboarding language={language} setLanguage={setLanguage} onComplete={completeOnboarding} />}
|
|
{isSidebarOpen && <div className="absolute inset-0 bg-slate-900/50 z-40 md:hidden backdrop-blur-sm transition-opacity" onClick={() => setIsSidebarOpen(false)} />}
|
|
|
|
<aside className={`fixed inset-y-0 left-0 z-50 w-72 bg-white/90 backdrop-blur-xl border-r border-slate-200 flex flex-col justify-between shadow-2xl transform transition-transform duration-300 ease-in-out pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)] ${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'} md:relative md:translate-x-0 md:flex`}>
|
|
<div className="flex flex-col h-full">
|
|
<div className="h-24 flex items-center justify-start px-6 mb-2 flex-shrink-0">
|
|
<div className="relative"><div className="w-10 h-10 bg-gradient-to-tr from-pink-400 to-rose-500 rounded-xl shadow-lg flex items-center justify-center text-white animate-pulse"><Sparkles size={20} fill="currentColor" /></div></div>
|
|
<div className="ml-3"><h1 className="font-extrabold text-xl text-slate-800 tracking-tight leading-tight">Sakura<br/><span className="text-pink-500">Sensei</span> 🌸</h1></div>
|
|
<button className="md:hidden ml-auto text-slate-400" onClick={() => setIsSidebarOpen(false)}><X size={24} /></button>
|
|
</div>
|
|
|
|
<nav className="px-3 pb-4 space-y-1 overflow-y-auto flex-1 scrollbar-hide">
|
|
|
|
{/* Study & Input */}
|
|
<div className="px-3 mt-4 mb-2 flex items-center gap-2 opacity-70">
|
|
<div className="w-1 h-1 rounded-full bg-indigo-400"></div>
|
|
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">{t.nav.sectionStudy}</span>
|
|
</div>
|
|
<NavButton mode={AppMode.CHAT} icon={MessageCircle} label={t.nav.chat} colorClass="from-indigo-500 to-violet-600" />
|
|
<NavButton mode={AppMode.READING} icon={BookOpen} label={t.nav.reading} colorClass="from-emerald-400 to-teal-500" />
|
|
<NavButton mode={AppMode.LISTENING} icon={Headphones} label={t.nav.listening} colorClass="from-sky-400 to-blue-500" />
|
|
|
|
{/* Practice & Output */}
|
|
<div className="px-3 mt-6 mb-2 flex items-center gap-2 opacity-70">
|
|
<div className="w-1 h-1 rounded-full bg-orange-400"></div>
|
|
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">{t.nav.sectionPractice}</span>
|
|
</div>
|
|
<NavButton mode={AppMode.SPEAKING} icon={Mic2} label={t.nav.speaking} colorClass="from-orange-400 to-pink-500" />
|
|
|
|
{/* Toolbox */}
|
|
<div className="px-3 mt-6 mb-2 flex items-center gap-2 opacity-70">
|
|
<div className="w-1 h-1 rounded-full bg-blue-400"></div>
|
|
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">{t.nav.sectionTools}</span>
|
|
</div>
|
|
<NavButton mode={AppMode.OCR} icon={ScanText} label={t.nav.ocr} colorClass="from-violet-400 to-purple-500" />
|
|
<NavButton mode={AppMode.TRANSLATION} icon={Languages} label={t.nav.translation} colorClass="from-blue-500 to-indigo-500" />
|
|
<NavButton mode={AppMode.CREATIVE} icon={Palette} label={t.nav.creative} colorClass="from-cyan-400 to-blue-500" />
|
|
</nav>
|
|
|
|
<div className="p-4 bg-slate-50/80 border-t border-slate-100 flex-shrink-0 grid grid-cols-2 gap-3">
|
|
<button onClick={toggleLanguage} className="flex flex-col items-center justify-center p-3 bg-white hover:bg-slate-50 border border-slate-200 hover:border-slate-300 rounded-2xl transition-all active:scale-95 shadow-sm hover:shadow-md group">
|
|
<div className="w-8 h-8 rounded-full bg-slate-100 group-hover:bg-white border border-slate-200 flex items-center justify-center text-slate-500 mb-1 transition-colors"><Globe size={16} /></div>
|
|
<span className="text-[10px] font-bold text-slate-600">{language === 'en' ? 'English' : language === 'ja' ? '日本語' : '中文'}</span>
|
|
</button>
|
|
<button onClick={() => { setIsSettingsOpen(true); setIsSidebarOpen(false); }} className="flex flex-col items-center justify-center p-3 bg-white hover:bg-slate-50 border border-slate-200 hover:border-slate-300 rounded-2xl transition-all active:scale-95 shadow-sm hover:shadow-md group">
|
|
<div className="w-8 h-8 rounded-full bg-slate-100 group-hover:bg-white border border-slate-200 flex items-center justify-center text-slate-500 mb-1 transition-colors"><Settings size={16} /></div>
|
|
<span className="text-[10px] font-bold text-slate-600">{t.nav.settings}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<main className="flex-1 h-full relative overflow-hidden bg-slate-50/50 flex flex-col" key={currentView}>
|
|
<div className="md:hidden h-16 bg-white/80 backdrop-blur border-b border-slate-200 flex items-center px-4 justify-between flex-shrink-0 z-40 pt-[env(safe-area-inset-top)] box-content">
|
|
<button onClick={() => setIsSidebarOpen(true)} className="p-2 -ml-2 text-slate-600 active:scale-90 transition-transform"><Menu size={24} /></button>
|
|
<span className="font-bold text-slate-800">Sakura Sensei</span>
|
|
<div className="w-8" />
|
|
</div>
|
|
<div className="flex-1 relative overflow-hidden">
|
|
{currentView === AppMode.CHAT && <ChatView language={language} sessions={chatSessions} activeSessionId={activeSessionId} onNewSession={createNewSession} onSelectSession={setActiveSessionId} onDeleteSession={deleteSession} onClearAllSessions={clearAllChatSessions} onUpdateSession={updateSessionMessages} selectedModel={selectedModel} addToast={addToast} />}
|
|
{currentView === AppMode.TRANSLATION && <TranslationView language={language} history={translationHistory} addToHistory={(rec) => setTranslationHistory(prev => [...prev, rec])} clearHistory={clearTranslationHistory} onDeleteHistoryItem={deleteTranslationRecord} />}
|
|
{currentView === AppMode.SPEAKING && <SpeakingPracticeView language={language} />}
|
|
{currentView === AppMode.CREATIVE && <CreativeStudio language={language} addToast={addToast} />}
|
|
{currentView === AppMode.READING && <ReadingView language={language} history={readingHistory} onSaveToHistory={(rec) => setReadingHistory(prev => [...prev, rec])} onClearHistory={clearReadingHistory} onDeleteHistoryItem={deleteReadingLesson} onUpdateHistory={updateReadingLesson} />}
|
|
{currentView === AppMode.LISTENING && <ListeningView language={language} history={listeningHistory} onSaveToHistory={(rec) => setListeningHistory(prev => [...prev, rec])} onClearHistory={clearListeningHistory} onDeleteHistoryItem={deleteListeningLesson} onUpdateHistory={updateListeningLesson} />}
|
|
{currentView === AppMode.OCR && <OCRView language={language} history={ocrHistory} onSaveToHistory={(rec) => setOcrHistory(prev => [...prev, rec])} onClearHistory={clearOCRHistory} onDeleteHistoryItem={deleteOCRRecord} addToast={addToast} />}
|
|
</div>
|
|
</main>
|
|
|
|
{isSettingsOpen && (
|
|
<div className="fixed inset-0 bg-slate-900/20 backdrop-blur-sm z-[60] flex items-center justify-center p-4 animate-fade-in">
|
|
<div className="bg-white rounded-3xl shadow-2xl max-w-lg w-full overflow-hidden flex flex-col max-h-[85vh] animate-scale-in">
|
|
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
|
|
<h3 className="text-xl font-extrabold text-slate-800 flex items-center gap-2"><Settings className="text-slate-400" /> {t.settings.title}</h3>
|
|
<button onClick={() => setIsSettingsOpen(false)} className="p-2 hover:bg-slate-100 rounded-full transition-colors"><X size={20} /></button>
|
|
</div>
|
|
<div className="p-6 space-y-8 overflow-y-auto">
|
|
<div className="bg-amber-50 p-5 rounded-2xl border border-amber-100">
|
|
<h4 className="text-sm font-bold text-amber-700 mb-2 flex items-center gap-2"><Key size={16} /> {t.settings.apiKeyTitle}</h4>
|
|
<p className="text-xs text-amber-600 mb-3">{t.settings.apiKeyDesc}</p>
|
|
<div className="space-y-3">
|
|
<input type="password" value={userApiKey} onChange={(e) => 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" />
|
|
<div className="relative">
|
|
<Link size={14} className="absolute left-3 top-3.5 text-amber-400" />
|
|
<input type="text" value={userBaseUrl} onChange={(e) => 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" />
|
|
</div>
|
|
<div className="flex gap-2 pt-2">
|
|
<button onClick={handleSaveSettings} className="flex-1 p-2 bg-amber-500 text-white rounded-lg font-bold shadow-md hover:bg-amber-600 transition-colors active:scale-95">{t.settings.saveKey}</button>
|
|
<button onClick={handleClearSettings} className="flex-1 p-2 bg-white text-red-400 border border-red-100 rounded-lg hover:bg-red-50 transition-colors active:scale-95">{t.settings.removeKey}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h4 className="text-sm font-bold text-slate-400 uppercase mb-4 flex gap-2"><BrainCircuit size={16} /> {t.settings.modelTitle}</h4>
|
|
<select value={selectedModel} onChange={(e) => { setSelectedModel(e.target.value); addToast('success', t.settings.modelSaved); }} className="w-full p-3 rounded-xl bg-slate-50 border border-slate-200 font-bold text-slate-700 outline-none focus:ring-2 focus:ring-indigo-500 transition-all cursor-pointer hover:bg-white">
|
|
{AVAILABLE_CHAT_MODELS.map(model => <option key={model.id} value={model.id}>{model.name}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<h4 className="text-sm font-bold text-slate-400 uppercase mb-4 flex gap-2"><Download size={16} /> {t.settings.backupTitle}</h4>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="p-4 rounded-2xl bg-indigo-50 border border-indigo-100 hover:bg-indigo-100 transition-all cursor-pointer group active:scale-95" onClick={handleBackup}>
|
|
<div className="font-bold text-indigo-900 mb-1 group-hover:text-indigo-700">{t.settings.backupBtn}</div>
|
|
<div className="text-xs text-indigo-600">{t.settings.backupDesc}</div>
|
|
</div>
|
|
<div className="p-4 rounded-2xl bg-slate-50 border border-slate-200 hover:bg-slate-100 transition-all relative group active:scale-95">
|
|
<div className="font-bold text-slate-700 mb-1 group-hover:text-slate-900">{t.settings.restoreBtn}</div>
|
|
<div className="text-xs text-slate-500">{t.settings.restoreDesc}</div>
|
|
<input type="file" onChange={handleRestore} accept=".json" className="absolute inset-0 opacity-0 cursor-pointer" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h4 className="text-sm font-bold text-slate-400 uppercase mb-4 flex gap-2"><FileText size={16} /> {t.settings.exportTitle}</h4>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<button onClick={exportChatText} className="p-3 rounded-xl border border-slate-200 text-slate-600 font-medium hover:bg-slate-50 text-xs active:scale-95 transition-all">{t.settings.exportChatBtn}</button>
|
|
<button onClick={exportTranslationCSV} className="p-3 rounded-xl border border-slate-200 text-slate-600 font-medium hover:bg-slate-50 text-xs active:scale-95 transition-all">{t.settings.exportTransBtn}</button>
|
|
<button onClick={exportReadingHistory} className="p-3 rounded-xl border border-slate-200 text-slate-600 font-medium hover:bg-slate-50 text-xs active:scale-95 transition-all">{t.settings.exportReadingBtn}</button>
|
|
<button onClick={exportOCRHistory} className="p-3 rounded-xl border border-slate-200 text-slate-600 font-medium hover:bg-slate-50 text-xs active:scale-95 transition-all">{t.settings.exportOCRBtn}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default App;
|