Files
ai-app-skr/App.tsx
2025-11-21 00:24:18 +08:00

495 lines
30 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[]>(() => {
const stored = localStorage.getItem(STORAGE_KEYS.CHAT_SESSIONS);
if (stored) return JSON.parse(stored);
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];
useEffect(() => { localStorage.setItem(STORAGE_KEYS.CHAT_SESSIONS, JSON.stringify(chatSessions)); }, [chatSessions]);
useEffect(() => { localStorage.setItem(STORAGE_KEYS.ACTIVE_SESSION, activeSessionId); }, [activeSessionId]);
useEffect(() => { localStorage.setItem(STORAGE_KEYS.TRANSLATION_HISTORY, JSON.stringify(translationHistory)); }, [translationHistory]);
useEffect(() => { localStorage.setItem(STORAGE_KEYS.READING_HISTORY, JSON.stringify(readingHistory)); }, [readingHistory]);
useEffect(() => { localStorage.setItem(STORAGE_KEYS.LISTENING_HISTORY, JSON.stringify(listeningHistory)); }, [listeningHistory]);
useEffect(() => { localStorage.setItem(STORAGE_KEYS.OCR_HISTORY, JSON.stringify(ocrHistory)); }, [ocrHistory]);
useEffect(() => { localStorage.setItem(STORAGE_KEYS.LANGUAGE, language); }, [language]);
useEffect(() => { localStorage.setItem(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;