初始化项目

This commit is contained in:
2025-11-21 00:24:10 +08:00
commit 2878783349
34 changed files with 6774 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

494
App.tsx Normal file
View File

@@ -0,0 +1,494 @@
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;

34
Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
# Stage 1: Build
FROM node:20-alpine as builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy source code
COPY . .
# Accept API Key as build arg (fallback if user doesn't set one)
ARG VITE_API_KEY
ENV VITE_API_KEY=$VITE_API_KEY
# Build the app
RUN npm run build
# Stage 2: Serve
FROM nginx:alpine
# Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy static files
COPY --from=builder /app/dist /usr/share/nginx/html
# Cloud Run port
EXPOSE 8080
CMD ["nginx", "-g", "daemon off;"]

20
README.md Normal file
View File

@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1MdpOjnvh39r0kvYmztzlvr-cTY1iF2tW
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

View File

@@ -0,0 +1,235 @@
import React, { useState, useRef, useEffect } from 'react';
import { Mic, Square, Loader2 } from 'lucide-react';
interface AudioRecorderProps {
onAudioCaptured: (base64Audio: string) => void;
disabled?: boolean;
titleStart?: string;
titleStop?: string;
}
const AudioRecorder: React.FC<AudioRecorderProps> = ({
onAudioCaptured,
disabled,
titleStart = "Start Voice Input",
titleStop = "Stop Recording"
}) => {
const [isRecording, setIsRecording] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const audioContextRef = useRef<AudioContext | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const processorRef = useRef<ScriptProcessorNode | null>(null);
const inputRef = useRef<MediaStreamAudioSourceNode | null>(null);
const audioDataRef = useRef<Float32Array[]>([]);
useEffect(() => {
return () => {
cleanup();
};
}, []);
const cleanup = () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
if (processorRef.current) {
processorRef.current.disconnect();
processorRef.current = null;
}
if (inputRef.current) {
inputRef.current.disconnect();
inputRef.current = null;
}
if (audioContextRef.current) {
if (audioContextRef.current.state !== 'closed') {
audioContextRef.current.close();
}
audioContextRef.current = null;
}
};
const startRecording = async () => {
try {
audioDataRef.current = [];
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
if (audioContext.state === 'suspended') {
await audioContext.resume();
}
audioContextRef.current = audioContext;
const input = audioContext.createMediaStreamSource(stream);
inputRef.current = input;
// Buffer size 4096, 1 input channel, 1 output channel
const processor = audioContext.createScriptProcessor(4096, 1, 1);
processorRef.current = processor;
processor.onaudioprocess = (e) => {
const channelData = e.inputBuffer.getChannelData(0);
// Clone the data
audioDataRef.current.push(new Float32Array(channelData));
};
input.connect(processor);
processor.connect(audioContext.destination);
setIsRecording(true);
} catch (err) {
console.error("Error accessing microphone:", err);
alert("Could not access microphone. Please check permissions.");
}
};
const stopRecording = async () => {
if (!isRecording) return;
setIsRecording(false);
setIsProcessing(true);
// Stop capturing
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
}
if (processorRef.current) {
processorRef.current.disconnect();
}
if (inputRef.current) {
inputRef.current.disconnect();
}
// Small delay to allow last process tick
setTimeout(() => {
try {
if (audioDataRef.current.length === 0) {
setIsProcessing(false);
cleanup();
return;
}
const sampleRate = audioContextRef.current?.sampleRate || 44100;
const blob = exportWAV(audioDataRef.current, sampleRate);
cleanup();
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => {
const result = reader.result as string;
// result is "data:audio/wav;base64,..."
const base64String = result.split(',')[1];
onAudioCaptured(base64String);
setIsProcessing(false);
};
} catch (e) {
console.error("WAV Encoding Error", e);
setIsProcessing(false);
cleanup();
}
}, 100);
};
return (
<button
onClick={isRecording ? stopRecording : startRecording}
disabled={disabled || isProcessing}
className={`p-3 rounded-full transition-all duration-300 ${
isRecording
? 'bg-red-500 hover:bg-red-600 text-white animate-pulse shadow-lg shadow-red-200 ring-4 ring-red-100'
: 'bg-slate-200 hover:bg-slate-300 text-slate-700 hover:shadow-md'
} disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center`}
title={isRecording ? titleStop : titleStart}
>
{isProcessing ? <Loader2 size={20} className="animate-spin" /> : (isRecording ? <Square size={20} fill="currentColor" /> : <Mic size={20} />)}
</button>
);
};
// --- WAV ENCODER HELPERS ---
const exportWAV = (audioData: Float32Array[], sampleRate: number) => {
const mergedBuffers = mergeBuffers(audioData);
const downsampledBuffer = downsampleBuffer(mergedBuffers, sampleRate);
const buffer = encodeWAV(downsampledBuffer);
return new Blob([buffer], { type: 'audio/wav' });
};
const mergeBuffers = (audioData: Float32Array[]) => {
const totalLength = audioData.reduce((acc, val) => acc + val.length, 0);
const result = new Float32Array(totalLength);
let offset = 0;
for (const arr of audioData) {
result.set(arr, offset);
offset += arr.length;
}
return result;
};
const downsampleBuffer = (buffer: Float32Array, sampleRate: number) => {
if (sampleRate === 16000) return buffer;
const targetRate = 16000;
const sampleRateRatio = sampleRate / targetRate;
const newLength = Math.ceil(buffer.length / sampleRateRatio);
const result = new Float32Array(newLength);
let offsetResult = 0;
let offsetBuffer = 0;
while (offsetResult < result.length) {
const nextOffsetBuffer = Math.round((offsetResult + 1) * sampleRateRatio);
let accum = 0, count = 0;
for (let i = offsetBuffer; i < nextOffsetBuffer && i < buffer.length; i++) {
accum += buffer[i];
count++;
}
// Fixed NaN issue here: verify count is > 0
if (count > 0) {
result[offsetResult] = accum / count;
} else {
result[offsetResult] = 0;
}
offsetResult++;
offsetBuffer = nextOffsetBuffer;
}
return result;
};
const encodeWAV = (samples: Float32Array) => {
const buffer = new ArrayBuffer(44 + samples.length * 2);
const view = new DataView(buffer);
const writeString = (view: DataView, offset: number, string: string) => {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
};
writeString(view, 0, 'RIFF');
view.setUint32(4, 36 + samples.length * 2, true);
writeString(view, 8, 'WAVE');
writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, 16000, true);
view.setUint32(28, 16000 * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
writeString(view, 36, 'data');
view.setUint32(40, samples.length * 2, true);
const floatTo16BitPCM = (output: DataView, offset: number, input: Float32Array) => {
for (let i = 0; i < input.length; i++, offset += 2) {
const s = Math.max(-1, Math.min(1, input[i]));
output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
};
floatTo16BitPCM(view, 44, samples);
return view;
};
export default AudioRecorder;

267
components/ChatBubble.tsx Normal file
View File

@@ -0,0 +1,267 @@
import React, { useState, useRef } from 'react';
import { Role, MessageType, ChatMessage, Language } from '../types';
import { User, Bot, BrainCircuit, Volume2, Pause, Sparkles, Download, Copy, Check, Loader2 } from 'lucide-react';
import { geminiService, decodeAudioData } from '../services/geminiService';
import { processAndDownloadAudio } from '../utils/audioUtils';
import { translations } from '../utils/localization';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
interface ChatBubbleProps {
message: ChatMessage;
language: Language;
onUpdateMessage?: (updatedMessage: ChatMessage) => void;
onError?: (msg: string) => void;
}
const ChatBubble: React.FC<ChatBubbleProps> = ({ message, language, onUpdateMessage, onError }) => {
const isUser = message.role === Role.USER;
const [isPlaying, setIsPlaying] = useState(false);
const [isGeneratingAudio, setIsGeneratingAudio] = useState(false);
const [isCopied, setIsCopied] = useState(false);
const audioContextRef = useRef<AudioContext | null>(null);
const audioSourceRef = useRef<AudioBufferSourceNode | null>(null);
const t = translations[language].chat;
const tCommon = translations[language].common;
const stopAudio = () => {
if (audioSourceRef.current) {
audioSourceRef.current.stop();
audioSourceRef.current = null;
}
setIsPlaying(false);
};
const handlePlayAudio = async () => {
if (isPlaying) {
stopAudio();
return;
}
let base64Data = message.metadata?.audioUrl;
// If no audio cached, generate it on demand
if (!base64Data && message.content && message.type === MessageType.TEXT) {
try {
setIsGeneratingAudio(true);
base64Data = await geminiService.generateSpeech(message.content);
if (!base64Data) throw new Error("Audio generation returned empty");
// Cache it if parent provided update handler
if (onUpdateMessage) {
onUpdateMessage({
...message,
metadata: {
...message.metadata,
audioUrl: base64Data
}
});
}
} catch (e) {
console.error("Audio gen failed", e);
setIsGeneratingAudio(false);
if (onError) onError(translations[language].common.error);
return;
} finally {
setIsGeneratingAudio(false);
}
}
if (!base64Data) return;
try {
if (!audioContextRef.current) {
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
}
const ctx = audioContextRef.current;
if (ctx.state === 'suspended') await ctx.resume();
const buffer = await decodeAudioData(base64Data, ctx);
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(ctx.destination);
source.onended = () => setIsPlaying(false);
source.start();
audioSourceRef.current = source;
setIsPlaying(true);
} catch (e) {
console.error("Audio playback error", e);
setIsPlaying(false);
if (onError) onError(translations[language].common.error);
}
};
const handleDownloadAudio = async () => {
let base64Data = message.metadata?.audioUrl;
if (!base64Data && message.content && message.type === MessageType.TEXT) {
try {
setIsGeneratingAudio(true);
base64Data = await geminiService.generateSpeech(message.content);
if (!base64Data) throw new Error("Audio generation returned empty");
if (onUpdateMessage) {
onUpdateMessage({ ...message, metadata: { ...message.metadata, audioUrl: base64Data } });
}
} catch (e) {
console.error(e);
if (onError) onError(translations[language].common.error);
} finally {
setIsGeneratingAudio(false);
}
}
if (base64Data) {
const filename = `sakura_audio_${Date.now()}.wav`;
processAndDownloadAudio(base64Data, filename);
}
};
const handleCopy = () => {
if (message.content) {
navigator.clipboard.writeText(message.content);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
}
};
const formatTime = (timestamp: number) => {
const date = new Date(timestamp);
const now = new Date();
const isToday = date.getDate() === now.getDate() && date.getMonth() === now.getMonth() && date.getFullYear() === now.getFullYear();
const timeStr = date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
if (isToday) return timeStr;
return `${date.toLocaleDateString()} ${timeStr}`;
};
return (
<div className={`flex w-full mb-6 animate-fade-in-up ${isUser ? 'justify-end' : 'justify-start'}`}>
<div className={`flex max-w-[95%] md:max-w-[75%] ${isUser ? 'flex-row-reverse' : 'flex-row'} gap-3`}>
{/* Avatar */}
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 shadow-md transform transition-transform hover:scale-110 ${isUser ? 'bg-indigo-600' : 'bg-pink-500'}`}>
{isUser ? <User size={16} className="text-white" /> : <Bot size={16} className="text-white" />}
</div>
{/* Content Bubble */}
<div className={`flex flex-col ${isUser ? 'items-end' : 'items-start'} min-w-0 w-full group`}>
{/* Metadata Badges */}
{message.metadata?.isThinking && (
<span className="text-[10px] flex items-center gap-1 text-amber-700 bg-amber-50 px-2 py-0.5 rounded-full mb-1 border border-amber-200 animate-pulse font-bold">
<BrainCircuit size={10} /> {t.deepThinking}
</span>
)}
<div className={`rounded-2xl p-4 shadow-sm border transition-shadow hover:shadow-md overflow-hidden w-full relative ${
isUser
? 'bg-indigo-600 text-white rounded-tr-sm border-transparent'
: 'bg-white border-pink-100 text-slate-800 rounded-tl-sm'
}`}>
{/* TEXT CONTENT - MARKDOWN RENDERED */}
{message.content && (
<div className={`
text-sm md:text-base leading-relaxed
${isUser ? 'prose-invert text-white' : 'prose-slate text-slate-800'}
prose prose-p:my-1 prose-headings:my-2 prose-strong:font-bold prose-code:bg-black/10 prose-code:rounded prose-code:px-1 prose-code:py-0.5 prose-pre:bg-slate-900 prose-pre:text-slate-100 prose-pre:rounded-lg max-w-none
`}>
{message.type === MessageType.TEXT ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.content}
</ReactMarkdown>
) : (
message.content
)}
</div>
)}
{/* IMAGE CONTENT */}
{message.type === MessageType.IMAGE && message.metadata?.imageUrl && (
<div className="mt-2 overflow-hidden rounded-lg border border-white/20 animate-scale-in">
<img src={message.metadata.imageUrl} alt="Uploaded or Generated" className="max-w-full h-auto object-cover" />
</div>
)}
{message.type === MessageType.TEXT && message.metadata?.imageUrl && (
<div className="mt-2 overflow-hidden rounded-lg border border-slate-200 animate-scale-in">
<img src={message.metadata.imageUrl} alt="Context" className="max-w-[150px] h-auto object-cover opacity-90 hover:opacity-100 transition-opacity rounded-md" />
<div className="text-[10px] opacity-70 p-1">{t.imageAnalyzed}</div>
</div>
)}
{/* Action Bar (Copy, TTS, Download) - Always visible for Text messages or if audioUrl exists */}
{(message.type === MessageType.TEXT || message.metadata?.audioUrl) && (
<div className={`flex items-center gap-2 mt-3 pt-2 border-t ${isUser ? 'border-white/20' : 'border-slate-100'}`}>
{/* Play TTS */}
<button
onClick={handlePlayAudio}
disabled={isGeneratingAudio}
className={`flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs font-bold transition-colors active:scale-95 ${
isUser
? 'hover:bg-white/10 text-indigo-100'
: 'hover:bg-pink-50 text-pink-500'
}`}
title={isUser ? t.playUserAudio : t.listenPronunciation}
>
{isGeneratingAudio ? <Loader2 size={14} className="animate-spin" /> : isPlaying ? <Pause size={14} className="animate-pulse" /> : <Volume2 size={14} />}
<span className="opacity-80">{isUser ? t.playUserAudio : t.listenPronunciation}</span>
</button>
{/* Download Audio */}
<button
onClick={handleDownloadAudio}
disabled={isGeneratingAudio}
className={`p-1.5 rounded-lg transition-colors active:scale-95 ${
isUser
? 'hover:bg-white/10 text-indigo-100'
: 'hover:bg-slate-100 text-slate-400 hover:text-slate-600'
}`}
title={tCommon.download}
>
{isGeneratingAudio ? <Loader2 size={14} className="animate-spin" /> : <Download size={14} />}
</button>
{/* Copy Text */}
<button
onClick={handleCopy}
className={`p-1.5 rounded-lg transition-colors active:scale-95 ml-auto flex items-center gap-1 ${
isUser
? 'hover:bg-white/10 text-indigo-100'
: 'hover:bg-slate-100 text-slate-400 hover:text-slate-600'
}`}
title={tCommon.copy}
>
{isCopied ? <Check size={14} /> : <Copy size={14} />}
{isCopied && <span className="text-[10px]">{tCommon.copied}</span>}
</button>
</div>
)}
</div>
{/* Footer (Timestamp + Model) */}
<div className="mt-1 flex items-center justify-between w-full px-1">
<span className="text-[10px] text-slate-400 font-medium opacity-70">
{formatTime(message.timestamp)}
</span>
{!isUser && message.model && (
<div className="flex items-center gap-1 text-[9px] text-slate-400 font-medium uppercase tracking-wide opacity-70">
<Sparkles size={8} /> {tCommon.generatedBy} {message.model.replace('gemini-', '')}
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default ChatBubble;

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { AlertTriangle, X } from 'lucide-react';
import { Language } from '../types';
import { translations } from '../utils/localization';
interface ConfirmModalProps {
isOpen: boolean;
title: string;
message: string;
language: Language;
onConfirm: () => void;
onCancel: () => void;
}
const ConfirmModal: React.FC<ConfirmModalProps> = ({ isOpen, title, message, language, onConfirm, onCancel }) => {
if (!isOpen) return null;
const t = translations[language].common;
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm animate-fade-in p-4">
<div className="bg-white w-full max-w-sm rounded-2xl shadow-2xl p-6 animate-scale-in relative">
<button onClick={onCancel} className="absolute top-4 right-4 text-slate-400 hover:text-slate-600">
<X size={20} />
</button>
<div className="flex flex-col items-center text-center">
<div className="w-12 h-12 bg-red-50 text-red-500 rounded-full flex items-center justify-center mb-4">
<AlertTriangle size={24} />
</div>
<h3 className="text-lg font-bold text-slate-800 mb-2">{title}</h3>
<p className="text-sm text-slate-500 mb-6">{message}</p>
<div className="flex gap-3 w-full">
<button
onClick={onCancel}
className="flex-1 py-2.5 px-4 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-xl font-bold transition-colors"
>
{t.cancel}
</button>
<button
onClick={onConfirm}
className="flex-1 py-2.5 px-4 bg-red-500 hover:bg-red-600 text-white rounded-xl font-bold transition-colors shadow-lg shadow-red-200"
>
{t.confirm}
</button>
</div>
</div>
</div>
</div>
);
};
export default ConfirmModal;

127
components/Onboarding.tsx Normal file
View File

@@ -0,0 +1,127 @@
import React, { useState } from 'react';
import { MessageCircle, Sparkles, Mic2, BookOpen, X, ArrowRight, Check, Globe } from 'lucide-react';
import { Language } from '../types';
import { translations } from '../utils/localization';
interface OnboardingProps {
language: Language;
setLanguage: (lang: Language) => void;
onComplete: () => void;
}
const Onboarding: React.FC<OnboardingProps> = ({ language, setLanguage, onComplete }) => {
const t = translations[language].onboarding;
const [step, setStep] = useState(0);
const steps = [
{
title: t.step1Title,
desc: t.step1Desc,
icon: <MessageCircle size={48} className="text-indigo-500" />,
color: 'bg-indigo-50',
},
{
title: t.step2Title,
desc: t.step2Desc,
icon: <Mic2 size={48} className="text-orange-500" />,
color: 'bg-orange-50',
},
{
title: t.step3Title,
desc: t.step3Desc,
icon: <Sparkles size={48} className="text-blue-500" />,
color: 'bg-blue-50',
}
];
const handleNext = () => {
if (step < steps.length - 1) {
setStep(step + 1);
} else {
onComplete();
}
};
return (
<div className="fixed inset-0 z-[100] bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4 animate-fade-in">
<div className="bg-white rounded-3xl shadow-2xl max-w-md w-full overflow-hidden relative animate-scale-in">
{/* Skip/Close */}
<button onClick={onComplete} className="absolute top-4 right-4 text-white/80 hover:text-white z-10">
<X size={24} />
</button>
{/* Header Image/Graphic */}
<div className="h-48 bg-gradient-to-br from-pink-400 to-rose-500 flex flex-col items-center justify-center relative overflow-hidden">
<div className="absolute -bottom-10 -left-10 w-32 h-32 bg-white/20 rounded-full blur-xl"></div>
<div className="absolute top-10 right-10 w-20 h-20 bg-white/20 rounded-full blur-lg"></div>
<Sparkles className="text-white w-16 h-16 mb-2 animate-pulse" />
<h2 className="text-2xl font-extrabold text-white tracking-tight">Sakura Sensei</h2>
{/* Language Switcher in Header (Step 0) */}
{step === 0 && (
<div className="mt-4 flex gap-2 bg-white/20 p-1 rounded-full backdrop-blur-sm">
{(['en', 'ja', 'zh'] as Language[]).map(lang => (
<button
key={lang}
onClick={() => setLanguage(lang)}
className={`px-3 py-1 rounded-full text-xs font-bold transition-all ${
language === lang ? 'bg-white text-rose-500 shadow-md' : 'text-white hover:bg-white/20'
}`}
>
{lang === 'en' ? 'English' : lang === 'ja' ? '日本語' : '中文'}
</button>
))}
</div>
)}
</div>
<div className="p-8">
<div className="mb-6">
<h3 className="text-2xl font-bold text-slate-800 mb-2">{t.welcome}</h3>
<p className="text-slate-500">{t.desc1}</p>
</div>
{/* Step Card */}
<div className="relative h-48">
{steps.map((s, idx) => (
<div
key={idx}
className={`absolute inset-0 flex flex-col items-center text-center transition-all duration-500 transform ${
idx === step ? 'opacity-100 translate-x-0' : idx < step ? 'opacity-0 -translate-x-full' : 'opacity-0 translate-x-full'
}`}
>
<div className={`w-20 h-20 rounded-2xl ${s.color} flex items-center justify-center mb-4 shadow-inner`}>
{s.icon}
</div>
<h4 className="text-lg font-bold text-slate-800 mb-2">{s.title}</h4>
<p className="text-sm text-slate-500 leading-relaxed">{s.desc}</p>
</div>
))}
</div>
{/* Controls */}
<div className="flex items-center justify-between mt-8">
{/* Indicators */}
<div className="flex gap-2">
{steps.map((_, idx) => (
<div key={idx} className={`h-2 rounded-full transition-all duration-300 ${idx === step ? 'w-6 bg-indigo-500' : 'w-2 bg-slate-200'}`} />
))}
</div>
<button
onClick={handleNext}
className="flex items-center gap-2 px-6 py-3 bg-slate-900 text-white rounded-xl font-bold hover:bg-slate-800 transition-all active:scale-95 shadow-lg"
>
{step === steps.length - 1 ? t.startBtn : translations[language].common.next}
{step === steps.length - 1 ? <Check size={18} /> : <ArrowRight size={18} />}
</button>
</div>
</div>
</div>
</div>
);
};
export default Onboarding;

64
components/Toast.tsx Normal file
View File

@@ -0,0 +1,64 @@
import React, { useEffect } from 'react';
import { CheckCircle, AlertCircle, X } from 'lucide-react';
export interface ToastMessage {
id: string;
type: 'success' | 'error' | 'info';
message: string;
}
interface ToastProps {
toasts: ToastMessage[];
onRemove: (id: string) => void;
}
const ToastContainer: React.FC<ToastProps> = ({ toasts, onRemove }) => {
return (
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 z-[100] flex flex-col gap-2 w-full max-w-md px-4">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onRemove={onRemove} />
))}
</div>
);
};
const ToastItem: React.FC<{ toast: ToastMessage; onRemove: (id: string) => void }> = ({ toast, onRemove }) => {
useEffect(() => {
const timer = setTimeout(() => {
onRemove(toast.id);
}, 3000);
return () => clearTimeout(timer);
}, [toast.id, onRemove]);
const getStyles = () => {
switch (toast.type) {
case 'success':
return 'bg-emerald-50 border-emerald-100 text-emerald-700';
case 'error':
return 'bg-red-50 border-red-100 text-red-700';
default:
return 'bg-indigo-50 border-indigo-100 text-indigo-700';
}
};
const getIcon = () => {
switch (toast.type) {
case 'success': return <CheckCircle size={20} className="text-emerald-500" />;
case 'error': return <AlertCircle size={20} className="text-red-500" />;
default: return <AlertCircle size={20} className="text-indigo-500" />;
}
};
return (
<div className={`flex items-center gap-3 p-4 rounded-2xl border shadow-lg animate-fade-in-up ${getStyles()}`}>
{getIcon()}
<p className="text-sm font-bold flex-1">{toast.message}</p>
<button onClick={() => onRemove(toast.id)} className="opacity-50 hover:opacity-100">
<X size={16} />
</button>
</div>
);
};
export default ToastContainer;

84
index.html Normal file
View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#ffffff" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, interactive-widget=resizes-content" />
<title>Sakura Sensei 🌸</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@300;400;500;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Nunito', 'Noto Sans JP', sans-serif;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Animations */
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
@keyframes slideInRight { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } }
@keyframes indeterminate {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.animate-fade-in-up { animation: fadeInUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
.animate-fade-in { animation: fadeIn 0.4s ease-out forwards; }
.animate-scale-in { animation: scaleIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
.animate-slide-in-right { animation: slideInRight 0.4s ease-out forwards; }
.animate-indeterminate { animation: indeterminate 1.5s infinite linear; }
.delay-100 { animation-delay: 100ms; }
.delay-200 { animation-delay: 200ms; }
.delay-300 { animation-delay: 300ms; }
.delay-500 { animation-delay: 500ms; }
</style>
<script type="importmap">
{
"imports": {
"@vitejs/plugin-react": "https://aistudiocdn.com/@vitejs/plugin-react@^5.1.1",
"react/": "https://aistudiocdn.com/react@^19.2.0/",
"react": "https://aistudiocdn.com/react@^19.2.0",
"react-markdown": "https://aistudiocdn.com/react-markdown@^10.1.0",
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/",
"remark-gfm": "https://aistudiocdn.com/remark-gfm@^4.0.1",
"@google/genai": "https://aistudiocdn.com/@google/genai@^1.30.0",
"lucide-react": "https://aistudiocdn.com/lucide-react@^0.554.0",
"vite": "https://aistudiocdn.com/vite@^7.2.2",
"html2canvas": "https://aistudiocdn.com/html2canvas@^1.4.1"
}
}
</script>
</head>
<body class="bg-slate-50 text-slate-800 h-[100dvh] overflow-hidden">
<div id="root" class="h-full w-full"></div>
<script type="module" src="/index.tsx"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js')
.catch(err => console.log('ServiceWorker registration failed: ', err));
});
}
</script>
</body>
</html>

15
index.tsx Normal file
View File

@@ -0,0 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

22
manifest.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "Sakura Sensei",
"short_name": "Sakura",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"icons": [
{
"src": "https://api.iconify.design/twemoji:cherry-blossom.svg",
"sizes": "192x192",
"type": "image/svg+xml",
"purpose": "any maskable"
},
{
"src": "https://api.iconify.design/twemoji:cherry-blossom.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}

8
metadata.json Normal file
View File

@@ -0,0 +1,8 @@
{
"name": "Sakura Sensei 🌸 - AI Japanese Tutor",
"description": "Immerse yourself in Japanese with Sakura Sensei. Experience realistic roleplay, deep cultural insights, and creative tools powered by Gemini.",
"requestFramePermissions": [
"microphone",
"camera"
]
}

19
nginx.conf Normal file
View File

@@ -0,0 +1,19 @@
server {
listen 8080;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, no-transform";
}
}

31
package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "sakura-sensei",
"private": true,
"version": "1.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@google/genai": "*",
"html2canvas": "^1.4.1",
"lucide-react": "^0.344.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0"
},
"devDependencies": {
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"@tailwindcss/typography": "^0.5.10",
"typescript": "^5.4.2",
"vite": "^5.1.6"
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

42
service-worker.js Normal file
View File

@@ -0,0 +1,42 @@
const CACHE_NAME = 'sakura-sensei-v1';
const urlsToCache = [
'/',
'/index.html',
'/manifest.json'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
return cache.addAll(urlsToCache);
})
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => {
if (response) {
return response;
}
return fetch(event.request);
})
);
});
self.addEventListener('activate', (event) => {
const cacheWhitelist = [CACHE_NAME];
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheWhitelist.indexOf(cacheName) === -1) {
return caches.delete(cacheName);
}
})
);
})
);
});

543
services/geminiService.ts Normal file
View File

@@ -0,0 +1,543 @@
import { GoogleGenAI, Modality, Type } from "@google/genai";
import { PronunciationFeedback, Language, ReadingLesson, ReadingDifficulty, OCRAnalysis, ListeningLesson } from "../types";
import { base64ToUint8Array, uint8ArrayToBase64 } from "../utils/audioUtils";
export const USER_API_KEY_STORAGE = 'sakura_user_api_key';
export const USER_BASE_URL_STORAGE = 'sakura_user_base_url';
// Helper to decode audio for playback
// Updated to support raw PCM (typically returned by Gemini TTS) which browser cannot decode automatically
export const decodeAudioData = async (
base64Data: string,
audioContext: AudioContext
): Promise<AudioBuffer> => {
const binaryString = atob(base64Data);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
try {
// Try standard decoding first (wav/mp3 containers)
// We clone the buffer because decodeAudioData detaches it
return await audioContext.decodeAudioData(bytes.buffer.slice(0));
} catch (e) {
// Fallback: Treat as raw PCM (16-bit, 24kHz default for Gemini TTS, or 16kHz)
// Assuming 24kHz Mono 16-bit Little Endian based on typical Gemini TTS raw output
const pcmData = new Int16Array(bytes.buffer);
const float32Data = new Float32Array(pcmData.length);
for (let i = 0; i < pcmData.length; i++) {
// Convert int16 to float32 (-1.0 to 1.0)
float32Data[i] = pcmData[i] / 32768.0;
}
// Create buffer: 1 channel, length, 24000 sample rate
const audioBuffer = audioContext.createBuffer(1, float32Data.length, 24000);
audioBuffer.getChannelData(0).set(float32Data);
return audioBuffer;
}
};
// Helper to check/request Veo key
export const ensureVeoKey = async (): Promise<void> => {
// @ts-ignore
if (window.aistudio) {
// @ts-ignore
const hasKey = await window.aistudio.hasSelectedApiKey();
if (!hasKey) {
// @ts-ignore
await window.aistudio.openSelectKey();
}
}
};
const LANGUAGE_MAP = {
en: "English",
ja: "Japanese",
zh: "Chinese (Simplified)"
};
class GeminiService {
private getAi() {
const userKey = localStorage.getItem(USER_API_KEY_STORAGE);
const userBaseUrl = localStorage.getItem(USER_BASE_URL_STORAGE);
const envKey = process.env.API_KEY;
const keyToUse = (userKey && userKey.trim().length > 0) ? userKey : envKey;
if (!keyToUse) {
console.error("API_KEY is missing.");
throw new Error("API Key is missing");
}
const config: any = { apiKey: keyToUse };
if (userBaseUrl && userBaseUrl.trim().length > 0) {
config.baseUrl = userBaseUrl.trim();
}
return new GoogleGenAI(config);
}
private async getApiKey(): Promise<string> {
const userKey = localStorage.getItem(USER_API_KEY_STORAGE);
const envKey = process.env.API_KEY;
const key = (userKey && userKey.trim().length > 0) ? userKey : envKey;
if (!key) throw new Error("No API Key available");
return key;
}
private async retryOperation<T>(operation: () => Promise<T>, retries = 3, delay = 1000): Promise<T> {
try {
return await operation();
} catch (error: any) {
const isOverloaded =
error?.status === 503 ||
error?.response?.status === 503 ||
error?.message?.includes('503') ||
error?.message?.includes('overloaded');
if (isOverloaded && retries > 0) {
console.warn(`Model overloaded (503). Retrying...`);
await new Promise(resolve => setTimeout(resolve, delay));
return this.retryOperation(operation, retries - 1, delay * 2);
}
throw error;
}
}
// 1. Text Chat Response - Returns { text, model }
async generateTextResponse(
prompt: string,
imageBase64?: string,
useThinking: boolean = false,
language: Language = 'en',
modelOverride?: string,
aiSpeakingLanguage: 'ja' | 'native' = 'native'
): Promise<{ text: string, model: string }> {
const ai = this.getAi();
let modelName = useThinking
? 'gemini-3-pro-preview'
: (imageBase64 ? 'gemini-3-pro-preview' : (modelOverride || 'gemini-2.5-flash'));
const targetLangName = LANGUAGE_MAP[language];
const parts: any[] = [];
if (imageBase64) {
parts.push({
inlineData: {
mimeType: 'image/jpeg',
data: imageBase64
}
});
parts.push({ text: `Analyze this image in the context of learning Japanese. Explain in ${targetLangName}: ` + prompt });
} else {
parts.push({ text: prompt });
}
let instruction = "";
if (aiSpeakingLanguage === 'ja') {
instruction = `You are Sakura, a Japanese language tutor.
IMPORTANT:
- Respond primarily in Japanese (日本語) to help the user practice immersion.
- Only use ${targetLangName} for complex grammar explanations or if the user asks specifically for a translation.
- Keep the tone encouraging and natural.`;
} else {
instruction = `You are Sakura, a friendly, encouraging, and highly skilled Japanese language tutor. You help users learn vocabulary, grammar, listening, and speaking. You provide clear explanations, examples, and translations.
IMPORTANT:
- You are teaching Japanese.
- However, the user speaks ${targetLangName}.
- Provide your explanations, translations, and feedback in ${targetLangName}.`;
}
const config: any = {
systemInstruction: instruction,
};
if (useThinking) {
config.thinkingConfig = { thinkingBudget: 32768 };
}
return this.retryOperation(async () => {
const response = await ai.models.generateContent({
model: modelName,
contents: { parts },
config: config
});
return {
text: response.text || "I apologize, I couldn't generate a response.",
model: modelName
};
});
}
// Internal helper for single TTS chunk
private async _generateSpeechChunk(text: string): Promise<string | null> {
const ai = this.getAi();
return this.retryOperation(async () => {
try {
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash-preview-tts',
contents: [{ parts: [{ text }] }],
config: {
responseModalities: [Modality.AUDIO],
speechConfig: {
voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Kore' } },
},
},
});
return response.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data || null;
} catch (e) {
console.error("TTS Chunk Error", e);
return null;
}
});
}
async generateSpeech(text: string): Promise<string | null> {
if (!text || !text.trim()) return null;
const MAX_CHUNK_LENGTH = 250; // Safe limit to prevent network timeout on long generation
// If text is short, process directly
if (text.length <= MAX_CHUNK_LENGTH) {
return this._generateSpeechChunk(text);
}
// Split text into chunks by sentence to avoid breaking words
const regex = /[^。!?.!?\n]+[。!?.!?\n]*|[^。!?.!?\n]+$/g;
const sentences = text.match(regex) || [text];
const chunks: string[] = [];
let currentChunk = '';
for (const sentence of sentences) {
if ((currentChunk + sentence).length > MAX_CHUNK_LENGTH) {
if (currentChunk) chunks.push(currentChunk);
currentChunk = sentence;
// Force split if a single sentence exceeds max length
while (currentChunk.length > MAX_CHUNK_LENGTH) {
chunks.push(currentChunk.slice(0, MAX_CHUNK_LENGTH));
currentChunk = currentChunk.slice(MAX_CHUNK_LENGTH);
}
} else {
currentChunk += sentence;
}
}
if (currentChunk) chunks.push(currentChunk);
try {
// Generate chunks in parallel to speed up total time
// Note: Promise.all order is preserved
const results = await Promise.all(chunks.map(chunk => this._generateSpeechChunk(chunk)));
// If any chunk failed, the whole audio is compromised
if (results.some(r => r === null)) return null;
// Convert Base64 -> Uint8Array
const audioSegments = results.map(r => base64ToUint8Array(r!));
// Concatenate raw PCM data
const totalLength = audioSegments.reduce((acc, cur) => acc + cur.length, 0);
const combined = new Uint8Array(totalLength);
let offset = 0;
for (const seg of audioSegments) {
combined.set(seg, offset);
offset += seg.length;
}
// Convert back to Base64 for playback/storage
return uint8ArrayToBase64(combined);
} catch (e) {
console.error("TTS Assembly Error", e);
return null;
}
}
async transcribeAudio(audioBase64: string): Promise<string> {
const ai = this.getAi();
return this.retryOperation(async () => {
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: {
parts: [
{ inlineData: { mimeType: 'audio/wav', data: audioBase64 } },
{ text: "Transcribe accurately." },
],
},
});
return response.text || "";
});
}
async generateImage(prompt: string): Promise<string | null> {
const ai = this.getAi();
return this.retryOperation(async () => {
try {
const response = await ai.models.generateImages({
model: 'imagen-4.0-generate-001',
prompt: prompt + " style of a japanese textbook illustration",
config: { numberOfImages: 1, outputMimeType: 'image/jpeg', aspectRatio: '1:1' },
});
const bytes = response.generatedImages?.[0]?.image?.imageBytes;
return bytes ? `data:image/jpeg;base64,${bytes}` : null;
} catch (e) {
console.error("Image Gen Error", e);
return null;
}
});
}
async editImage(base64Original: string, prompt: string): Promise<string | null> {
const ai = this.getAi();
return this.retryOperation(async () => {
try {
const cleanBase64 = base64Original.replace(/^data:image\/(png|jpeg|jpg|webp|heic|heif);base64,/i, "");
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash-image',
contents: {
parts: [
{ inlineData: { data: cleanBase64, mimeType: 'image/jpeg' } },
{ text: prompt }
]
},
config: { responseModalities: [Modality.IMAGE] }
});
for (const part of response.candidates?.[0]?.content?.parts || []) {
if (part.inlineData) return `data:image/png;base64,${part.inlineData.data}`;
}
return null;
} catch (e) {
console.error("Image Edit Error", e);
return null;
}
});
}
async generateVideo(prompt: string, onStatusUpdate: (status: string) => void): Promise<string | null> {
await ensureVeoKey();
const ai = this.getAi();
try {
onStatusUpdate("Initializing Veo...");
let operation = await ai.models.generateVideos({
model: 'veo-3.1-fast-generate-preview',
prompt: prompt,
config: { numberOfVideos: 1, resolution: '720p', aspectRatio: '16:9' }
});
onStatusUpdate("Dreaming up video...");
while (!operation.done) {
await new Promise(resolve => setTimeout(resolve, 5000));
operation = await ai.operations.getVideosOperation({ operation: operation });
}
const videoUri = operation.response?.generatedVideos?.[0]?.video?.uri;
if (!videoUri) return null;
const apiKey = await this.getApiKey();
const videoRes = await fetch(`${videoUri}&key=${apiKey}`);
const blob = await videoRes.blob();
return URL.createObjectURL(blob);
} catch (e) {
console.error("Veo Error", e);
return null;
}
}
async analyzeSpeakingPerformance(audioBase64: string, scenarioContext: string, historyContext: string, language: Language = 'en'): Promise<PronunciationFeedback | null> {
const ai = this.getAi();
const targetLangName = LANGUAGE_MAP[language];
const prompt = `Roleplay: ${scenarioContext}. History: ${historyContext}. Listen, Transcribe, Reply, Evaluate (JSON). Translation/Advice in ${targetLangName}.`;
return this.retryOperation(async () => {
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: {
parts: [{ inlineData: { mimeType: 'audio/wav', data: audioBase64 } }, { text: prompt }]
},
config: {
responseMimeType: "application/json",
responseSchema: {
type: Type.OBJECT,
properties: {
transcription: { type: Type.STRING },
response: { type: Type.STRING },
translation: { type: Type.STRING },
score: { type: Type.INTEGER },
pronunciationIssues: { type: Type.ARRAY, items: { type: Type.STRING } },
advice: { type: Type.STRING }
},
required: ["transcription", "response", "translation", "score", "pronunciationIssues", "advice"]
}
}
});
return response.text ? JSON.parse(response.text) : null;
});
}
async generateReadingLesson(topic: string, difficulty: ReadingDifficulty, language: Language): Promise<ReadingLesson | null> {
const ai = this.getAi();
const targetLangName = LANGUAGE_MAP[language];
const prompt = `Create a complete Japanese reading lesson on "${topic}", level ${difficulty}.
The 'japaneseContent' MUST be a complete article or story (at least 300 characters).
Output JSON with title, japaneseContent, translation (${targetLangName}), vocabulary, and grammarPoints (list of key grammar used in the text with explanations).`;
return this.retryOperation(async () => {
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: { parts: [{ text: prompt }] },
config: {
responseMimeType: "application/json",
responseSchema: {
type: Type.OBJECT,
properties: {
title: { type: Type.STRING },
japaneseContent: { type: Type.STRING },
translation: { type: Type.STRING },
vocabulary: { type: Type.ARRAY, items: { type: Type.OBJECT, properties: { word: { type: Type.STRING }, reading: { type: Type.STRING }, meaning: { type: Type.STRING } } } },
grammarPoints: { type: Type.ARRAY, items: { type: Type.OBJECT, properties: { point: { type: Type.STRING }, explanation: { type: Type.STRING } } } }
},
required: ["title", "japaneseContent", "translation", "vocabulary", "grammarPoints"]
}
}
});
return response.text ? JSON.parse(response.text) : null;
});
}
async generateListeningLesson(topic: string, difficulty: ReadingDifficulty, language: Language): Promise<ListeningLesson | null> {
const ai = this.getAi();
const targetLangName = LANGUAGE_MAP[language];
// Prompt asks for a conversation or monologue suitable for listening practice
const prompt = `Create a Japanese listening practice script on "${topic}", level ${difficulty}. It should be a conversation or monologue.
Output JSON with:
- title
- script (The full Japanese text of the conversation/monologue)
- translation (The full text in ${targetLangName})
- vocabulary (Key words)
- questions (3 multiple choice comprehension questions in ${targetLangName})
- Each question needs: question, options (array of 3 strings), correctIndex (0-2), explanation.
`;
return this.retryOperation(async () => {
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: { parts: [{ text: prompt }] },
config: {
responseMimeType: "application/json",
responseSchema: {
type: Type.OBJECT,
properties: {
title: { type: Type.STRING },
script: { type: Type.STRING },
translation: { type: Type.STRING },
vocabulary: { type: Type.ARRAY, items: { type: Type.OBJECT, properties: { word: { type: Type.STRING }, reading: { type: Type.STRING }, meaning: { type: Type.STRING } } } },
questions: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
id: { type: Type.STRING },
question: { type: Type.STRING },
options: { type: Type.ARRAY, items: { type: Type.STRING } },
correctIndex: { type: Type.INTEGER },
explanation: { type: Type.STRING }
},
required: ["question", "options", "correctIndex", "explanation"]
}
}
},
required: ["title", "script", "translation", "vocabulary", "questions"]
}
}
});
return response.text ? JSON.parse(response.text) : null;
});
}
async generateReadingTutorResponse(question: string, lesson: ReadingLesson | ListeningLesson, history: string, language: Language): Promise<string> {
const ai = this.getAi();
// Handle both ReadingLesson (japaneseContent) and ListeningLesson (script)
const content = 'japaneseContent' in lesson ? lesson.japaneseContent : lesson.script;
const prompt = `Tutor for text "${lesson.title}". Question: "${question}". History: ${history}. Explain in ${LANGUAGE_MAP[language]}.`;
return this.retryOperation(async () => {
const res = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: { parts: [{ text: prompt }] }
});
return res.text || "";
});
}
async translateText(text: string, target: string, source: string = "Auto"): Promise<string> {
const ai = this.getAi();
return this.retryOperation(async () => {
const res = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: { parts: [{ text: `Translate the following text from ${source} to ${target}.` }, { text: text }] },
config: {
responseMimeType: "application/json",
responseSchema: {
type: Type.OBJECT,
properties: { translation: { type: Type.STRING } },
required: ["translation"]
}
}
});
return (res.text ? JSON.parse(res.text).translation : "") || "";
});
}
async translateImage(base64: string, target: string, source: string = "Auto"): Promise<{ original: string; translated: string } | null> {
const ai = this.getAi();
const cleanBase64 = base64.replace(/^data:image\/(png|jpeg|jpg|webp|heic|heif);base64,/i, "");
return this.retryOperation(async () => {
const res = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: {
parts: [{ inlineData: { mimeType: 'image/jpeg', data: cleanBase64 } }, { text: `Extract text (Language: ${source}) and translate to ${target}. JSON output: original, translated.` }]
},
config: {
responseMimeType: "application/json",
responseSchema: {
type: Type.OBJECT,
properties: { original: { type: Type.STRING }, translated: { type: Type.STRING } },
required: ["original", "translated"]
}
}
});
return res.text ? JSON.parse(res.text) : null;
});
}
async extractAndAnalyzeText(base64: string, language: Language): Promise<OCRAnalysis | null> {
const ai = this.getAi();
const cleanBase64 = base64.replace(/^data:image\/(png|jpeg|jpg|webp|heic|heif);base64,/i, "");
const targetLang = LANGUAGE_MAP[language];
const prompt = `OCR and analyze text. Explain in ${targetLang}. JSON: extractedText, detectedLanguage, summary, vocabulary, grammarPoints.`;
return this.retryOperation(async () => {
const res = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: {
parts: [{ inlineData: { mimeType: 'image/jpeg', data: cleanBase64 } }, { text: prompt }]
},
config: {
responseMimeType: "application/json",
responseSchema: {
type: Type.OBJECT,
properties: {
extractedText: { type: Type.STRING },
detectedLanguage: { type: Type.STRING },
summary: { type: Type.STRING },
vocabulary: { type: Type.ARRAY, items: { type: Type.OBJECT, properties: { word: { type: Type.STRING }, reading: { type: Type.STRING }, meaning: { type: Type.STRING } } } },
grammarPoints: { type: Type.ARRAY, items: { type: Type.OBJECT, properties: { point: { type: Type.STRING }, explanation: { type: Type.STRING } } } }
},
required: ["extractedText", "detectedLanguage", "summary", "vocabulary", "grammarPoints"]
}
}
});
return res.text ? JSON.parse(res.text) : null;
});
}
}
export const geminiService = new GeminiService();

14
tailwind.config.js Normal file
View File

@@ -0,0 +1,14 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
"./**/*.{js,ts,jsx,tsx}"
],
theme: {
extend: {},
},
plugins: [
require('@tailwindcss/typography'),
],
}

20
tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["./**/*.ts", "./**/*.tsx"]
}

172
types.ts Normal file
View File

@@ -0,0 +1,172 @@
export enum Role {
USER = 'user',
MODEL = 'model',
}
export enum MessageType {
TEXT = 'text',
AUDIO = 'audio',
IMAGE = 'image',
VIDEO = 'video',
}
export interface ChatMessage {
id: string;
role: Role;
type: MessageType;
content: string; // Text content or base64/url for media
model?: string; // Model used for generation
metadata?: {
isThinking?: boolean;
audioUrl?: string; // For TTS playback or User recording
imageUrl?: string;
videoUrl?: string;
transcription?: string; // For audio inputs
};
timestamp: number;
}
// New Interface for Chat Sessions
export interface ChatSession {
id: string;
title: string;
messages: ChatMessage[];
createdAt: number;
updatedAt: number;
}
export enum AppMode {
CHAT = 'chat',
READING = 'reading',
LISTENING = 'listening', // New Listening Mode
SPEAKING = 'speaking',
CREATIVE = 'creative',
TRANSLATION = 'translation',
OCR = 'ocr',
}
export type Language = 'en' | 'ja' | 'zh';
// Specific Gemini Models
export enum ModelNames {
TEXT_FAST = 'gemini-2.5-flash',
TEXT_REASONING = 'gemini-3-pro-preview',
TTS = 'gemini-2.5-flash-preview-tts',
IMAGE_GEN = 'imagen-4.0-generate-001',
IMAGE_EDIT = 'gemini-2.5-flash-image', // Nano Banana
VIDEO_GEN = 'veo-3.1-fast-generate-preview',
TRANSCRIPTION = 'gemini-2.5-flash',
}
export const AVAILABLE_CHAT_MODELS = [
{ id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro (Default - Best Reasoning)' },
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash (Fast & Balanced)' }
];
// Speaking Mode Types
export interface PronunciationFeedback {
score: number; // 0-100
transcription: string;
response: string; // AI Reply in Japanese
translation: string; // AI Reply in English/Native Lang
pronunciationIssues: string[]; // List of specific phoneme/pitch errors
advice: string; // General advice
}
export interface Scenario {
id: string;
title: string;
icon: string;
description: string;
initialMessage: string; // What AI says first
initialTranslation?: string; // Translation of initial message
role: string; // Who the AI is
}
// Reading Mode Types
export enum ReadingDifficulty {
BEGINNER = 'beginner', // N5/N4
INTERMEDIATE = 'intermediate', // N3/N2
ADVANCED = 'advanced', // N1+
}
export interface ReadingLesson {
title: string;
japaneseContent: string;
translation: string;
vocabulary: { word: string; reading: string; meaning: string }[];
grammarPoints?: { point: string; explanation: string }[];
}
export interface ReadingLessonRecord extends ReadingLesson {
id: string;
topic: string;
difficulty: ReadingDifficulty;
timestamp: number;
chatHistory?: ChatMessage[]; // Persist tutor chat
}
// Listening Mode Types
export interface QuizQuestion {
id: string;
question: string;
options: string[];
correctIndex: number;
explanation: string;
}
export interface ListeningLesson {
title: string;
script: string; // The full Japanese text (initially hidden)
translation: string;
vocabulary: { word: string; reading: string; meaning: string }[];
questions: QuizQuestion[];
}
export interface ListeningLessonRecord extends ListeningLesson {
id: string;
topic: string;
difficulty: ReadingDifficulty;
timestamp: number;
chatHistory?: ChatMessage[];
}
// OCR Mode Types
export interface OCRAnalysis {
extractedText: string;
detectedLanguage: string;
summary: string;
vocabulary: { word: string; reading: string; meaning: string }[];
grammarPoints: { point: string; explanation: string }[];
}
export interface OCRRecord {
id: string;
timestamp: number;
imagePreview: string;
analysis: OCRAnalysis;
}
// Translation Mode Types
export interface TranslationRecord {
id: string;
sourceText: string;
targetText: string;
sourceLang: string; // e.g. 'Detected Language' or 'English'
targetLang: string; // e.g. 'Japanese'
timestamp: number;
}
// Backup Data Type
export interface AppDataBackup {
version: number;
createdAt: number;
language: Language;
chatSessions: ChatSession[];
translationHistory: TranslationRecord[];
readingHistory?: ReadingLessonRecord[];
ocrHistory?: OCRRecord[];
listeningHistory?: ListeningLessonRecord[];
}

80
utils/audioUtils.ts Normal file
View File

@@ -0,0 +1,80 @@
export const base64ToUint8Array = (base64: string) => {
const binaryString = atob(base64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
};
export const uint8ArrayToBase64 = (bytes: Uint8Array) => {
let binary = '';
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
};
export const triggerDownload = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
export const createWavFileFromPcm = (pcmData: Uint8Array, sampleRate: number = 24000, numChannels: number = 1): Blob => {
const header = new ArrayBuffer(44);
const view = new DataView(header);
const writeString = (view: DataView, offset: number, string: string) => {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
};
writeString(view, 0, 'RIFF');
view.setUint32(4, 36 + pcmData.length, true);
writeString(view, 8, 'WAVE');
writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * numChannels * 2, true);
view.setUint16(32, numChannels * 2, true);
view.setUint16(34, 16, true);
writeString(view, 36, 'data');
view.setUint32(40, pcmData.length, true);
return new Blob([view, pcmData], { type: 'audio/wav' });
};
export const processAndDownloadAudio = (base64Data: string, filename: string) => {
try {
// Check for RIFF header (WAV)
// Some base64 strings might have newlines, strip them if necessary,
// but generally atob handles it or we assume clean base64.
const binaryString = atob(base64Data.substring(0, 50).replace(/\s/g, ''));
const isWav = binaryString.startsWith('RIFF');
if (isWav) {
const bytes = base64ToUint8Array(base64Data);
const blob = new Blob([bytes], { type: 'audio/wav' });
triggerDownload(blob, filename);
} else {
// Assume Raw PCM 24kHz 16-bit Mono (Gemini Flash TTS default)
const bytes = base64ToUint8Array(base64Data);
const blob = createWavFileFromPcm(bytes, 24000, 1);
triggerDownload(blob, filename);
}
} catch (e) {
console.error("Error downloading audio", e);
}
};

926
utils/localization.ts Normal file
View File

@@ -0,0 +1,926 @@
import { Language, Scenario } from "../types";
export const getScenarios = (language: Language): Scenario[] => {
switch(language) {
case 'ja':
return [
{
id: 'cafe_order',
title: 'カフェで注文',
icon: '☕',
description: '東京のカフェでコーヒーと軽食を注文する練習。',
initialMessage: 'いらっしゃいませ!ご注文はお決まりですか?',
role: '店員'
},
{
id: 'train_station',
title: '駅での道案内',
icon: '🚄',
description: '駅員に行き先を尋ねる練習。',
initialMessage: 'はい、どうされましたか?',
role: '駅員'
},
{
id: 'conbini',
title: 'コンビニでの買い物',
icon: '🏪',
description: 'コンビニで支払いをする練習。',
initialMessage: 'お弁当温めますか?',
role: '店員'
},
{
id: 'hotel_checkin',
title: 'ホテルのチェックイン',
icon: '🏨',
description: 'ホテルのフロントでチェックインをする。',
initialMessage: 'いらっしゃいませ。チェックインでございますか?',
role: 'フロント係'
},
{
id: 'immigration',
title: '入国審査',
icon: '🛂',
description: '空港の入国審査で質問に答える練習。',
initialMessage: '次の方どうぞ。パスポートを見せてください。',
role: '審査官'
},
{
id: 'boarding',
title: '搭乗手続き',
icon: '✈️',
description: '搭乗ゲートでのやり取り。',
initialMessage: 'ご搭乗ありがとうございます。パスポートと搭乗券を拝見します。',
role: '地上係員'
}
];
case 'zh':
return [
{
id: 'cafe_order',
title: '咖啡厅点单',
icon: '☕',
description: '练习在东京的咖啡厅点咖啡和小吃。',
initialMessage: 'いらっしゃいませ!ご注文はお決まりですか?',
initialTranslation: '欢迎光临!决定好要点什么了吗?',
role: '店员'
},
{
id: 'train_station',
title: '车站问路',
icon: '🚄',
description: '练习询问车站工作人员路线。',
initialMessage: 'はい、どうされましたか?',
initialTranslation: '您好,有什么可以帮您的吗?',
role: '站务员'
},
{
id: 'conbini',
title: '便利店购物',
icon: '🏪',
description: '练习在便利店结账。',
initialMessage: 'お弁当温めますか?',
initialTranslation: '便当需要加热吗?',
role: '店员'
},
{
id: 'hotel_checkin',
title: '酒店入住',
icon: '🏨',
description: '在酒店前台办理入住手续。',
initialMessage: 'いらっしゃいませ。チェックインでございますか?',
initialTranslation: '欢迎光临。是办理入住吗?',
role: '前台接待'
},
{
id: 'immigration',
title: '入境审查',
icon: '🛂',
description: '练习在机场回答入境审查官的提问。',
initialMessage: '次の方どうぞ。パスポートを見せてください。',
initialTranslation: '下一位。请出示您的护照。',
role: '审查官'
},
{
id: 'boarding',
title: '登机手续',
icon: '✈️',
description: '练习登机口的对话。',
initialMessage: 'ご搭乗ありがとうございます。パスポートと搭乗券を拝見します。',
initialTranslation: '感谢您的搭乘。请出示护照和登机牌。',
role: '地勤人员'
}
];
default: // en
return [
{
id: 'cafe_order',
title: 'Ordering at a Cafe',
icon: '☕',
description: 'Practice ordering coffee and snacks at a cafe in Tokyo.',
initialMessage: 'いらっしゃいませ!ご注文はお決まりですか?',
initialTranslation: 'Welcome! Have you decided on your order?',
role: 'Barista'
},
{
id: 'train_station',
title: 'Asking Directions',
icon: '🚄',
description: 'Practice asking a station attendant for help finding a platform.',
initialMessage: 'はい、どうされましたか?',
initialTranslation: 'Yes, how can I help you?',
role: 'Station Attendant'
},
{
id: 'conbini',
title: 'Convenience Store',
icon: '🏪',
description: 'Buying items at a Konbini.',
initialMessage: 'お弁当温めますか?',
initialTranslation: 'Would you like your bento warmed up?',
role: 'Clerk'
},
{
id: 'hotel_checkin',
title: 'Hotel Check-in',
icon: '🏨',
description: 'Checking into a hotel.',
initialMessage: 'いらっしゃいませ。チェックインでございますか?',
initialTranslation: 'Welcome. Are you checking in?',
role: 'Receptionist'
},
{
id: 'immigration',
title: 'Immigration',
icon: '🛂',
description: 'Answering questions at airport immigration control.',
initialMessage: '次の方どうぞ。パスポートを見せてください。',
initialTranslation: 'Next person, please. Show me your passport.',
role: 'Officer'
},
{
id: 'boarding',
title: 'Boarding Gate',
icon: '✈️',
description: 'Interacting with staff at the boarding gate.',
initialMessage: 'ご搭乗ありがとうございます。パスポートと搭乗券を拝見します。',
initialTranslation: 'Thank you for boarding. May I see your passport and boarding pass?',
role: 'Ground Staff'
}
];
}
};
export const translations = {
en: {
appTitle: "Sakura Sensei 🌸",
nav: {
sectionStudy: "Study & Input",
sectionPractice: "Practice & Output",
sectionTools: "Toolbox",
sectionImmersion: "Immersion",
chat: "Tutor Dojo",
reading: "Reading Hall",
listening: "Listening Lab",
speaking: "Roleplay",
creative: "Atelier",
translation: "Translator",
ocr: "Scanner",
settings: "Settings"
},
common: {
cancel: "Cancel",
confirm: "Confirm",
delete: "Delete",
next: "Next",
generatedBy: "Generated by",
error: "Error occurred",
poweredBy: "Powered by Gemini",
deleteItemConfirm: "Are you sure you want to delete this item?",
clearHistoryConfirm: "Are you sure you want to clear the entire history?",
save: "Save",
download: "Download",
content: "Content",
tutor: "Tutor",
text: "Text",
explanation: "Explanation",
clear: "Clear",
copy: "Copy",
copied: "Copied!",
share: "Share",
shareImage: "Image",
shareText: "Text",
shareFile: "File (TXT)",
aiLanguage: "AI Language",
langJa: "Japanese",
langNative: "User Language",
today: "Today",
yesterday: "Yesterday"
},
onboarding: {
welcome: "Welcome to Sakura Sensei!",
desc1: "Your AI-powered companion for mastering Japanese.",
step1Title: "Conversational Tutor",
step1Desc: "Chat with Sakura (Gemini 3 Pro) to practice grammar or ask cultural questions.",
step2Title: "Immersive Practice",
step2Desc: "Roleplay realistic scenarios, generate reading materials, and scan real-world text.",
step3Title: "Creative Tools",
step3Desc: "Generate images and videos to visualize your learning journey.",
startBtn: "Start Learning",
selectLang: "Select Interface Language"
},
chat: {
welcome: "Konnichiwa! 🌸 I am Sakura. How can I help you with your Japanese today?",
inputPlaceholder: "Send a message...",
thinkingPlaceholder: "Reasoning about grammar...",
imageAttached: "Image attached",
sending: "Sakura is thinking...",
error: "Connection lost.",
locationError: "Region not supported. Please configure a Proxy URL in Settings.",
playUserAudio: "Play Recording",
listenPronunciation: "Listen",
deepThinking: "Deep Thought",
imageAnalyzed: "Image Analyzed",
thinkingToggle: "Thinking Mode",
newChat: "New Chat",
history: "Chat History",
noHistory: "No previous chats.",
deleteChat: "Delete",
deleteConfirm: "Are you sure you want to delete this chat session?",
untitled: "Untitled Chat",
transcribedPrefix: "(Transcribed): "
},
creative: {
title: "Creative Atelier 🎨",
genImage: "Paint",
editImage: "Magic Edit",
genVideo: "Dream Video",
promptLabel: "Your Vision",
editLabel1: "1. Base Photo",
editLabel2: "2. Instruction",
uploadPlaceholder: "Drop an image here",
generateBtn: "Generate",
creatingBtn: "Creating...",
download: "Download",
videoWarning: "* Video generation (Veo) takes time.",
emptyState: "Your masterpiece will appear here",
imagePrompt: "Cyberpunk samurai cat in neon Tokyo...",
editPrompt: "Turn trees into cherry blossoms...",
videoPrompt: "Traditional tea ceremony in a futuristic garden...",
uploadAlert: "Please upload an image first!"
},
speaking: {
title: "Conversation Dojo 🗣️",
subtitle: "Roleplay in realistic scenarios. Get instant feedback on accent and fluency.",
back: "Exit",
listening: "Listening...",
tapSpeak: "Tap to Speak",
processing: "Analyzing...",
feedbackTitle: "Sensei's Report",
score: "Fluency",
toImprove: "Corrections",
advice: "Advice",
transcription: "You Said",
meaning: "Meaning",
perfect: "Sugoi! Perfect pronunciation! 🎉",
emptyFeedback: "Speak clearly to get feedback.",
replay: "Replay",
start: "Start",
roleplay: "Role",
translation: "Translation"
},
reading: {
title: "Reading Hall 📜",
subtitle: "Generate custom reading lessons based on your level.",
topicLabel: "Topic",
difficultyLabel: "Level",
levels: {
beginner: "Beginner (N5-N4)",
intermediate: "Intermediate (N3-N2)",
advanced: "Advanced (N1)"
},
generate: "Create Lesson",
generating: "Writing...",
translationToggle: "Translation",
vocabTitle: "Vocabulary",
grammarHeader: "Grammar",
qaTitle: "Tutor Chat",
qaPlaceholder: "Ask about this text...",
qaWelcome: "Lesson generated. Ask me anything about the text!",
historyTitle: "Library",
loadMore: "Open",
emptyHistory: "Empty Library",
clear: "Clear",
placeholder: "e.g. Kyoto History, Anime Culture",
translationLabel: "Translation",
thinking: "thinking...",
playAudio: "Listen",
stopAudio: "Stop",
contentMissing: "No text generated. Please try creating a new lesson with a more specific topic.",
translationMissing: "No translation available."
},
listening: {
title: "Listening Lab 🎧",
subtitle: "Train your ears with AI-generated conversations and quizzes.",
generate: "Create Practice",
generating: "Composing...",
play: "Play Audio",
pause: "Pause",
replay: "Replay",
showScript: "Show Transcript",
hideScript: "Hide Transcript",
quizTitle: "Comprehension Quiz",
check: "Check Answer",
correct: "Correct!",
incorrect: "Incorrect, try again.",
scriptTitle: "Transcript",
historyTitle: "Practice Log",
emptyHistory: "No practice logs",
qaWelcome: "I've generated a listening exercise. Listen to the audio first, try the quiz, then ask me anything!",
noScript: "No script available to play.",
scriptMissing: "No script generated. Please try generating again."
},
ocr: {
title: "Text Scanner 🔍",
subtitle: "Scan text (books, menus) to create study guides.",
uploadBtn: "Upload",
cameraBtn: "Camera",
processing: "Scanning...",
extractedTitle: "Extracted Text",
analysisTitle: "Study Notes",
vocabHeader: "Vocabulary",
grammarHeader: "Grammar",
summaryHeader: "Summary",
chatPlaceholder: "Ask about this text...",
reScan: "New Scan",
error: "Could not analyze image.",
history: "Scan History",
emptyHistory: "No scans yet",
clear: "Clear",
analyzedIntro: "Analyzed (Language: $lang). Ask me anything!",
historyIntro: "Loaded from history (Language: $lang).",
tutorChat: "Tutor Chat",
thinking: "thinking...",
analysisFailed: "Analysis failed."
},
translation: {
title: "Translator",
inputLabel: "Input",
outputLabel: "Translation",
translateBtn: "Translate",
translating: "Translating...",
extracting: "Scanning...",
scanImage: "Camera",
uploadImage: "Image",
sourceLang: "Source",
targetLang: "Target",
history: "Translator History",
clear: "Clear",
copy: "Copy",
langs: {
auto: "Auto Detect",
en: "English",
ja: "Japanese",
zh: "Chinese",
ko: "Korean",
fr: "French",
es: "Spanish"
},
errorTranslating: "Error translating.",
imageReadError: "Could not read text from image.",
imageTransError: "Image translation failed."
},
settings: {
title: "Settings & Data",
backupTitle: "Backup",
backupDesc: "Download all data locally.",
backupBtn: "Backup",
restoreDesc: "Restore from backup file.",
restoreBtn: "Restore",
exportTitle: "Export",
exportChatBtn: "Chat Log (TXT)",
exportTransBtn: "Translations (CSV)",
exportReadingBtn: "Reading History (JSON)",
exportOCRBtn: "Scan History (JSON)",
successRestore: "Restored successfully!",
errorRestore: "Invalid file.",
apiKeyTitle: "API Configuration",
apiKeyDesc: "Configure your Gemini API access.",
apiKeyPlaceholder: "Paste API Key",
baseUrlPlaceholder: "Base URL (Optional, e.g. for Proxy)",
apiKeyMissing: "API Key is required.",
saveKey: "Save",
removeKey: "Remove",
keySaved: "Settings saved!",
keyRemoved: "Settings cleared.",
modelTitle: "AI Model",
modelDesc: "Select model for chat/reasoning.",
modelSaved: "Model updated!"
},
recorder: {
start: "Start Mic",
stop: "Stop Mic"
}
},
ja: {
appTitle: "さくら先生 🌸",
nav: {
sectionStudy: "学習とインプット",
sectionPractice: "練習とアウトプット",
sectionTools: "ツールボックス",
sectionImmersion: "没入体験",
chat: "学習道場",
reading: "読書の間",
listening: "聴解ラボ",
speaking: "ロールプレイ",
creative: "アトリエ",
translation: "翻訳機",
ocr: "スキャナー",
settings: "設定"
},
common: {
cancel: "キャンセル",
confirm: "確認",
delete: "削除",
next: "次へ",
generatedBy: "生成モデル:",
error: "エラーが発生しました",
poweredBy: "Powered by Gemini",
deleteItemConfirm: "この項目を削除してもよろしいですか?",
clearHistoryConfirm: "履歴をすべて消去してもよろしいですか?",
save: "保存",
download: "ダウンロード",
content: "コンテンツ",
tutor: "チューター",
text: "テキスト",
explanation: "解説",
clear: "クリア",
copy: "コピー",
copied: "コピーしました!",
share: "共有",
shareImage: "画像",
shareText: "テキスト",
shareFile: "ファイル (TXT)",
aiLanguage: "AIの使用言語",
langJa: "日本語",
langNative: "ユーザー言語",
today: "今日",
yesterday: "昨日"
},
onboarding: {
welcome: "さくら先生へようこそ!",
desc1: "あなたのためのAI日本語学習パートナーです。",
step1Title: "会話チューター",
step1Desc: "さくら先生Gemini 3 Proとチャットして、文法や文化について学びましょう。",
step2Title: "没入型練習",
step2Desc: "リアルなシナリオでのロールプレイ、読み物の作成、現実世界のテキストのスキャン。",
step3Title: "クリエイティブツール",
step3Desc: "学習の旅を視覚化するために画像やビデオを生成します。",
startBtn: "学習を始める",
selectLang: "言語を選択"
},
chat: {
welcome: "こんにちは!🌸 さくらです。日本語の勉強をお手伝いします。",
inputPlaceholder: "メッセージを送信...",
thinkingPlaceholder: "文法を推論中...",
imageAttached: "画像が添付されました",
sending: "さくら先生が考え中...",
error: "接続が失われました。",
locationError: "この地域はサポートされていません。設定でプロキシURLを設定してください。",
playUserAudio: "録音を再生",
listenPronunciation: "聞く",
deepThinking: "深い思考",
imageAnalyzed: "画像を分析しました",
thinkingToggle: "思考モード",
newChat: "新しいチャット",
history: "チャット履歴",
noHistory: "履歴はありません。",
deleteChat: "削除",
deleteConfirm: "このチャットセッションを削除してもよろしいですか?",
untitled: "無題のチャット",
transcribedPrefix: "(書き起こし): "
},
creative: {
title: "クリエイティブアトリエ 🎨",
genImage: "描画",
editImage: "マジック編集",
genVideo: "夢のビデオ",
promptLabel: "あなたのビジョン",
editLabel1: "1. 元の画像",
editLabel2: "2. 指示",
uploadPlaceholder: "ここに画像をドロップ",
generateBtn: "生成",
creatingBtn: "作成中...",
download: "ダウンロード",
videoWarning: "* ビデオ生成 (Veo) には時間がかかります。",
emptyState: "ここに作品が表示されます",
imagePrompt: "ネオン輝く東京のサイバーパンク侍猫...",
editPrompt: "木を桜に変えて...",
videoPrompt: "未来的な庭園での伝統的な茶道...",
uploadAlert: "まずは画像をアップロードしてください!"
},
speaking: {
title: "会話道場 🗣️",
subtitle: "リアルなシナリオでロールプレイ。アクセントや流暢さを即座にフィードバック。",
back: "終了",
listening: "聞いています...",
tapSpeak: "タップして話す",
processing: "分析中...",
feedbackTitle: "先生のレポート",
score: "流暢さ",
toImprove: "修正点",
advice: "アドバイス",
transcription: "あなたの発言",
meaning: "意味",
perfect: "すごい!完璧な発音です!🎉",
emptyFeedback: "はっきりと話してください。",
replay: "再生",
start: "開始",
roleplay: "役割",
translation: "翻訳"
},
reading: {
title: "読書の間 📜",
subtitle: "レベルに合わせて読み物を生成します。",
topicLabel: "トピック",
difficultyLabel: "レベル",
levels: {
beginner: "初級 (N5-N4)",
intermediate: "中級 (N3-N2)",
advanced: "上級 (N1)"
},
generate: "レッスン作成",
generating: "執筆中...",
translationToggle: "翻訳",
vocabTitle: "語彙",
grammarHeader: "文法",
qaTitle: "チューターチャット",
qaPlaceholder: "このテキストについて質問...",
qaWelcome: "レッスンを作成しました。テキストについて何でも聞いてください!",
historyTitle: "ライブラリ",
loadMore: "開く",
emptyHistory: "ライブラリは空です",
clear: "クリア",
placeholder: "例:京都の歴史、アニメ文化",
translationLabel: "翻訳",
thinking: "考え中...",
playAudio: "聞く",
stopAudio: "停止",
contentMissing: "コンテンツが生成されませんでした。新しいトピックで試してください。",
translationMissing: "翻訳がありません。"
},
listening: {
title: "聴解ラボ 🎧",
subtitle: "AIが生成した会話とクイズで耳を鍛えましょう。",
generate: "練習を作成",
generating: "作成中...",
play: "音声を再生",
pause: "一時停止",
replay: "もう一度",
showScript: "スクリプトを表示",
hideScript: "スクリプトを隠す",
quizTitle: "理解度クイズ",
check: "答え合わせ",
correct: "正解!",
incorrect: "不正解、もう一度。",
scriptTitle: "スクリプト",
historyTitle: "練習ログ",
emptyHistory: "練習ログなし",
qaWelcome: "リスニング練習を作成しました。まず音声を聞いてクイズに挑戦し、その後何でも質問してください!",
noScript: "再生できるスクリプトがありません。",
scriptMissing: "スクリプトが生成されませんでした。もう一度試してください。"
},
ocr: {
title: "テキストスキャナー 🔍",
subtitle: "テキスト(本、メニュー)をスキャンして学習ガイドを作成。",
uploadBtn: "アップロード",
cameraBtn: "カメラ",
processing: "スキャン中...",
extractedTitle: "抽出されたテキスト",
analysisTitle: "学習ノート",
vocabHeader: "語彙",
grammarHeader: "文法",
summaryHeader: "要約",
chatPlaceholder: "このテキストについて質問...",
reScan: "新しいスキャン",
error: "画像を分析できませんでした。",
history: "スキャン履歴",
emptyHistory: "スキャン履歴なし",
clear: "クリア",
analyzedIntro: "分析しました(言語:$lang。何でも聞いてください",
historyIntro: "履歴から読み込みました(言語:$lang。",
tutorChat: "チューターチャット",
thinking: "考え中...",
analysisFailed: "分析に失敗しました。"
},
translation: {
title: "翻訳機",
inputLabel: "入力",
outputLabel: "翻訳",
translateBtn: "翻訳",
translating: "翻訳中...",
extracting: "スキャン中...",
scanImage: "カメラ",
uploadImage: "画像",
sourceLang: "翻訳元",
targetLang: "翻訳先",
history: "翻訳履歴",
clear: "クリア",
copy: "コピー",
langs: {
auto: "自動検出",
en: "英語",
ja: "日本語",
zh: "中国語",
ko: "韓国語",
fr: "フランス語",
es: "スペイン語"
},
errorTranslating: "翻訳エラー。",
imageReadError: "テキストを読み取れませんでした。",
imageTransError: "画像の翻訳に失敗しました。"
},
settings: {
title: "設定とデータ",
backupTitle: "バックアップ",
backupDesc: "すべてのデータをローカルにダウンロード。",
backupBtn: "バックアップ",
restoreDesc: "バックアップファイルから復元。",
restoreBtn: "復元",
exportTitle: "エクスポート",
exportChatBtn: "チャットログ (TXT)",
exportTransBtn: "翻訳 (CSV)",
exportReadingBtn: "読書履歴 (JSON)",
exportOCRBtn: "スキャン履歴 (JSON)",
successRestore: "正常に復元されました!",
errorRestore: "無効なファイルです。",
apiKeyTitle: "API構成",
apiKeyDesc: "Gemini APIアクセスを構成します。",
apiKeyPlaceholder: "APIキーを貼り付け",
baseUrlPlaceholder: "ベースURL (オプション、プロキシ用)",
apiKeyMissing: "APIキーが必要です。",
saveKey: "保存",
removeKey: "削除",
keySaved: "設定を保存しました!",
keyRemoved: "設定をクリアしました。",
modelTitle: "AIモデル",
modelDesc: "チャット/推論用のモデルを選択。",
modelSaved: "モデルを更新しました!"
},
recorder: {
start: "マイク開始",
stop: "マイク停止"
}
},
zh: {
appTitle: "樱花老师 🌸",
nav: {
sectionStudy: "学习与输入",
sectionPractice: "练习与输出",
sectionTools: "工具箱",
sectionImmersion: "沉浸体验",
chat: "学习道场",
reading: "阅读室",
listening: "听力实验室",
speaking: "角色扮演",
creative: "工作室",
translation: "翻译机",
ocr: "扫描仪",
settings: "设置"
},
common: {
cancel: "取消",
confirm: "确认",
delete: "删除",
next: "下一步",
generatedBy: "生成模型:",
error: "发生错误",
poweredBy: "Powered by Gemini",
deleteItemConfirm: "您确定要删除此项目吗?",
clearHistoryConfirm: "您确定要清空历史记录吗?",
save: "保存",
download: "下载",
content: "内容",
tutor: "导师",
text: "文本",
explanation: "解析",
clear: "清除",
copy: "复制",
copied: "已复制!",
share: "分享",
shareImage: "图片",
shareText: "文本",
shareFile: "文件 (TXT)",
aiLanguage: "AI使用语言",
langJa: "日语",
langNative: "用户语言",
today: "今天",
yesterday: "昨天"
},
onboarding: {
welcome: "欢迎来到樱花老师!",
desc1: "您的AI日语学习伙伴。",
step1Title: "对话导师",
step1Desc: "与樱花老师Gemini 3 Pro聊天学习语法或文化。",
step2Title: "沉浸式练习",
step2Desc: "角色扮演现实场景,生成阅读材料,扫描现实世界的文本。",
step3Title: "创意工具",
step3Desc: "生成图像和视频以可视化您的学习之旅。",
startBtn: "开始学习",
selectLang: "选择界面语言"
},
chat: {
welcome: "你好!🌸 我是樱花。今天我可以帮你学习日语吗?",
inputPlaceholder: "发送消息...",
thinkingPlaceholder: "正在推理由法...",
imageAttached: "已附上图片",
sending: "樱花老师正在思考...",
error: "连接丢失。",
locationError: "不支持该地区。请在设置中配置代理URL。",
playUserAudio: "播放录音",
listenPronunciation: "听",
deepThinking: "深度思考",
imageAnalyzed: "图像已分析",
thinkingToggle: "思考模式",
newChat: "新聊天",
history: "聊天记录",
noHistory: "没有以前的聊天。",
deleteChat: "删除",
deleteConfirm: "您确定要删除此聊天会话吗?",
untitled: "未命名聊天",
transcribedPrefix: "(转录): "
},
creative: {
title: "创意工作室 🎨",
genImage: "绘画",
editImage: "魔法编辑",
genVideo: "梦境视频",
promptLabel: "你的愿景",
editLabel1: "1. 基础照片",
editLabel2: "2. 指令",
uploadPlaceholder: "在这里拖放图像",
generateBtn: "生成",
creatingBtn: "正在创建...",
download: "下载",
videoWarning: "* 视频生成 (Veo) 需要时间。",
emptyState: "你的杰作将出现在这里",
imagePrompt: "霓虹灯闪烁的东京赛博朋克武士猫...",
editPrompt: "把树变成樱花...",
videoPrompt: "未来花园中的传统茶道...",
uploadAlert: "请先上传图片!"
},
speaking: {
title: "对话道场 🗣️",
subtitle: "在现实场景中进行角色扮演。即时反馈口音和流利度。",
back: "退出",
listening: "正在听...",
tapSpeak: "点击说话",
processing: "正在分析...",
feedbackTitle: "老师的报告",
score: "流利度",
toImprove: "修正",
advice: "建议",
transcription: "你说了",
meaning: "意思",
perfect: "太棒了!完美的发音!🎉",
emptyFeedback: "请清楚地说出以获得反馈。",
replay: "重播",
start: "开始",
roleplay: "角色",
translation: "翻译"
},
reading: {
title: "阅读室 📜",
subtitle: "根据您的水平生成自定义阅读课程。",
topicLabel: "主题",
difficultyLabel: "等级",
levels: {
beginner: "初级 (N5-N4)",
intermediate: "中级 (N3-N2)",
advanced: "高级 (N1)"
},
generate: "创建课程",
generating: "正在写作...",
translationToggle: "翻译",
vocabTitle: "词汇",
grammarHeader: "语法",
qaTitle: "导师聊天",
qaPlaceholder: "关于此文本的问题...",
qaWelcome: "课程已生成。关于文本的问题尽管问我!",
historyTitle: "图书馆",
loadMore: "打开",
emptyHistory: "图书馆为空",
clear: "清除",
placeholder: "例如:京都历史,动漫文化",
translationLabel: "翻译",
thinking: "思考中...",
playAudio: "听",
stopAudio: "停止",
contentMissing: "未生成内容。请尝试新的主题。",
translationMissing: "暂无翻译。"
},
listening: {
title: "听力实验室 🎧",
subtitle: "通过AI生成的对话和测验训练您的耳朵。",
generate: "创建练习",
generating: "正在创作...",
play: "播放音频",
pause: "暂停",
replay: "重播",
showScript: "显示脚本",
hideScript: "隐藏脚本",
quizTitle: "理解测验",
check: "检查答案",
correct: "正确!",
incorrect: "不正确,请重试。",
scriptTitle: "脚本",
historyTitle: "练习日志",
emptyHistory: "暂无练习记录",
qaWelcome: "我已生成听力练习。先听音频,尝试测验,然后尽管问我任何问题!",
noScript: "暂无脚本可播放。",
scriptMissing: "未生成脚本。请重试。"
},
ocr: {
title: "文本扫描仪 🔍",
subtitle: "扫描文本(书籍,菜单)以创建学习指南。",
uploadBtn: "上传",
cameraBtn: "相机",
processing: "正在扫描...",
extractedTitle: "提取的文本",
analysisTitle: "学习笔记",
vocabHeader: "词汇",
grammarHeader: "语法",
summaryHeader: "摘要",
chatPlaceholder: "关于此文本的问题...",
reScan: "新扫描",
error: "无法分析图像。",
history: "扫描记录",
emptyHistory: "暂无扫描",
clear: "清除",
analyzedIntro: "已分析(语言:$lang。尽管问我",
historyIntro: "从历史记录加载(语言:$lang。",
tutorChat: "导师聊天",
thinking: "思考中...",
analysisFailed: "分析失败。"
},
translation: {
title: "翻译机",
inputLabel: "输入",
outputLabel: "翻译",
translateBtn: "翻译",
translating: "正在翻译...",
extracting: "正在扫描...",
scanImage: "相机",
uploadImage: "图像",
sourceLang: "源语言",
targetLang: "目标语言",
history: "翻译记录",
clear: "清除",
copy: "复制",
langs: {
auto: "自动检测",
en: "英语",
ja: "日语",
zh: "中文",
ko: "韩语",
fr: "法语",
es: "西班牙语"
},
errorTranslating: "翻译错误。",
imageReadError: "无法读取文本。",
imageTransError: "图片翻译失败。"
},
settings: {
title: "设置和数据",
backupTitle: "备份",
backupDesc: "下载所有数据到本地。",
backupBtn: "备份",
restoreDesc: "从备份文件恢复。",
restoreBtn: "恢复",
exportTitle: "导出",
exportChatBtn: "聊天记录 (TXT)",
exportTransBtn: "翻译 (CSV)",
exportReadingBtn: "阅读记录 (JSON)",
exportOCRBtn: "扫描记录 (JSON)",
successRestore: "恢复成功!",
errorRestore: "无效文件。",
apiKeyTitle: "API配置",
apiKeyDesc: "配置您的Gemini API访问。",
apiKeyPlaceholder: "粘贴API密钥",
baseUrlPlaceholder: "Base URL (可选,用于代理)",
apiKeyMissing: "需要API密钥。",
saveKey: "保存",
removeKey: "删除",
keySaved: "设置已保存!",
keyRemoved: "设置已清除。",
modelTitle: "AI模型",
modelDesc: "选择聊天/推理模型。",
modelSaved: "模型已更新!"
},
recorder: {
start: "开始录音",
stop: "停止录音"
}
}
};

546
views/ChatView.tsx Normal file
View File

@@ -0,0 +1,546 @@
import React, { useState, useRef, useEffect } from 'react';
import { ChatMessage, Role, MessageType, Language, ChatSession } from '../types';
import { geminiService } from '../services/geminiService';
import ChatBubble from '../components/ChatBubble';
import AudioRecorder from '../components/AudioRecorder';
import { Send, Image as ImageIcon, BrainCircuit, Loader2, Plus, History, MessageSquare, Trash2, X, Sparkles, PanelRightClose, PanelRightOpen, Share2, Download, FileText, Image as ImageIconLucide, Languages } from 'lucide-react';
import { translations } from '../utils/localization';
import html2canvas from 'html2canvas';
interface ChatViewProps {
language: Language;
sessions: ChatSession[];
activeSessionId: string;
onNewSession: () => void;
onSelectSession: (id: string) => void;
onDeleteSession: (id: string) => void;
onClearAllSessions: () => void;
onUpdateSession: (id: string, messages: ChatMessage[]) => void;
selectedModel?: string;
addToast: (type: 'success' | 'error' | 'info', msg: string) => void;
}
const ChatView: React.FC<ChatViewProps> = ({
language,
sessions,
activeSessionId,
onNewSession,
onSelectSession,
onDeleteSession,
onClearAllSessions,
onUpdateSession,
selectedModel,
addToast
}) => {
const t = translations[language].chat;
const tCommon = translations[language].common;
const activeSession = sessions.find(s => s.id === activeSessionId) || sessions[0];
const messages = activeSession ? activeSession.messages : [];
const [inputValue, setInputValue] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [useThinking, setUseThinking] = useState(false);
const [attachedImage, setAttachedImage] = useState<string | null>(null);
// Settings State
const [aiSpeakingLanguage, setAiSpeakingLanguage] = useState<'ja' | 'native'>('ja');
const [isShareMenuOpen, setIsShareMenuOpen] = useState(false);
// History Sidebar State - Default Closed as requested
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages, activeSessionId]);
// Close share menu on click outside
useEffect(() => {
const handleClick = () => setIsShareMenuOpen(false);
if (isShareMenuOpen) window.addEventListener('click', handleClick);
return () => window.removeEventListener('click', handleClick);
}, [isShareMenuOpen]);
const handleUpdateMessage = (updatedMsg: ChatMessage) => {
const updatedMessages = messages.map(m => m.id === updatedMsg.id ? updatedMsg : m);
onUpdateSession(activeSessionId, updatedMessages);
};
const handleSendMessage = async () => {
if ((!inputValue.trim() && !attachedImage) || isLoading) return;
const currentText = inputValue;
const currentImage = attachedImage;
setInputValue('');
setAttachedImage(null);
setIsLoading(true);
// 1. Construct User Message
const userMsg: ChatMessage = {
id: Date.now().toString(),
role: Role.USER,
type: MessageType.TEXT,
content: currentText,
timestamp: Date.now(),
metadata: { imageUrl: currentImage || undefined }
};
// IMPORTANT: Calculate new history locally to avoid stale closure issues after await
const messagesWithUser = [...messages, userMsg];
// Update UI immediately with user message
onUpdateSession(activeSessionId, messagesWithUser);
try {
// 2. Get Response
const result = await geminiService.generateTextResponse(
currentText || "Describe this image",
currentImage || undefined,
useThinking,
language,
selectedModel,
aiSpeakingLanguage
);
// 3. TTS (if short and not thinking)
let ttsAudio: string | null = null;
if (!useThinking && result.text.length < 300) {
try { ttsAudio = await geminiService.generateSpeech(result.text); } catch (e) {}
}
const aiMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: Role.MODEL,
type: MessageType.TEXT,
content: result.text,
model: result.model,
timestamp: Date.now(),
metadata: { isThinking: useThinking, audioUrl: ttsAudio || undefined }
};
// 4. Add AI Message to the LOCALLY calculated history (messagesWithUser)
// This ensures we don't lose the user message we just added
onUpdateSession(activeSessionId, [...messagesWithUser, aiMsg]);
} catch (error: any) {
const errorMsg = error?.message || t.error;
const errorMsgObj: ChatMessage = {
id: Date.now().toString(),
role: Role.MODEL,
type: MessageType.TEXT,
content: `${t.error}\n(${errorMsg})`,
timestamp: Date.now()
};
onUpdateSession(activeSessionId, [...messagesWithUser, errorMsgObj]);
} finally {
setIsLoading(false);
setUseThinking(false);
}
};
const handleAudioInput = async (base64Audio: string) => {
setIsLoading(true);
try {
// 1. Transcribe first (async)
const transcription = await geminiService.transcribeAudio(base64Audio);
const userMsg: ChatMessage = {
id: Date.now().toString(),
role: Role.USER,
type: MessageType.AUDIO,
content: `${t.transcribedPrefix}${transcription}`,
timestamp: Date.now(),
metadata: { audioUrl: base64Audio, transcription: transcription }
};
// 2. Update UI with User Message
const messagesWithUser = [...messages, userMsg];
onUpdateSession(activeSessionId, messagesWithUser);
// 3. Generate AI Response
const result = await geminiService.generateTextResponse(transcription, undefined, false, language, selectedModel, aiSpeakingLanguage);
const ttsAudio = await geminiService.generateSpeech(result.text);
const aiMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: Role.MODEL,
type: MessageType.TEXT,
content: result.text,
model: result.model,
timestamp: Date.now(),
metadata: { audioUrl: ttsAudio || undefined }
};
// 4. Update UI with AI Message using local history
onUpdateSession(activeSessionId, [...messagesWithUser, aiMsg]);
} catch (e) {
console.error(e);
addToast('error', t.error);
} finally {
setIsLoading(false);
}
};
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
setAttachedImage(reader.result as string);
};
reader.readAsDataURL(file);
}
};
// Share Handlers
const shareAsText = () => {
const text = messages.map(m => `[${new Date(m.timestamp).toLocaleString()}] ${m.role === Role.USER ? 'User' : 'Sakura'}: ${m.content}`).join('\n\n');
navigator.clipboard.writeText(text);
addToast('success', tCommon.copied);
};
const shareAsFile = () => {
const text = messages.map(m => `[${new Date(m.timestamp).toLocaleString()}] ${m.role === Role.USER ? 'User' : 'Sakura'}: ${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 = `sakura_chat_${Date.now()}.txt`;
a.click();
URL.revokeObjectURL(url);
};
const shareAsImage = async () => {
if (!messagesContainerRef.current) return;
addToast('info', 'Generating image...');
// Clone the element to capture full content
const original = messagesContainerRef.current;
const clone = original.cloneNode(true) as HTMLElement;
// We need to maintain the width to ensure text wrapping is identical
const width = original.offsetWidth;
clone.style.width = `${width}px`;
clone.style.height = 'auto';
clone.style.maxHeight = 'none';
clone.style.overflow = 'visible';
clone.style.position = 'absolute';
clone.style.top = '-9999px';
clone.style.left = '0';
clone.style.background = '#f8fafc'; // Match bg-slate-50
clone.style.zIndex = '-1';
document.body.appendChild(clone);
try {
// Small delay to ensure DOM rendering
await new Promise(resolve => setTimeout(resolve, 100));
const canvas = await html2canvas(clone, {
useCORS: true,
scale: 2, // Higher res
backgroundColor: '#f8fafc',
windowWidth: width,
height: clone.scrollHeight,
windowHeight: clone.scrollHeight
});
const url = canvas.toDataURL('image/png');
const a = document.createElement('a');
a.href = url;
a.download = `sakura_chat_${Date.now()}.png`;
a.click();
} catch (e) {
console.error(e);
addToast('error', 'Failed to generate image');
} finally {
if (document.body.contains(clone)) {
document.body.removeChild(clone);
}
}
};
// --- Sub-components ---
const HistoryContent = () => (
<div className="flex flex-col h-full bg-white">
<div className="p-4 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
<h3 className="font-bold text-slate-700 flex items-center gap-2">
<History size={18} className="text-indigo-500" /> {t.history}
</h3>
<div className="flex items-center gap-3">
{sessions.length > 0 && (
<button onClick={onClearAllSessions} className="text-xs text-red-400 hover:text-red-600 hover:underline">
{translations[language].reading.clear}
</button>
)}
<button onClick={() => setIsHistoryOpen(false)} className="md:hidden text-slate-400 hover:text-slate-600">
<X size={20} />
</button>
</div>
</div>
<div className="p-4">
<button
onClick={() => { onNewSession(); setIsHistoryOpen(false); }}
className="w-full py-3 bg-indigo-600 text-white hover:bg-indigo-700 rounded-xl font-bold flex items-center justify-center gap-2 transition-all shadow-md hover:shadow-lg active:scale-95"
>
<Plus size={18} /> {t.newChat}
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 pb-20 space-y-3">
{sessions.length === 0 && <div className="text-center text-slate-400 text-xs mt-4">{t.noHistory}</div>}
{sessions.slice().sort((a,b) => b.updatedAt - a.updatedAt).map(session => (
<div
key={session.id}
className={`group flex items-start gap-3 p-3 rounded-xl border cursor-pointer relative transition-all ${
session.id === activeSessionId
? 'bg-indigo-50 border-indigo-200 shadow-sm'
: 'bg-slate-50 border-slate-100 hover:bg-white hover:shadow-md'
}`}
onClick={() => { onSelectSession(session.id); if(window.innerWidth < 768) setIsHistoryOpen(false); }}
>
{/* Icon */}
<div className={`w-10 h-10 rounded-xl flex-shrink-0 flex items-center justify-center ${session.id === activeSessionId ? 'bg-indigo-200 text-indigo-700' : 'bg-white text-slate-300 border border-slate-200'}`}>
<MessageSquare size={20} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex justify-between items-start">
<h4 className={`font-bold text-sm truncate pr-6 ${session.id === activeSessionId ? 'text-indigo-900' : 'text-slate-700'}`}>
{session.title || t.untitled}
</h4>
</div>
<div className="flex justify-between items-center mt-1">
<span className="text-[10px] text-slate-400">
{new Date(session.updatedAt).toLocaleDateString()} {new Date(session.updatedAt).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</span>
</div>
<p className="text-[10px] text-slate-400 line-clamp-1 mt-1">
{session.messages.length > 1 ? session.messages[session.messages.length-1].content.substring(0, 50) : '...'}
</p>
</div>
{/* Delete Button */}
<button
onClick={(e) => { e.stopPropagation(); onDeleteSession(session.id); }}
className="absolute bottom-2 right-2 p-1.5 text-slate-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
title={t.deleteChat}
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
</div>
);
return (
<div className="flex h-full bg-slate-50 relative overflow-hidden">
{/* MAIN CHAT AREA */}
<div className="flex-1 flex flex-col h-full min-w-0 bg-slate-50/30 relative">
{/* Header / Toolbar */}
<div className="flex items-center justify-between px-4 py-3 bg-white/80 backdrop-blur border-b border-slate-200 z-20 sticky top-0">
<div className="flex items-center gap-2 overflow-x-auto scrollbar-hide">
<div className="flex items-center gap-1.5 text-xs font-bold text-indigo-600 bg-indigo-50 px-2.5 py-1 rounded-lg border border-indigo-100 whitespace-nowrap">
<Sparkles size={12} />
{selectedModel ? selectedModel.replace('gemini-', '').replace('-preview', '') : 'AI'}
</div>
{/* AI Language Toggle */}
<button
onClick={() => setAiSpeakingLanguage(prev => prev === 'ja' ? 'native' : 'ja')}
className="flex items-center gap-1.5 text-xs font-bold text-slate-600 bg-white px-2.5 py-1 rounded-lg border border-slate-200 hover:bg-slate-50 whitespace-nowrap"
title={tCommon.aiLanguage}
>
<Languages size={12} />
{aiSpeakingLanguage === 'ja' ? tCommon.langJa : tCommon.langNative}
</button>
</div>
<div className="flex items-center gap-2">
{/* Share Button */}
<div className="relative">
<button
onClick={(e) => { e.stopPropagation(); setIsShareMenuOpen(!isShareMenuOpen); }}
className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 transition-colors"
title={tCommon.share}
>
<Share2 size={18} />
</button>
{/* Share Dropdown */}
{isShareMenuOpen && (
<div className="absolute right-0 top-full mt-2 w-40 bg-white rounded-xl shadow-xl border border-slate-100 overflow-hidden animate-scale-in z-50">
<button onClick={shareAsImage} className="w-full text-left px-4 py-3 text-sm text-slate-700 hover:bg-slate-50 flex items-center gap-2">
<ImageIconLucide size={16} /> {tCommon.shareImage}
</button>
<button onClick={shareAsText} className="w-full text-left px-4 py-3 text-sm text-slate-700 hover:bg-slate-50 flex items-center gap-2">
<FileText size={16} /> {tCommon.shareText}
</button>
<button onClick={shareAsFile} className="w-full text-left px-4 py-3 text-sm text-slate-700 hover:bg-slate-50 flex items-center gap-2">
<Download size={16} /> {tCommon.shareFile}
</button>
</div>
)}
</div>
{/* Toggle History Button */}
<button
onClick={() => setIsHistoryOpen(!isHistoryOpen)}
className={`p-2 rounded-lg border transition-colors flex items-center gap-2 text-sm font-medium ${
isHistoryOpen
? 'bg-indigo-50 text-indigo-600 border-indigo-200'
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50'
}`}
title={t.history}
>
<History size={18} />
<span className="hidden sm:inline">{t.history}</span>
{isHistoryOpen ? <PanelRightClose size={16} className="opacity-50" /> : <PanelRightOpen size={16} className="opacity-50" />}
</button>
</div>
</div>
{/* Messages Scroll Area */}
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-6" ref={messagesContainerRef}>
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-slate-300 animate-fade-in pb-20">
<div className="w-20 h-20 bg-white rounded-3xl flex items-center justify-center mb-6 shadow-sm border border-slate-100 rotate-3">
<MessageSquare size={36} className="text-indigo-200" />
</div>
<p className="text-sm font-bold text-slate-400">{t.inputPlaceholder}</p>
</div>
)}
{messages.map((msg) => (
<ChatBubble
key={msg.id}
message={msg}
language={language}
onUpdateMessage={handleUpdateMessage}
onError={(errorMsg) => addToast('error', errorMsg)}
/>
))}
{isLoading && (
<div className="flex items-center gap-2 text-slate-400 text-sm ml-4 animate-pulse">
<div className="w-8 h-8 bg-white rounded-full flex items-center justify-center shadow-sm border border-slate-100">
<Loader2 size={14} className="animate-spin text-indigo-500" />
</div>
{t.sending}
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="bg-white border-t border-slate-200 p-3 sm:p-4 z-20 pb-[env(safe-area-inset-bottom)]">
{attachedImage && (
<div className="flex items-center gap-2 mb-3 px-1 animate-scale-in">
<div className="relative group">
<img src={attachedImage} alt="Preview" className="h-14 w-14 object-cover rounded-lg border border-slate-200 shadow-sm" />
<button
onClick={() => setAttachedImage(null)}
className="absolute -top-2 -right-2 bg-slate-800 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs shadow-md hover:bg-red-50 transition-colors"
>
×
</button>
</div>
<span className="text-xs text-indigo-600 font-bold bg-indigo-50 px-3 py-1 rounded-full">{t.imageAttached}</span>
</div>
)}
<div className="max-w-4xl mx-auto flex flex-col sm:flex-row gap-2 items-end">
<div className="flex gap-1 items-center justify-between w-full sm:w-auto">
<div className="flex gap-1">
<button
onClick={() => fileInputRef.current?.click()}
className="p-3 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-xl transition-all"
title="Attach Image"
>
<ImageIcon size={20} />
</button>
<input type="file" ref={fileInputRef} onChange={handleImageUpload} className="hidden" accept="image/*" />
<button
onClick={() => setUseThinking(!useThinking)}
className={`p-3 rounded-xl transition-all ${
useThinking ? 'bg-amber-50 text-amber-600 ring-1 ring-amber-200' : 'text-slate-400 hover:text-amber-600 hover:bg-amber-50'
}`}
title={t.thinkingToggle}
>
<BrainCircuit size={20} />
</button>
<AudioRecorder onAudioCaptured={handleAudioInput} disabled={isLoading} />
</div>
<button
onClick={handleSendMessage}
disabled={!inputValue.trim() && !attachedImage || isLoading}
className="sm:hidden p-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl disabled:opacity-50 disabled:scale-95 transition-all shadow-md shadow-indigo-200"
>
{isLoading ? <Loader2 size={20} className="animate-spin" /> : <Send size={20} />}
</button>
</div>
<div className="flex-1 w-full">
<textarea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSendMessage(); }}}
placeholder={useThinking ? t.thinkingPlaceholder : t.inputPlaceholder}
className="w-full border-2 border-transparent bg-slate-100 focus:bg-white rounded-2xl px-4 py-3 focus:border-indigo-500 focus:ring-0 resize-y min-h-[50px] max-h-[200px] text-sm md:text-base transition-all placeholder:text-slate-400 text-slate-800"
style={{ minHeight: '50px' }}
/>
</div>
<button
onClick={handleSendMessage}
disabled={!inputValue.trim() && !attachedImage || isLoading}
className="hidden sm:flex p-3.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl disabled:opacity-50 transition-all shadow-lg shadow-indigo-200 hover:scale-105 active:scale-95"
>
{isLoading ? <Loader2 size={20} className="animate-spin" /> : <Send size={20} />}
</button>
</div>
</div>
</div>
{/* RIGHT SIDEBAR - DESKTOP */}
<div className={`
hidden md:block h-full bg-white border-l border-slate-200 transition-all duration-300 ease-in-out overflow-hidden z-30
${isHistoryOpen ? 'w-80 opacity-100' : 'w-0 opacity-0 border-none'}
`}>
<HistoryContent />
</div>
{/* MOBILE DRAWER (Slide over) */}
{isHistoryOpen && (
<div className="fixed inset-0 z-50 md:hidden flex justify-end">
<div className="absolute inset-0 bg-slate-900/30 backdrop-blur-sm transition-opacity" onClick={() => setIsHistoryOpen(false)} />
<div className="relative w-[85%] max-w-sm bg-white h-full shadow-2xl animate-slide-in-right z-50">
<HistoryContent />
</div>
</div>
)}
</div>
);
};
export default ChatView;

201
views/CreativeStudio.tsx Normal file
View File

@@ -0,0 +1,201 @@
import React, { useState, useRef } from 'react';
import { geminiService } from '../services/geminiService';
import { Image as ImageIcon, Video, Wand2, Download, Loader2, Sparkles } from 'lucide-react';
import { Language } from '../types';
import { translations } from '../utils/localization';
interface CreativeStudioProps {
language: Language;
addToast: (type: 'success' | 'error' | 'info', msg: string) => void;
}
type Mode = 'image-gen' | 'image-edit' | 'video-gen';
const CreativeStudio: React.FC<CreativeStudioProps> = ({ language, addToast }) => {
const t = translations[language].creative;
const tCommon = translations[language].common;
const [mode, setMode] = useState<Mode>('image-gen');
const [prompt, setPrompt] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [resultUrl, setResultUrl] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState('');
const [uploadedImage, setUploadedImage] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleGenerate = async () => {
if (!prompt.trim()) return;
setIsLoading(true);
setResultUrl(null);
setStatusMessage(t.creatingBtn);
try {
if (mode === 'image-gen') {
const url = await geminiService.generateImage(prompt);
setResultUrl(url);
} else if (mode === 'video-gen') {
const url = await geminiService.generateVideo(prompt, (status) => setStatusMessage(status));
setResultUrl(url);
} else if (mode === 'image-edit') {
if (!uploadedImage) {
addToast('error', t.uploadAlert);
setIsLoading(false);
return;
}
const url = await geminiService.editImage(uploadedImage, prompt);
setResultUrl(url);
}
} catch (e) {
console.error(e);
addToast('error', "Generation failed.");
} finally {
setIsLoading(false);
}
};
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
setUploadedImage(reader.result as string);
};
reader.readAsDataURL(file);
}
};
// ... rest of UI (same as before) ...
const ModeButton = ({ id, icon: Icon, label }: { id: Mode, icon: React.ElementType, label: string }) => {
const isActive = mode === id;
return (
<button
onClick={() => { setMode(id); setResultUrl(null); }}
className={`relative flex-1 px-4 py-3 rounded-xl text-sm font-bold flex items-center justify-center gap-2 transition-all duration-200 ${
isActive
? 'bg-white text-indigo-600 shadow-md shadow-slate-200 ring-1 ring-black/5 z-10 scale-105'
: 'text-slate-500 hover:bg-slate-100/50'
}`}
>
<Icon size={18} className={isActive ? 'stroke-[2.5px]' : ''} /> {label}
</button>
);
}
return (
<div className="h-full flex flex-col p-4 md:p-8 bg-slate-50/50 overflow-y-auto animate-fade-in">
<div className="max-w-5xl mx-auto w-full">
{/* ... Header & Controls (same) ... */}
<div className="mb-8 animate-fade-in-up">
<h2 className="text-3xl font-extrabold text-slate-800 tracking-tight">{t.title}</h2>
<p className="text-slate-500 mt-1">{tCommon.poweredBy}</p>
</div>
<div className="bg-slate-200/60 p-1.5 rounded-2xl flex mb-8 w-full max-w-2xl mx-auto shadow-inner animate-fade-in-up delay-100">
<ModeButton id="image-gen" icon={ImageIcon} label={t.genImage} />
<ModeButton id="image-edit" icon={Wand2} label={t.editImage} />
<ModeButton id="video-gen" icon={Video} label={t.genVideo} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Input Section */}
<div className="bg-white p-6 md:p-8 rounded-3xl shadow-sm border border-slate-100 h-fit relative overflow-hidden animate-fade-in-up delay-200">
{/* Decor */}
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-bl from-indigo-50 to-transparent -mr-10 -mt-10 rounded-full" />
{mode === 'image-edit' && (
<div className="mb-8 relative z-10 animate-scale-in">
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-3">{t.editLabel1}</label>
<div
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed border-slate-200 rounded-2xl h-48 flex flex-col items-center justify-center cursor-pointer hover:bg-indigo-50 hover:border-indigo-200 transition-all duration-300 overflow-hidden relative group"
>
{uploadedImage ? (
<img src={uploadedImage} alt="Base" className="w-full h-full object-cover" />
) : (
<div className="text-center text-slate-400 group-hover:text-indigo-500 transition-colors">
<div className="w-12 h-12 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-3 group-hover:bg-white group-hover:scale-110 transition-all">
<ImageIcon size={24} />
</div>
<span className="text-sm font-medium">{t.uploadPlaceholder}</span>
</div>
)}
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleImageUpload}
/>
</div>
</div>
)}
<div className="mb-8 relative z-10">
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-3">
{mode === 'image-edit' ? t.editLabel2 : t.promptLabel}
</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-2xl p-4 text-slate-700 focus:bg-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent focus:outline-none h-32 resize-none shadow-inner text-lg transition-all"
placeholder={
mode === 'video-gen' ? t.videoPrompt :
mode === 'image-edit' ? t.editPrompt :
t.imagePrompt
}
/>
</div>
<button
onClick={handleGenerate}
disabled={isLoading || !prompt}
className="w-full py-4 bg-gradient-to-r from-indigo-600 to-violet-600 hover:from-indigo-500 hover:to-violet-500 text-white rounded-2xl font-bold shadow-lg shadow-indigo-200 transform active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isLoading ? <Loader2 className="animate-spin" /> : <Sparkles size={20} />}
{isLoading ? t.creatingBtn : t.generateBtn}
</button>
{mode === 'video-gen' && (
<p className="text-[10px] text-slate-400 mt-4 text-center uppercase tracking-wide font-medium animate-pulse">
{t.videoWarning}
</p>
)}
</div>
{/* Output Section (Simplified for brevity, logic same as before) */}
<div className="bg-white rounded-3xl border border-slate-100 shadow-sm flex flex-col items-center justify-center min-h-[400px] p-4 relative overflow-hidden group animate-fade-in-up delay-300">
<div className="absolute inset-0 opacity-[0.03] pointer-events-none bg-[radial-gradient(#4f46e5_1px,transparent_1px)] [background-size:16px_16px]"></div>
{isLoading ? (
<div className="text-center relative z-10">
<div className="relative">
<div className="absolute inset-0 bg-indigo-500 blur-xl opacity-20 animate-pulse rounded-full"></div>
<Loader2 size={64} className="text-indigo-600 animate-spin mx-auto mb-6 relative z-10" />
</div>
<p className="text-slate-800 font-bold text-lg animate-pulse">{statusMessage}</p>
</div>
) : resultUrl ? (
<div className="w-full h-full flex flex-col items-center justify-center relative z-10 animate-scale-in">
<div className="relative rounded-2xl overflow-hidden shadow-2xl ring-4 ring-white transition-transform hover:scale-[1.02]">
{mode === 'video-gen' ? (
<video controls autoPlay loop className="max-h-[400px] w-auto bg-black"><source src={resultUrl} type="video/mp4" /></video>
) : (
<img src={resultUrl} alt="Result" className="max-h-[400px] w-auto object-contain bg-white" />
)}
</div>
<a href={resultUrl} download="creation" className="mt-6 flex items-center gap-2 px-6 py-3 bg-slate-900 text-white rounded-full font-bold hover:bg-slate-800 transition-colors shadow-lg hover:scale-105 transform"><Download size={18} /> {t.download}</a>
</div>
) : (
<div className="text-slate-300 text-center relative z-10">
<Sparkles size={64} className="mx-auto mb-4 opacity-50" />
<p className="font-medium text-lg">{t.emptyState}</p>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default CreativeStudio;

705
views/ListeningView.tsx Normal file
View File

@@ -0,0 +1,705 @@
import React, { useState, useRef, useEffect } from 'react';
import { Language, ListeningLesson, ListeningLessonRecord, ReadingDifficulty, ChatMessage, Role, MessageType } from '../types';
import { geminiService, decodeAudioData } from '../services/geminiService';
import { processAndDownloadAudio } from '../utils/audioUtils';
import { Headphones, Loader2, Send, Eye, EyeOff, List, HelpCircle, ChevronLeft, History, Trash2, X, PanelRightClose, PanelRightOpen, Volume2, Square, Play, Pause, CheckCircle, AlertCircle, FileText, MessageCircle, Download, RotateCcw } from 'lucide-react';
import { translations } from '../utils/localization';
import ChatBubble from '../components/ChatBubble';
interface ListeningViewProps {
language: Language;
history: ListeningLessonRecord[];
onSaveToHistory: (lesson: ListeningLessonRecord) => void;
onUpdateHistory: (lesson: ListeningLessonRecord) => void;
onClearHistory: () => void;
onDeleteHistoryItem: (id: string) => void;
}
const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSaveToHistory, onUpdateHistory, onClearHistory, onDeleteHistoryItem }) => {
const t = translations[language].listening;
const tCommon = translations[language].common;
// Setup State
const [topic, setTopic] = useState('');
const [difficulty, setDifficulty] = useState<ReadingDifficulty>(ReadingDifficulty.INTERMEDIATE);
const [isGenerating, setIsGenerating] = useState(false);
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
// Content State
const [lesson, setLesson] = useState<ListeningLesson | null>(null);
const [currentRecordId, setCurrentRecordId] = useState<string | null>(null);
const [showScript, setShowScript] = useState(false);
const [selectedAnswers, setSelectedAnswers] = useState<{[key: number]: number}>({}); // questionIndex -> optionIndex
const [showQuizResults, setShowQuizResults] = useState(false);
// Mobile Tab State
const [mobileTab, setMobileTab] = useState<'content' | 'tutor'>('content');
// Audio State
const [isTTSLoading, setIsTTSLoading] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [audioCache, setAudioCache] = useState<string | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const audioSourceRef = useRef<AudioBufferSourceNode | null>(null);
// Tutor Chat State
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
const [chatInput, setChatInput] = useState('');
const [isChatLoading, setIsChatLoading] = useState(false);
const chatEndRef = useRef<HTMLDivElement>(null);
// Cleanup audio when leaving lesson
useEffect(() => {
return () => stopAudio();
}, [lesson]);
const stopAudio = () => {
if (audioSourceRef.current) {
audioSourceRef.current.stop();
audioSourceRef.current = null;
}
setIsPlaying(false);
};
const playAudioData = async (base64Data: string) => {
stopAudio();
if (!audioContextRef.current) {
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
}
if (audioContextRef.current.state === 'suspended') await audioContextRef.current.resume();
const ctx = audioContextRef.current;
const buffer = await decodeAudioData(base64Data, ctx);
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(ctx.destination);
source.onended = () => setIsPlaying(false);
source.start();
audioSourceRef.current = source;
};
const toggleAudio = async () => {
if (isPlaying) {
stopAudio();
return;
}
if (audioCache) {
setIsPlaying(true);
await playAudioData(audioCache);
return;
}
if (!lesson?.script) {
alert(t.noScript);
return;
}
setIsTTSLoading(true);
try {
const audioBase64 = await geminiService.generateSpeech(lesson.script);
if (!audioBase64) return;
setAudioCache(audioBase64);
setIsPlaying(true);
await playAudioData(audioBase64);
} catch (e) {
console.error("TTS Playback failed", e);
setIsPlaying(false);
} finally {
setIsTTSLoading(false);
}
};
const downloadAudio = async () => {
if (audioCache) {
processAndDownloadAudio(audioCache, `sakura_listening_${Date.now()}.wav`);
return;
}
if (!lesson?.script) {
alert(t.noScript);
return;
}
setIsTTSLoading(true);
try {
const audioBase64 = await geminiService.generateSpeech(lesson.script);
if (audioBase64) {
setAudioCache(audioBase64);
processAndDownloadAudio(audioBase64, `sakura_listening_${Date.now()}.wav`);
}
} catch(e) {
console.error("TTS Download failed", e);
} finally {
setIsTTSLoading(false);
}
};
const generateLesson = async () => {
if (!topic.trim()) return;
setIsGenerating(true);
try {
const result = await geminiService.generateListeningLesson(topic, difficulty, language);
if (result) {
const newId = Date.now().toString();
const initialChat: ChatMessage[] = [{
id: 'init',
role: Role.MODEL,
type: MessageType.TEXT,
content: t.qaWelcome,
timestamp: Date.now()
}];
const record: ListeningLessonRecord = {
...result,
id: newId,
topic: topic,
difficulty: difficulty,
timestamp: Date.now(),
chatHistory: initialChat
};
onSaveToHistory(record);
// Set State
setLesson(result);
setCurrentRecordId(newId);
setChatMessages(initialChat);
setAudioCache(null);
// Reset View State
setIsHistoryOpen(false);
setMobileTab('content');
setShowScript(false);
setSelectedAnswers({});
setShowQuizResults(false);
}
} catch (e) {
console.error(e);
} finally {
setIsGenerating(false);
}
};
const loadFromHistory = (record: ListeningLessonRecord) => {
setLesson(record);
setCurrentRecordId(record.id);
setIsHistoryOpen(false);
setMobileTab('content');
setShowScript(false);
setSelectedAnswers({});
setShowQuizResults(false);
setAudioCache(null);
if (record.chatHistory && record.chatHistory.length > 0) {
setChatMessages(record.chatHistory);
} else {
setChatMessages([{
id: 'init',
role: Role.MODEL,
type: MessageType.TEXT,
content: t.qaWelcome,
timestamp: Date.now()
}]);
}
};
const updateCurrentLessonChat = (newMessages: ChatMessage[]) => {
setChatMessages(newMessages);
if (currentRecordId && lesson) {
const existing = history.find(h => h.id === currentRecordId);
if (existing) {
onUpdateHistory({
...existing,
chatHistory: newMessages
});
}
}
};
const handleAskTutor = async () => {
if (!chatInput.trim() || !lesson) return;
const question = chatInput;
setChatInput('');
setIsChatLoading(true);
// Add User Message
const updatedMessages = [...chatMessages, {
id: Date.now().toString(),
role: Role.USER,
type: MessageType.TEXT,
content: question,
timestamp: Date.now()
}];
updateCurrentLessonChat(updatedMessages);
// Build history
const historyText = updatedMessages.slice(-4).map(m => `${m.role}: ${m.content}`).join('\n');
try {
const answer = await geminiService.generateReadingTutorResponse(question, lesson, historyText, language);
const finalMessages = [...updatedMessages, {
id: (Date.now() + 1).toString(),
role: Role.MODEL,
type: MessageType.TEXT,
content: answer,
timestamp: Date.now()
}];
updateCurrentLessonChat(finalMessages);
} catch (e) {
console.error(e);
} finally {
setIsChatLoading(false);
}
};
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [chatMessages, mobileTab]);
const handleQuizSelect = (questionIndex: number, optionIndex: number) => {
if (showQuizResults) return; // locked
setSelectedAnswers(prev => ({...prev, [questionIndex]: optionIndex}));
};
const checkQuiz = () => {
setShowQuizResults(true);
};
const HistoryContent = () => (
<div className="flex flex-col h-full bg-white">
<div className="p-4 border-b border-slate-100 bg-slate-50/50 flex items-center justify-between">
<h3 className="font-bold text-slate-700 flex items-center gap-2">
<History size={18} className="text-sky-500" /> {t.historyTitle}
</h3>
<div className="flex items-center gap-3">
{history.length > 0 && (
<button onClick={onClearHistory} className="text-xs text-red-400 hover:text-red-600 hover:underline">
{tCommon.clear}
</button>
)}
<button onClick={() => setIsHistoryOpen(false)} className="md:hidden text-slate-400 hover:text-slate-600">
<X size={20} />
</button>
</div>
</div>
<div className="p-4 space-y-3 overflow-y-auto h-full pb-20">
{history.length === 0 && (
<div className="text-center text-slate-400 text-sm mt-10">{t.emptyHistory}</div>
)}
{history.slice().reverse().map(rec => (
<div
key={rec.id}
onClick={() => loadFromHistory(rec)}
className={`group flex items-start gap-3 p-3 rounded-xl border cursor-pointer relative transition-all ${
currentRecordId === rec.id
? 'bg-sky-50 border-sky-200 shadow-sm'
: 'bg-slate-50 border-slate-100 hover:bg-white hover:shadow-md'
}`}
>
<div className="w-10 h-10 rounded-xl bg-sky-100 flex-shrink-0 flex items-center justify-center text-sky-700 text-[10px] font-bold uppercase shadow-inner">
{rec.difficulty === ReadingDifficulty.BEGINNER ? 'N5' : rec.difficulty === ReadingDifficulty.INTERMEDIATE ? 'N3' : 'N1'}
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-start">
<h4 className={`font-bold text-sm truncate pr-6 ${currentRecordId === rec.id ? 'text-sky-900' : 'text-slate-700'}`}>
{rec.title}
</h4>
</div>
<div className="flex justify-between items-center mt-1">
<span className="text-[10px] text-slate-400">
{new Date(rec.timestamp).toLocaleDateString()} {new Date(rec.timestamp).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</span>
</div>
<p className="text-xs text-slate-500 line-clamp-1 mt-1">{rec.topic}</p>
</div>
<button
onClick={(e) => { e.stopPropagation(); onDeleteHistoryItem(rec.id); }}
className="absolute bottom-2 right-2 p-1.5 text-slate-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
</div>
);
return (
<div className="h-full flex bg-slate-50 relative overflow-hidden">
{/* Left Main Content */}
<div className="flex-1 flex flex-col h-full min-w-0 relative transition-all">
{/* Setup Mode */}
{!lesson && (
<div className="flex flex-col h-full">
<div className="flex items-center justify-end px-4 py-3 bg-slate-50/90 backdrop-blur z-20 sticky top-0">
<button
onClick={() => setIsHistoryOpen(!isHistoryOpen)}
className={`p-2 rounded-lg border transition-colors flex items-center gap-2 text-sm font-medium ${
isHistoryOpen
? 'bg-sky-50 text-sky-600 border-sky-200'
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50'
}`}
>
<History size={18} />
<span className="hidden sm:inline">{t.historyTitle}</span>
{isHistoryOpen ? <PanelRightClose size={16} className="opacity-50" /> : <PanelRightOpen size={16} className="opacity-50" />}
</button>
</div>
<div className="flex-1 flex flex-col items-center justify-center p-6 overflow-y-auto">
<div className="max-w-2xl w-full bg-white p-8 rounded-3xl shadow-sm border border-slate-100 animate-scale-in relative">
<div className="text-center mb-10">
<div className="w-16 h-16 bg-sky-100 text-sky-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-inner animate-pulse">
<Headphones size={32} />
</div>
<h2 className="text-3xl font-extrabold text-slate-800">{t.title}</h2>
<p className="text-slate-500 mt-2">{t.subtitle}</p>
</div>
<div className="space-y-6">
<div className="animate-fade-in-up delay-100">
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">{translations[language].reading.topicLabel}</label>
<input
type="text"
value={topic}
onChange={(e) => setTopic(e.target.value)}
placeholder={translations[language].reading.placeholder}
className="w-full p-4 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-sky-500 focus:border-transparent outline-none font-medium transition-all"
/>
</div>
<div className="animate-fade-in-up delay-200">
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">{translations[language].reading.difficultyLabel}</label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{([ReadingDifficulty.BEGINNER, ReadingDifficulty.INTERMEDIATE, ReadingDifficulty.ADVANCED] as ReadingDifficulty[]).map((lvl) => (
<button
key={lvl}
onClick={() => setDifficulty(lvl)}
className={`p-3 rounded-xl border text-sm font-bold transition-all transform active:scale-95 ${
difficulty === lvl
? 'bg-sky-50 border-sky-500 text-sky-700 shadow-sm ring-1 ring-sky-500'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
{translations[language].reading.levels[lvl]}
</button>
))}
</div>
</div>
<button
onClick={generateLesson}
disabled={!topic.trim() || isGenerating}
className="w-full py-4 bg-sky-600 hover:bg-sky-700 text-white rounded-xl font-bold shadow-lg shadow-sky-200 transition-all disabled:opacity-50 flex items-center justify-center gap-2 mt-4 animate-fade-in-up delay-300 transform active:scale-95"
>
{isGenerating ? <Loader2 className="animate-spin" /> : <Headphones size={20} />}
{isGenerating ? t.generating : t.generate}
</button>
</div>
</div>
</div>
</div>
)}
{/* Lesson Mode */}
{lesson && (
<div className="flex flex-col lg:flex-row h-full overflow-hidden">
{/* Left: Content */}
<div className={`flex-1 flex-col h-full overflow-hidden bg-white relative z-10 ${mobileTab === 'content' ? 'flex' : 'hidden lg:flex'}`}>
<div className="p-4 border-b border-slate-100 flex items-center justify-between bg-white/80 backdrop-blur z-10">
<div className="flex items-center gap-2">
<button onClick={() => { setLesson(null); setCurrentRecordId(null); }} className="text-slate-400 hover:text-slate-600 p-2 hover:scale-110 transition-transform">
<ChevronLeft size={24} />
</button>
</div>
<div className="flex items-center gap-2 flex-1 justify-center">
<h3 className="font-bold text-slate-700 truncate max-w-[100px] sm:max-w-md hidden sm:block">{lesson.title}</h3>
<div className="lg:hidden flex bg-slate-100 rounded-lg p-1 mx-2">
<button
onClick={() => setMobileTab('content')}
className={`px-3 py-1 rounded-md text-xs font-bold transition-all ${mobileTab === 'content' ? 'bg-white text-sky-600 shadow-sm' : 'text-slate-500'}`}
>
<FileText size={14} className="inline mr-1" /> {tCommon.content}
</button>
<button
onClick={() => setMobileTab('tutor')}
className={`px-3 py-1 rounded-md text-xs font-bold transition-all ${mobileTab === 'tutor' ? 'bg-white text-sky-600 shadow-sm' : 'text-slate-500'}`}
>
<MessageCircle size={14} className="inline mr-1" /> {tCommon.tutor}
</button>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setIsHistoryOpen(!isHistoryOpen)}
className={`p-2 rounded-lg border transition-colors flex items-center gap-2 text-sm font-medium ${
isHistoryOpen
? 'bg-sky-50 text-sky-600 border-sky-200'
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50'
}`}
>
{isHistoryOpen ? <PanelRightClose size={18} /> : <PanelRightOpen size={18} />}
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6 md:p-10 bg-slate-50/30">
<div className="max-w-3xl mx-auto space-y-8">
{/* Audio Player Section - Modern Card Design */}
<div className="bg-white p-6 md:p-8 rounded-3xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] border border-slate-100 flex flex-col items-center justify-center animate-scale-in relative overflow-hidden">
{/* Background Decor */}
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_50%_-20%,#f0f9ff,transparent)] pointer-events-none" />
<div className="relative z-10 text-center w-full max-w-md">
{/* Icon / Visualizer */}
<div className="mb-8 flex justify-center">
<div className={`relative w-20 h-20 rounded-3xl flex items-center justify-center transition-all duration-500 ${isPlaying ? 'bg-sky-500 shadow-lg shadow-sky-200 rotate-3' : 'bg-white border-2 border-slate-100 -rotate-3'}`}>
{isTTSLoading ? (
<Loader2 size={32} className={`animate-spin ${isPlaying ? 'text-white' : 'text-sky-500'}`} />
) : (
<Headphones size={32} className={`${isPlaying ? 'text-white animate-bounce-subtle' : 'text-slate-300'}`} />
)}
{isPlaying && (
<span className="absolute -right-2 -top-2 flex h-6 w-6">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-6 w-6 bg-sky-500"></span>
</span>
)}
</div>
</div>
{/* Controls Row - 3 Buttons */}
<div className="flex items-center justify-center gap-6 md:gap-10">
{/* Replay Button */}
<div className="flex flex-col items-center gap-2">
<button
onClick={() => { stopAudio(); setTimeout(() => toggleAudio(), 100); }}
className="w-14 h-14 rounded-2xl bg-slate-50 hover:bg-sky-50 text-slate-500 hover:text-sky-600 border border-slate-100 hover:border-sky-100 flex items-center justify-center transition-all hover:scale-110 hover:-rotate-6 active:scale-95"
title={t.replay}
>
<RotateCcw size={22} strokeWidth={2.5} />
</button>
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">{t.replay}</span>
</div>
{/* Main Play/Pause Button */}
<div className="flex flex-col items-center gap-2 -mt-6">
<button
onClick={toggleAudio}
className={`w-20 h-20 rounded-[2rem] flex items-center justify-center shadow-xl transition-all hover:scale-105 active:scale-95 active:shadow-sm ${
isPlaying
? 'bg-sky-500 text-white shadow-sky-200 hover:bg-sky-600'
: 'bg-white text-sky-500 border-2 border-sky-50 shadow-slate-100 hover:border-sky-100'
}`}
>
{isTTSLoading ? (
<Loader2 size={32} className="animate-spin" />
) : isPlaying ? (
<Pause size={36} fill="currentColor" className="translate-x-px" />
) : (
<Play size={36} fill="currentColor" className="translate-x-1" />
)}
</button>
<span className="text-[10px] font-bold text-sky-500 uppercase tracking-wider">
{isPlaying ? t.pause : t.play}
</span>
</div>
{/* Download Button */}
<div className="flex flex-col items-center gap-2">
<button
onClick={downloadAudio}
disabled={isTTSLoading}
className="w-14 h-14 rounded-2xl bg-slate-50 hover:bg-emerald-50 text-slate-500 hover:text-emerald-600 border border-slate-100 hover:border-emerald-100 flex items-center justify-center transition-all hover:scale-110 hover:rotate-6 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
title={tCommon.save}
>
<Download size={22} strokeWidth={2.5} />
</button>
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">{tCommon.save}</span>
</div>
</div>
</div>
</div>
{/* Comprehension Quiz */}
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm animate-fade-in-up delay-100">
<h4 className="text-lg font-bold text-slate-800 mb-4 flex items-center gap-2"><CheckCircle size={20} className="text-sky-500" /> {t.quizTitle}</h4>
<div className="space-y-6">
{lesson.questions?.map((q, qIndex) => (
<div key={qIndex} className="p-4 bg-slate-50 rounded-xl border border-slate-100">
<p className="font-bold text-slate-700 mb-3">{qIndex + 1}. {q.question}</p>
<div className="space-y-2">
{q.options?.map((opt, optIndex) => {
const isSelected = selectedAnswers[qIndex] === optIndex;
const isCorrect = q.correctIndex === optIndex;
let itemClass = "border-slate-200 hover:bg-white";
if (showQuizResults) {
if (isCorrect) itemClass = "bg-green-50 border-green-200 text-green-800";
else if (isSelected && !isCorrect) itemClass = "bg-red-50 border-red-200 text-red-800";
else itemClass = "opacity-60 border-slate-100";
} else if (isSelected) {
itemClass = "bg-sky-50 border-sky-300 ring-1 ring-sky-300 text-sky-800";
}
return (
<button
key={optIndex}
onClick={() => handleQuizSelect(qIndex, optIndex)}
disabled={showQuizResults}
className={`w-full text-left p-3 rounded-lg border text-sm font-medium transition-all ${itemClass}`}
>
{opt}
{showQuizResults && isCorrect && <CheckCircle size={16} className="inline ml-2 text-green-500" />}
{showQuizResults && isSelected && !isCorrect && <AlertCircle size={16} className="inline ml-2 text-red-500" />}
</button>
);
})}
</div>
{showQuizResults && (
<div className="mt-3 text-xs bg-white p-3 rounded-lg border border-slate-100 text-slate-600">
<span className="font-bold">{tCommon.explanation}:</span> {q.explanation}
</div>
)}
</div>
))}
</div>
{!showQuizResults && (
<button
onClick={checkQuiz}
disabled={Object.keys(selectedAnswers).length < (lesson.questions?.length || 0)}
className="mt-6 w-full py-3 bg-slate-800 text-white rounded-xl font-bold disabled:opacity-50 hover:bg-slate-900 transition-all"
>
{t.check}
</button>
)}
</div>
{/* Script Toggle */}
<div className="flex justify-center">
<button
onClick={() => setShowScript(!showScript)}
className="flex items-center gap-2 px-6 py-2 bg-white border border-slate-200 rounded-full text-slate-600 font-bold hover:bg-slate-50 transition-colors shadow-sm"
>
{showScript ? <EyeOff size={18} /> : <Eye size={18} />}
{showScript ? t.hideScript : t.showScript}
</button>
</div>
{/* Script Reveal */}
{showScript && (
<div className="animate-fade-in-up">
<div className="bg-white p-6 md:p-8 rounded-2xl border border-slate-200 shadow-sm mb-6">
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider mb-4">{t.scriptTitle}</h4>
<p className="text-lg md:text-xl leading-loose font-serif text-slate-800 whitespace-pre-wrap">
{lesson.script || <span className="text-red-400 italic">{t.scriptMissing}</span>}
</p>
</div>
<div className="bg-slate-50 p-6 rounded-2xl border border-slate-200 mb-6">
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider mb-2">{translations[language].reading.translationLabel}</h4>
<p className="text-slate-700 leading-relaxed">{lesson.translation}</p>
</div>
{/* Vocabulary List */}
<div className="bg-sky-50/50 rounded-2xl p-6 border border-sky-100/50">
<h4 className="text-sm font-bold text-sky-800 mb-4 flex items-center gap-2">
<List size={18} /> {translations[language].reading.vocabTitle}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{lesson.vocabulary?.map((v, i) => (
<div key={i} className="bg-white p-3 rounded-xl shadow-sm border border-sky-100 hover:shadow-md transition-shadow relative">
<div className="flex items-baseline gap-2 mb-1">
<span className="text-lg font-bold text-slate-800">{v.word}</span>
<span className="text-sm text-slate-500">({v.reading})</span>
</div>
<p className="text-sm text-sky-700 font-medium">{v.meaning}</p>
</div>
))}
</div>
</div>
</div>
)}
</div>
</div>
</div>
{/* Right: Tutor Chat */}
<div className={`w-full lg:w-96 bg-slate-50 flex-col h-full shadow-inner border-l border-slate-200 z-20 ${mobileTab === 'tutor' ? 'flex' : 'hidden lg:flex'}`}>
<div className="p-3 lg:p-4 bg-white border-b border-slate-200 flex items-center gap-2 shadow-sm">
<button onClick={() => setMobileTab('content')} className="lg:hidden mr-2 text-slate-400"><ChevronLeft size={20} /></button>
<HelpCircle className="text-sky-500 animate-pulse" size={20} />
<span className="font-bold text-slate-700">{translations[language].reading.qaTitle}</span>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-slate-50/50">
{chatMessages.map(msg => (
<ChatBubble key={msg.id} message={msg} language={language} />
))}
{isChatLoading && (
<div className="flex items-center gap-2 text-xs text-slate-400 px-2 animate-pulse">
<Loader2 size={14} className="animate-spin" /> {translations[language].reading.thinking}
</div>
)}
<div ref={chatEndRef} />
</div>
<div className="p-3 bg-white border-t border-slate-200 pb-[env(safe-area-inset-bottom)]">
<div className="flex items-center gap-2 bg-slate-100 rounded-full px-2 py-1 border border-slate-200 focus-within:ring-2 focus-within:ring-sky-500 focus-within:bg-white transition-all">
<input
className="flex-1 bg-transparent border-none focus:ring-0 text-sm px-3 py-2 outline-none text-slate-700 placeholder:text-slate-400"
placeholder={translations[language].reading.qaPlaceholder}
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAskTutor()}
/>
<button
onClick={handleAskTutor}
disabled={!chatInput.trim() || isChatLoading}
className="p-2 bg-sky-500 text-white rounded-full hover:bg-sky-600 disabled:opacity-50 disabled:cursor-not-allowed transform active:scale-95 transition-transform"
>
{isChatLoading ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
</button>
</div>
</div>
</div>
</div>
)}
</div>
{/* Sidebar History (Desktop) */}
<div className={`
hidden md:block h-full bg-white border-l border-slate-200 transition-all duration-300 ease-in-out overflow-hidden z-30
${isHistoryOpen ? 'w-80 opacity-100' : 'w-0 opacity-0 border-none'}
`}>
<HistoryContent />
</div>
{/* Mobile Drawer */}
{isHistoryOpen && (
<div className="fixed inset-0 z-50 md:hidden flex justify-end">
<div className="absolute inset-0 bg-slate-900/30 backdrop-blur-sm transition-opacity" onClick={() => setIsHistoryOpen(false)} />
<div className="relative w-[85%] max-w-sm bg-white h-full shadow-2xl animate-slide-in-right z-50 flex flex-col">
<HistoryContent />
</div>
</div>
)}
</div>
);
};
export default ListeningView;

513
views/OCRView.tsx Normal file
View File

@@ -0,0 +1,513 @@
import React, { useState, useRef, useEffect } from 'react';
import { Language, OCRAnalysis, ChatMessage, Role, MessageType, OCRRecord } from '../types';
import { geminiService, decodeAudioData } from '../services/geminiService';
import { translations } from '../utils/localization';
import { processAndDownloadAudio } from '../utils/audioUtils';
import { ScanText, Upload, Camera, Loader2, Send, Book, PenTool, RotateCcw, History, Trash2, X, PanelRightClose, PanelRightOpen, Volume2, Square, MessageCircle, HelpCircle, ChevronLeft, FileText, Download } from 'lucide-react';
import ChatBubble from '../components/ChatBubble';
interface OCRViewProps {
language: Language;
history: OCRRecord[];
onSaveToHistory: (record: OCRRecord) => void;
onClearHistory: () => void;
onDeleteHistoryItem: (id: string) => void;
addToast: (type: 'success' | 'error' | 'info', msg: string) => void;
}
const OCRView: React.FC<OCRViewProps> = ({ language, history, onSaveToHistory, onClearHistory, onDeleteHistoryItem, addToast }) => {
const t = translations[language].ocr;
const tCommon = translations[language].common;
const [isProcessing, setIsProcessing] = useState(false);
const [analysis, setAnalysis] = useState<OCRAnalysis | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
const [chatInput, setChatInput] = useState('');
const [isChatLoading, setIsChatLoading] = useState(false);
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
// Mobile Tab State: 'content' (Text/Vocab/Notes) vs 'tutor' (Chat)
const [mobileTab, setMobileTab] = useState<'content' | 'tutor'>('content');
// Audio State
const [playingAudioId, setPlayingAudioId] = useState<string | null>(null); // 'main' or 'vocab-word'
const [isDownloading, setIsDownloading] = useState(false);
const audioContextRef = useRef<AudioContext | null>(null);
const audioSourceRef = useRef<AudioBufferSourceNode | null>(null);
const chatEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const cameraInputRef = useRef<HTMLInputElement>(null);
// Scroll to bottom of chat
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [chatMessages, mobileTab]);
// Cleanup audio
useEffect(() => {
return () => {
stopAudio();
};
}, [analysis]);
const handleImageInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onloadend = async () => {
const base64 = reader.result as string;
setImagePreview(base64);
processImage(base64);
};
reader.readAsDataURL(file);
};
const processImage = async (base64: string) => {
setIsProcessing(true);
try {
const result = await geminiService.extractAndAnalyzeText(base64, language);
if (result) {
setAnalysis(result);
setChatMessages([{
id: 'init',
role: Role.MODEL,
type: MessageType.TEXT,
content: t.analyzedIntro.replace('$lang', result.detectedLanguage),
timestamp: Date.now()
}]);
// Save to History
const record: OCRRecord = {
id: Date.now().toString(),
timestamp: Date.now(),
imagePreview: base64,
analysis: result
};
onSaveToHistory(record);
setIsHistoryOpen(false); // Collapse sidebar on new scan
setMobileTab('content'); // Reset to source view
} else {
addToast('error', t.error);
}
} catch (e) {
console.error(e);
addToast('error', t.analysisFailed);
} finally {
setIsProcessing(false);
}
};
const loadFromHistory = (record: OCRRecord) => {
setAnalysis(record.analysis);
setImagePreview(record.imagePreview);
setIsHistoryOpen(false); // Collapse sidebar on load
setMobileTab('content');
setChatMessages([{
id: 'init',
role: Role.MODEL,
type: MessageType.TEXT,
content: t.historyIntro.replace('$lang', record.analysis.detectedLanguage),
timestamp: Date.now()
}]);
};
const stopAudio = () => {
if (audioSourceRef.current) {
audioSourceRef.current.stop();
audioSourceRef.current = null;
}
setPlayingAudioId(null);
};
const playAudio = async (text: string, id: string) => {
if (playingAudioId === id) {
stopAudio();
return;
}
if (playingAudioId) stopAudio();
setPlayingAudioId(id);
try {
const audioBase64 = await geminiService.generateSpeech(text);
if (audioBase64) {
if (!audioContextRef.current) {
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
}
const ctx = audioContextRef.current;
if (ctx.state === 'suspended') await ctx.resume();
const buffer = await decodeAudioData(audioBase64, ctx);
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(ctx.destination);
source.onended = () => setPlayingAudioId(null);
source.start();
audioSourceRef.current = source;
} else {
setPlayingAudioId(null);
}
} catch (e) {
console.error(e);
setPlayingAudioId(null);
}
};
const handleDownload = async (text: string) => {
if (!text.trim()) return;
setIsDownloading(true);
try {
const audioBase64 = await geminiService.generateSpeech(text);
if (audioBase64) {
processAndDownloadAudio(audioBase64, `ocr_extract_${Date.now()}.wav`);
}
} catch (e) {
console.error(e);
} finally {
setIsDownloading(false);
}
};
const handleAskTutor = async () => {
if (!chatInput.trim() || !analysis) return;
const question = chatInput;
setChatInput('');
setIsChatLoading(true);
const newHistory = [...chatMessages, { id: Date.now().toString(), role: Role.USER, type: MessageType.TEXT, content: question, timestamp: Date.now() }];
setChatMessages(newHistory);
const historyText = newHistory.slice(-4).map(m => `${m.role}: ${m.content}`).join('\n');
try {
const dummyLesson = { title: "OCR Scan", japaneseContent: analysis.extractedText, translation: analysis.summary, vocabulary: [] };
const answer = await geminiService.generateReadingTutorResponse(question, dummyLesson, historyText, language);
setChatMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: Role.MODEL, type: MessageType.TEXT, content: answer, timestamp: Date.now() }]);
} catch (e) {
console.error(e);
} finally {
setIsChatLoading(false);
}
};
const reset = () => { setAnalysis(null); setImagePreview(null); setChatMessages([]); };
// History Sidebar Component
const HistoryContent = () => (
<div className="flex flex-col h-full bg-white">
<div className="p-4 border-b border-slate-100 bg-slate-50/50 flex items-center justify-between">
<h3 className="font-bold text-slate-700 flex items-center gap-2">
<History size={18} className="text-indigo-500" /> {t.history}
</h3>
<div className="flex items-center gap-3">
{history.length > 0 && (
<button onClick={onClearHistory} className="text-xs text-red-400 hover:text-red-600 hover:underline">
{t.clear}
</button>
)}
<button onClick={() => setIsHistoryOpen(false)} className="lg:hidden text-slate-400 hover:text-slate-600">
<X size={20} />
</button>
</div>
</div>
<div className="p-4 space-y-3 overflow-y-auto h-full pb-20">
{history.length === 0 && (
<div className="text-center text-slate-400 text-sm mt-10">{t.emptyHistory}</div>
)}
{history.slice().reverse().map(rec => (
<div
key={rec.id}
onClick={() => loadFromHistory(rec)}
className="group flex items-start gap-3 p-3 rounded-xl bg-slate-50 border border-slate-100 hover:bg-white hover:shadow-md cursor-pointer transition-all relative"
>
{/* Image Thumbnail */}
<img src={rec.imagePreview} className="w-12 h-12 object-cover rounded-lg border border-slate-200 flex-shrink-0 bg-white" alt="scan thumbnail" />
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex justify-between items-start">
<div className="text-xs font-bold text-slate-700 line-clamp-1 pr-6">{rec.analysis.extractedText.substring(0, 30) || 'Text'}...</div>
</div>
<div className="flex justify-between items-center mt-1">
<span className="text-[10px] text-slate-400">
{new Date(rec.timestamp).toLocaleDateString()} {new Date(rec.timestamp).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</span>
</div>
<div className="text-[10px] text-indigo-400 mt-1 truncate">
{t.analyzedIntro.replace('$lang', rec.analysis.detectedLanguage)}
</div>
</div>
{/* Delete Button */}
<button
onClick={(e) => { e.stopPropagation(); onDeleteHistoryItem(rec.id); }}
className="absolute bottom-2 right-2 p-1.5 text-slate-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
</div>
);
// LOADING
if (isProcessing) {
return (
<div className="h-full flex flex-col items-center justify-center bg-slate-50 animate-fade-in">
<div className="relative">
{imagePreview && <img src={imagePreview} className="w-32 h-32 object-cover rounded-2xl opacity-50 blur-sm" alt="processing" />}
<div className="absolute inset-0 flex items-center justify-center">
<Loader2 size={48} className="text-indigo-600 animate-spin" />
</div>
</div>
<p className="mt-4 font-bold text-slate-600 animate-pulse">{t.processing}</p>
</div>
);
}
return (
<div className="h-full flex bg-slate-50 relative overflow-hidden">
{/* Main Content Area */}
<div className="flex-1 flex flex-col h-full min-w-0 relative">
{/* SETUP SCREEN */}
{!analysis ? (
<div className="flex flex-col h-full">
{/* Sticky Header */}
<div className="flex items-center justify-end px-4 py-3 bg-slate-50/90 backdrop-blur z-20 sticky top-0">
<button
onClick={() => setIsHistoryOpen(!isHistoryOpen)}
className={`p-2 rounded-lg border transition-colors flex items-center gap-2 text-sm font-medium ${
isHistoryOpen
? 'bg-indigo-50 text-indigo-600 border-indigo-200'
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50'
}`}
>
<History size={18} />
<span className="hidden sm:inline">{t.history}</span>
{isHistoryOpen ? <PanelRightClose size={16} className="opacity-50" /> : <PanelRightOpen size={16} className="opacity-50" />}
</button>
</div>
<div className="flex-1 flex flex-col items-center justify-center p-6 animate-fade-in overflow-y-auto">
<div className="max-w-lg w-full bg-white p-10 rounded-3xl shadow-sm border border-slate-100 text-center animate-scale-in relative">
<div className="w-20 h-20 bg-indigo-50 text-indigo-600 rounded-full flex items-center justify-center mx-auto mb-6 shadow-inner">
<ScanText size={40} />
</div>
<h2 className="text-3xl font-extrabold text-slate-800 mb-2">{t.title}</h2>
<p className="text-slate-500 mb-8">{t.subtitle}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<button onClick={() => cameraInputRef.current?.click()} className="p-4 border-2 border-indigo-100 bg-indigo-50 hover:bg-indigo-100 hover:border-indigo-300 rounded-2xl flex flex-col items-center gap-2 transition-all group">
<Camera size={28} className="text-indigo-500 group-hover:scale-110 transition-transform" />
<span className="font-bold text-indigo-900">{t.cameraBtn}</span>
<input type="file" ref={cameraInputRef} className="hidden" accept="image/*" capture="environment" onChange={handleImageInput} />
</button>
<button onClick={() => fileInputRef.current?.click()} className="p-4 border-2 border-slate-100 bg-slate-50 hover:bg-slate-100 hover:border-slate-300 rounded-2xl flex flex-col items-center gap-2 transition-all group">
<Upload size={28} className="text-slate-500 group-hover:scale-110 transition-transform" />
<span className="font-bold text-slate-700">{t.uploadBtn}</span>
<input type="file" ref={fileInputRef} className="hidden" accept="image/*" onChange={handleImageInput} />
</button>
</div>
</div>
</div>
</div>
) : (
// ANALYSIS SCREEN
<div className="h-full flex flex-col lg:flex-row overflow-hidden">
{/* LEFT: Main Content (Image, Text, Notes, Vocab) */}
<div className={`flex-1 flex-col h-full overflow-y-auto bg-white relative z-10 ${mobileTab === 'content' ? 'flex' : 'hidden lg:flex'}`}>
{/* Header */}
<div className="p-4 border-b border-slate-100 flex items-center justify-between bg-white/80 backdrop-blur sticky top-0 z-20">
<div className="flex items-center gap-2">
<h3 className="font-bold text-slate-700 flex items-center gap-2 truncate"><ScanText className="text-indigo-500" size={20} /> {t.title}</h3>
</div>
{/* Mobile Tab Switcher */}
<div className="lg:hidden flex bg-slate-100 rounded-lg p-1 mx-2">
<button
onClick={() => setMobileTab('content')}
className={`px-3 py-1 rounded-md text-xs font-bold transition-all ${mobileTab === 'content' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500'}`}
>
<FileText size={14} className="inline mr-1" /> {tCommon.content}
</button>
<button
onClick={() => setMobileTab('tutor')}
className={`px-3 py-1 rounded-md text-xs font-bold transition-all ${mobileTab === 'tutor' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500'}`}
>
<MessageCircle size={14} className="inline mr-1" /> {tCommon.tutor}
</button>
</div>
<div className="flex gap-2 items-center">
<button onClick={reset} className="flex items-center gap-1 text-sm font-bold text-slate-500 hover:text-indigo-600 px-3 py-1 rounded-full hover:bg-slate-100 transition-colors">
<RotateCcw size={16} /> <span className="hidden sm:inline">{t.reScan}</span>
</button>
<button
onClick={() => setIsHistoryOpen(!isHistoryOpen)}
className={`p-2 rounded-lg border transition-colors flex items-center gap-2 text-sm font-medium ${
isHistoryOpen
? 'bg-indigo-50 text-indigo-600 border-indigo-200'
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50'
}`}
>
{isHistoryOpen ? <PanelRightClose size={18} /> : <PanelRightOpen size={18} />}
</button>
</div>
</div>
{/* Content Scroll Area */}
<div className="p-6 lg:p-10 space-y-8 max-w-4xl mx-auto">
{/* 1. Image & Extracted Text */}
<div className="flex flex-col md:flex-row gap-6">
<div className="w-full md:w-1/3">
<div className="rounded-2xl overflow-hidden border border-slate-200 shadow-sm bg-slate-900/5">
<img src={imagePreview!} className="w-full h-auto object-contain" alt="scan result" />
</div>
</div>
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider">{t.extractedTitle}</h4>
<div className="flex items-center gap-2">
<button
onClick={() => playAudio(analysis?.extractedText || '', 'main')}
className={`p-1.5 rounded-full transition-colors ${playingAudioId === 'main' ? 'bg-pink-100 text-pink-500' : 'text-slate-400 hover:bg-indigo-50 hover:text-indigo-500'}`}
>
{playingAudioId === 'main' ? <Square size={16} fill="currentColor" /> : <Volume2 size={16} />}
</button>
<button
onClick={() => handleDownload(analysis?.extractedText || '')}
className={`p-1.5 rounded-full transition-colors ${isDownloading ? 'bg-slate-100 text-slate-500' : 'text-slate-400 hover:bg-slate-100 hover:text-slate-600'}`}
disabled={isDownloading}
>
{isDownloading ? <Loader2 size={16} className="animate-spin" /> : <Download size={16} />}
</button>
</div>
</div>
<div className="p-4 bg-white rounded-2xl border border-slate-200 text-lg leading-relaxed whitespace-pre-wrap font-serif text-slate-800 shadow-sm">
{analysis?.extractedText}
</div>
</div>
</div>
{/* 2. Summary */}
<div className="animate-fade-in-up delay-100">
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider mb-2">{t.summaryHeader}</h4>
<p className="text-slate-700 leading-relaxed bg-indigo-50/50 p-6 rounded-2xl border border-indigo-100">{analysis?.summary}</p>
</div>
{/* 3. Vocabulary */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm animate-fade-in-up delay-200">
<h4 className="text-sm font-bold text-indigo-800 mb-4 flex items-center gap-2"><Book size={18} /> {t.vocabHeader}</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{analysis?.vocabulary.map((v, i) => (
<div key={i} className="bg-slate-50 p-3 rounded-xl border border-slate-100 flex flex-col group hover:bg-white hover:shadow-md transition-all">
<div className="flex justify-between items-baseline mb-1">
<div className="flex items-baseline gap-2">
<span className="font-bold text-slate-800">{v.word}</span>
<span className="text-xs text-slate-500 font-mono">({v.reading})</span>
</div>
<button
onClick={() => playAudio(v.word, `vocab-${i}`)}
className={`p-1.5 rounded-full transition-colors ${playingAudioId === `vocab-${i}` ? 'bg-pink-100 text-pink-500' : 'text-slate-300 hover:bg-indigo-50 hover:text-indigo-500'}`}
>
{playingAudioId === `vocab-${i}` ? <Loader2 size={14} className="animate-spin" /> : <Volume2 size={14} />}
</button>
</div>
<span className="text-sm text-indigo-600 font-medium">{v.meaning}</span>
</div>
))}
</div>
</div>
{/* 4. Grammar */}
{analysis?.grammarPoints && analysis.grammarPoints.length > 0 && (
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm animate-fade-in-up delay-300">
<h4 className="text-sm font-bold text-emerald-800 mb-4 flex items-center gap-2"><PenTool size={18} /> {t.grammarHeader}</h4>
<div className="space-y-4">
{analysis.grammarPoints.map((g, i) => (
<div key={i} className="bg-emerald-50/50 p-4 rounded-xl border border-emerald-100">
<h5 className="font-bold text-emerald-900 mb-1">{g.point}</h5>
<p className="text-sm text-emerald-700 leading-relaxed">{g.explanation}</p>
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* RIGHT: Tutor Chat (Tab: tutor) */}
<div className={`w-full lg:w-96 bg-slate-50 flex-col h-full shadow-inner border-l border-slate-200 z-20 ${mobileTab === 'tutor' ? 'flex' : 'hidden lg:flex'}`}>
{/* Header */}
<div className="p-3 lg:p-4 bg-white border-b border-slate-200 flex items-center gap-2 shadow-sm">
<button onClick={() => setMobileTab('content')} className="lg:hidden mr-2 text-slate-400"><ChevronLeft size={20} /></button>
<HelpCircle className="text-indigo-500 animate-pulse" size={20} />
<span className="font-bold text-slate-700">{t.tutorChat}</span>
</div>
{/* Chat Area */}
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-slate-50/50">
{chatMessages.map(msg => <ChatBubble key={msg.id} message={msg} language={language} />)}
{isChatLoading && (
<div className="flex items-center gap-2 text-xs text-slate-400 px-2 animate-pulse">
<Loader2 size={14} className="animate-spin" /> {t.thinking}
</div>
)}
<div ref={chatEndRef} />
</div>
{/* Input Area */}
<div className="p-3 bg-white border-t border-slate-200 pb-[env(safe-area-inset-bottom)]">
<div className="flex items-center gap-2 bg-slate-100 rounded-full px-2 py-1 border border-slate-200 focus-within:ring-2 focus-within:ring-indigo-500 focus-within:bg-white transition-all">
<input
className="flex-1 bg-transparent border-none focus:ring-0 text-sm px-3 py-2 outline-none text-slate-700 placeholder:text-slate-400"
placeholder={t.chatPlaceholder}
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAskTutor()}
/>
<button
onClick={handleAskTutor}
disabled={!chatInput.trim() || isChatLoading}
className="p-2 bg-indigo-600 text-white rounded-full hover:bg-indigo-700 disabled:opacity-50 transform active:scale-95 transition-transform"
>
{isChatLoading ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
</button>
</div>
</div>
</div>
</div>
)}
</div>
{/* Sidebar History (Desktop) */}
<div className={`
hidden lg:block h-full bg-white border-l border-slate-200 transition-all duration-300 ease-in-out overflow-hidden z-30
${isHistoryOpen ? 'w-80 opacity-100' : 'w-0 opacity-0 border-none'}
`}>
<HistoryContent />
</div>
{/* Mobile Drawer (Slide Over) */}
{isHistoryOpen && (
<div className="fixed inset-0 z-50 lg:hidden flex justify-end">
<div className="absolute inset-0 bg-slate-900/30 backdrop-blur-sm transition-opacity" onClick={() => setIsHistoryOpen(false)} />
<div className="relative w-[85%] max-w-sm bg-white h-full shadow-2xl animate-slide-in-right z-50 flex flex-col">
<HistoryContent />
</div>
</div>
)}
</div>
);
};
export default OCRView;

647
views/ReadingView.tsx Normal file
View File

@@ -0,0 +1,647 @@
import React, { useState, useRef, useEffect } from 'react';
import { Language, ReadingLesson, ReadingDifficulty, ChatMessage, Role, MessageType, ReadingLessonRecord } from '../types';
import { geminiService, decodeAudioData } from '../services/geminiService';
import { processAndDownloadAudio } from '../utils/audioUtils';
import { BookOpen, Loader2, Send, ToggleLeft, ToggleRight, List, HelpCircle, ChevronLeft, RotateCcw, History, Trash2, X, PanelRightClose, PanelRightOpen, Volume2, Square, MessageCircle, FileText, PenTool, Download } from 'lucide-react';
import { translations } from '../utils/localization';
import ChatBubble from '../components/ChatBubble';
interface ReadingViewProps {
language: Language;
history: ReadingLessonRecord[];
onSaveToHistory: (lesson: ReadingLessonRecord) => void;
onUpdateHistory: (lesson: ReadingLessonRecord) => void;
onClearHistory: () => void;
onDeleteHistoryItem: (id: string) => void;
}
const ReadingView: React.FC<ReadingViewProps> = ({ language, history, onSaveToHistory, onUpdateHistory, onClearHistory, onDeleteHistoryItem }) => {
const t = translations[language].reading;
const tCommon = translations[language].common;
// Setup State
const [topic, setTopic] = useState('');
const [difficulty, setDifficulty] = useState<ReadingDifficulty>(ReadingDifficulty.INTERMEDIATE);
const [isGenerating, setIsGenerating] = useState(false);
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
// Content State
const [lesson, setLesson] = useState<ReadingLesson | null>(null);
const [currentRecordId, setCurrentRecordId] = useState<string | null>(null);
const [showTranslation, setShowTranslation] = useState(false);
// Mobile Tab State
const [mobileTab, setMobileTab] = useState<'text' | 'tutor'>('text');
// TTS State
const [isTTSLoading, setIsTTSLoading] = useState(false);
const [isPlayingTTS, setIsPlayingTTS] = useState(false);
const [playingVocabWord, setPlayingVocabWord] = useState<string | null>(null);
const [audioCache, setAudioCache] = useState<string | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const audioSourceRef = useRef<AudioBufferSourceNode | null>(null);
// Tutor Chat State
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
const [chatInput, setChatInput] = useState('');
const [isChatLoading, setIsChatLoading] = useState(false);
const chatEndRef = useRef<HTMLDivElement>(null);
// Cleanup audio when leaving lesson
useEffect(() => {
return () => {
if (audioSourceRef.current) {
audioSourceRef.current.stop();
}
setIsPlayingTTS(false);
setPlayingVocabWord(null);
};
}, [lesson]);
const generateLesson = async () => {
if (!topic.trim()) return;
setIsGenerating(true);
try {
const result = await geminiService.generateReadingLesson(topic, difficulty, language);
if (result) {
const newId = Date.now().toString();
const initialChat: ChatMessage[] = [{
id: 'init',
role: Role.MODEL,
type: MessageType.TEXT,
content: t.qaWelcome,
timestamp: Date.now()
}];
const record: ReadingLessonRecord = {
...result,
id: newId,
topic: topic,
difficulty: difficulty,
timestamp: Date.now(),
chatHistory: initialChat
};
onSaveToHistory(record);
// Set State
setLesson(result);
setCurrentRecordId(newId);
setChatMessages(initialChat);
setAudioCache(null);
// Collapse sidebar by default when entering detail view
setIsHistoryOpen(false);
setMobileTab('text'); // Default to text view
}
} catch (e) {
console.error(e);
} finally {
setIsGenerating(false);
}
};
const loadFromHistory = (record: ReadingLessonRecord) => {
setLesson(record);
setCurrentRecordId(record.id);
setIsHistoryOpen(false); // Collapse sidebar by default
setMobileTab('text');
setAudioCache(null);
if (record.chatHistory && record.chatHistory.length > 0) {
setChatMessages(record.chatHistory);
} else {
setChatMessages([{
id: 'init',
role: Role.MODEL,
type: MessageType.TEXT,
content: t.qaWelcome,
timestamp: Date.now()
}]);
}
};
const updateCurrentLessonChat = (newMessages: ChatMessage[]) => {
setChatMessages(newMessages);
if (currentRecordId && lesson) {
// Find existing record details to preserve topic/difficulty
const existing = history.find(h => h.id === currentRecordId);
if (existing) {
onUpdateHistory({
...existing,
chatHistory: newMessages
});
}
}
};
const initAudioContext = async () => {
if (!audioContextRef.current) {
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
}
if (audioContextRef.current.state === 'suspended') {
await audioContextRef.current.resume();
}
return audioContextRef.current;
};
const stopAudio = () => {
if (audioSourceRef.current) {
audioSourceRef.current.stop();
audioSourceRef.current = null;
}
setIsPlayingTTS(false);
setPlayingVocabWord(null);
};
const playAudioData = async (base64Data: string, onEnded: () => void) => {
stopAudio();
const ctx = await initAudioContext();
const buffer = await decodeAudioData(base64Data, ctx);
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(ctx.destination);
source.onended = onEnded;
source.start();
audioSourceRef.current = source;
};
const toggleTTS = async () => {
if (isPlayingTTS) {
stopAudio();
return;
}
if (audioCache) {
setIsPlayingTTS(true);
await playAudioData(audioCache, () => setIsPlayingTTS(false));
return;
}
if (!lesson?.japaneseContent) return;
setIsTTSLoading(true);
try {
const audioBase64 = await geminiService.generateSpeech(lesson.japaneseContent);
if (!audioBase64) return;
setAudioCache(audioBase64);
setIsPlayingTTS(true);
await playAudioData(audioBase64, () => setIsPlayingTTS(false));
} catch (e) {
console.error("TTS Playback failed", e);
setIsPlayingTTS(false);
} finally {
setIsTTSLoading(false);
}
};
const downloadTTS = async () => {
if (audioCache) {
processAndDownloadAudio(audioCache, `sakura_reading_${Date.now()}.wav`);
return;
}
if (!lesson?.japaneseContent) return;
setIsTTSLoading(true);
try {
const audioBase64 = await geminiService.generateSpeech(lesson.japaneseContent);
if (audioBase64) {
setAudioCache(audioBase64);
processAndDownloadAudio(audioBase64, `sakura_reading_${Date.now()}.wav`);
}
} catch (e) {
console.error("TTS Download failed", e);
} finally {
setIsTTSLoading(false);
}
};
const playVocab = async (word: string) => {
if (playingVocabWord === word) {
stopAudio();
return;
}
setPlayingVocabWord(word);
try {
const audioBase64 = await geminiService.generateSpeech(word);
if (audioBase64) {
await playAudioData(audioBase64, () => setPlayingVocabWord(null));
} else {
setPlayingVocabWord(null);
}
} catch (e) {
setPlayingVocabWord(null);
}
};
const handleAskTutor = async () => {
if (!chatInput.trim() || !lesson) return;
const question = chatInput;
setChatInput('');
setIsChatLoading(true);
// Add User Message
const updatedMessages = [...chatMessages, {
id: Date.now().toString(),
role: Role.USER,
type: MessageType.TEXT,
content: question,
timestamp: Date.now()
}];
updateCurrentLessonChat(updatedMessages);
// Build history string for context
const historyText = updatedMessages.slice(-4).map(m => `${m.role}: ${m.content}`).join('\n');
try {
const answer = await geminiService.generateReadingTutorResponse(question, lesson, historyText, language);
const finalMessages = [...updatedMessages, {
id: (Date.now() + 1).toString(),
role: Role.MODEL,
type: MessageType.TEXT,
content: answer,
timestamp: Date.now()
}];
updateCurrentLessonChat(finalMessages);
} catch (e) {
console.error(e);
} finally {
setIsChatLoading(false);
}
};
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [chatMessages, mobileTab]);
const HistoryContent = () => (
<div className="flex flex-col h-full bg-white">
<div className="p-4 border-b border-slate-100 bg-slate-50/50 flex items-center justify-between">
<h3 className="font-bold text-slate-700 flex items-center gap-2">
<History size={18} className="text-emerald-500" /> {t.historyTitle}
</h3>
<div className="flex items-center gap-3">
{history.length > 0 && (
<button onClick={onClearHistory} className="text-xs text-red-400 hover:text-red-600 hover:underline">
{t.clear}
</button>
)}
{/* Close button explicitly for mobile */}
<button onClick={() => setIsHistoryOpen(false)} className="md:hidden text-slate-400 hover:text-slate-600">
<X size={20} />
</button>
</div>
</div>
<div className="p-4 space-y-3 overflow-y-auto h-full pb-20">
{history.length === 0 && (
<div className="text-center text-slate-400 text-sm mt-10">{t.emptyHistory}</div>
)}
{history.slice().reverse().map(rec => (
<div
key={rec.id}
onClick={() => loadFromHistory(rec)}
className={`group flex items-start gap-3 p-3 rounded-xl border cursor-pointer relative transition-all ${
currentRecordId === rec.id
? 'bg-emerald-50 border-emerald-200 shadow-sm'
: 'bg-slate-50 border-slate-100 hover:bg-white hover:shadow-md'
}`}
>
{/* Difficulty Icon/Badge */}
<div className="w-10 h-10 rounded-xl bg-emerald-100 flex-shrink-0 flex items-center justify-center text-emerald-700 text-[10px] font-bold uppercase shadow-inner">
{rec.difficulty === ReadingDifficulty.BEGINNER ? 'N5' : rec.difficulty === ReadingDifficulty.INTERMEDIATE ? 'N3' : 'N1'}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex justify-between items-start">
<h4 className={`font-bold text-sm truncate pr-6 ${currentRecordId === rec.id ? 'text-emerald-900' : 'text-slate-700'}`}>
{rec.title}
</h4>
</div>
<div className="flex justify-between items-center mt-1">
<span className="text-[10px] text-slate-400">
{new Date(rec.timestamp).toLocaleDateString()} {new Date(rec.timestamp).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</span>
</div>
<p className="text-xs text-slate-500 line-clamp-1 mt-1">{rec.topic}</p>
</div>
{/* Delete Button */}
<button
onClick={(e) => { e.stopPropagation(); onDeleteHistoryItem(rec.id); }}
className="absolute bottom-2 right-2 p-1.5 text-slate-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
</div>
);
// --- MAIN LAYOUT ---
return (
<div className="h-full flex bg-slate-50 relative overflow-hidden">
{/* Left Main Content */}
<div className="flex-1 flex flex-col h-full min-w-0 relative transition-all">
{/* Setup Mode */}
{!lesson && (
<div className="flex flex-col h-full">
{/* Sticky Header for Setup */}
<div className="flex items-center justify-end px-4 py-3 bg-slate-50/90 backdrop-blur z-20 sticky top-0">
<button
onClick={() => setIsHistoryOpen(!isHistoryOpen)}
className={`p-2 rounded-lg border transition-colors flex items-center gap-2 text-sm font-medium ${
isHistoryOpen
? 'bg-emerald-50 text-emerald-600 border-emerald-200'
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50'
}`}
>
<History size={18} />
<span className="hidden sm:inline">{t.historyTitle}</span>
{isHistoryOpen ? <PanelRightClose size={16} className="opacity-50" /> : <PanelRightOpen size={16} className="opacity-50" />}
</button>
</div>
<div className="flex-1 flex flex-col items-center justify-center p-6 overflow-y-auto">
<div className="max-w-2xl w-full bg-white p-8 rounded-3xl shadow-sm border border-slate-100 animate-scale-in relative">
<div className="text-center mb-10">
<div className="w-16 h-16 bg-emerald-100 text-emerald-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-inner animate-pulse">
<BookOpen size={32} />
</div>
<h2 className="text-3xl font-extrabold text-slate-800">{t.title}</h2>
<p className="text-slate-500 mt-2">{t.subtitle}</p>
</div>
<div className="space-y-6">
<div className="animate-fade-in-up delay-100">
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">{t.topicLabel}</label>
<input
type="text"
value={topic}
onChange={(e) => setTopic(e.target.value)}
placeholder={t.placeholder}
className="w-full p-4 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 focus:border-transparent outline-none font-medium transition-all"
/>
</div>
<div className="animate-fade-in-up delay-200">
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">{t.difficultyLabel}</label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{([ReadingDifficulty.BEGINNER, ReadingDifficulty.INTERMEDIATE, ReadingDifficulty.ADVANCED] as ReadingDifficulty[]).map((lvl) => (
<button
key={lvl}
onClick={() => setDifficulty(lvl)}
className={`p-3 rounded-xl border text-sm font-bold transition-all transform active:scale-95 ${
difficulty === lvl
? 'bg-emerald-50 border-emerald-500 text-emerald-700 shadow-sm ring-1 ring-emerald-500'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
{t.levels[lvl]}
</button>
))}
</div>
</div>
<button
onClick={generateLesson}
disabled={!topic.trim() || isGenerating}
className="w-full py-4 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl font-bold shadow-lg shadow-emerald-200 transition-all disabled:opacity-50 flex items-center justify-center gap-2 mt-4 animate-fade-in-up delay-300 transform active:scale-95"
>
{isGenerating ? <Loader2 className="animate-spin" /> : <BookOpen size={20} />}
{isGenerating ? t.generating : t.generate}
</button>
</div>
</div>
</div>
</div>
)}
{/* Lesson Mode */}
{lesson && (
<div className="flex flex-col lg:flex-row h-full overflow-hidden">
{/* Left: Content */}
<div className={`flex-1 flex-col h-full overflow-hidden bg-white relative z-10 ${mobileTab === 'text' ? 'flex' : 'hidden lg:flex'}`}>
<div className="p-4 border-b border-slate-100 flex items-center justify-between bg-white/80 backdrop-blur z-10">
<div className="flex items-center gap-2 flex-shrink-0">
<button onClick={() => { setLesson(null); setCurrentRecordId(null); }} className="text-slate-400 hover:text-slate-600 p-2 hover:scale-110 transition-transform">
<ChevronLeft size={24} />
</button>
</div>
<div className="flex items-center gap-2 flex-1 justify-center min-w-0">
<h3 className="font-bold text-slate-700 truncate max-w-[100px] sm:max-w-md hidden sm:block">{lesson.title}</h3>
{/* Mobile Tab Switcher */}
<div className="lg:hidden flex bg-slate-100 rounded-lg p-1 mx-2">
<button
onClick={() => setMobileTab('text')}
className={`px-3 py-1 rounded-md text-xs font-bold transition-all ${mobileTab === 'text' ? 'bg-white text-emerald-600 shadow-sm' : 'text-slate-500'}`}
>
<FileText size={14} className="inline mr-1" /> {tCommon.text}
</button>
<button
onClick={() => setMobileTab('tutor')}
className={`px-3 py-1 rounded-md text-xs font-bold transition-all ${mobileTab === 'tutor' ? 'bg-white text-emerald-600 shadow-sm' : 'text-slate-500'}`}
>
<MessageCircle size={14} className="inline mr-1" /> {tCommon.tutor}
</button>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={toggleTTS}
disabled={isTTSLoading || !lesson.japaneseContent}
className={`hidden sm:flex items-center gap-2 text-xs font-bold px-3 py-1.5 rounded-full transition-colors ${
isPlayingTTS
? 'bg-pink-100 text-pink-600 hover:bg-pink-200'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{isTTSLoading ? <Loader2 size={14} className="animate-spin" /> : isPlayingTTS ? <Square size={14} fill="currentColor" /> : <Volume2 size={14} />}
{isPlayingTTS ? t.stopAudio : t.playAudio}
</button>
{/* Download Button - Now visible on mobile (icon only) */}
<button
onClick={downloadTTS}
disabled={isTTSLoading || !lesson.japaneseContent}
className={`flex items-center justify-center w-8 h-8 sm:w-auto sm:px-3 sm:py-1.5 rounded-full transition-colors bg-slate-100 hover:bg-slate-200 text-slate-600 disabled:opacity-50 disabled:cursor-not-allowed`}
title="Download Audio"
>
<Download size={14} />
</button>
{/* Mobile Icon only for TTS Play */}
<button
onClick={toggleTTS}
disabled={isTTSLoading || !lesson.japaneseContent}
className={`sm:hidden flex items-center justify-center w-8 h-8 rounded-full transition-colors ${
isPlayingTTS
? 'bg-pink-100 text-pink-600'
: 'bg-slate-100 text-slate-600'
} disabled:opacity-50`}
>
{isTTSLoading ? <Loader2 size={14} className="animate-spin" /> : isPlayingTTS ? <Square size={14} fill="currentColor" /> : <Volume2 size={14} />}
</button>
<button
onClick={() => setShowTranslation(!showTranslation)}
disabled={!lesson.translation}
className="flex items-center gap-2 text-xs font-bold px-3 py-1.5 bg-slate-100 hover:bg-slate-200 rounded-full transition-colors text-slate-600 disabled:opacity-50"
>
{showTranslation ? <ToggleRight className="text-emerald-600" /> : <ToggleLeft />}
<span className="hidden sm:inline">{t.translationLabel}</span>
</button>
{/* Sidebar Toggle In Lesson View */}
<button
onClick={() => setIsHistoryOpen(!isHistoryOpen)}
className={`p-2 rounded-lg border transition-colors flex items-center gap-2 text-sm font-medium ${
isHistoryOpen
? 'bg-emerald-50 text-emerald-600 border-emerald-200'
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50'
}`}
>
{isHistoryOpen ? <PanelRightClose size={18} /> : <PanelRightOpen size={18} />}
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6 md:p-10">
<div className="max-w-3xl mx-auto">
<div className="mb-12 animate-fade-in-up delay-100">
<p className="text-xl md:text-3xl leading-loose font-serif text-slate-800 whitespace-pre-wrap">
{lesson.japaneseContent || <span className="text-red-400 italic text-base">{t.contentMissing}</span>}
</p>
</div>
{showTranslation && (
<div className="mb-12 p-6 bg-slate-50 rounded-2xl border border-slate-200 animate-scale-in">
<h4 className="text-xs font-bold text-slate-400 uppercase mb-3">{t.translationLabel}</h4>
<p className="text-lg leading-relaxed text-slate-600">{lesson.translation || <span className="text-slate-400 italic">{t.translationMissing}</span>}</p>
</div>
)}
<div className="bg-emerald-50/50 rounded-2xl p-6 border border-emerald-100/50 animate-fade-in-up delay-300">
<h4 className="text-sm font-bold text-emerald-800 mb-4 flex items-center gap-2">
<List size={18} /> {t.vocabTitle}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{lesson.vocabulary.map((v, i) => (
<div key={i} className="bg-white p-3 rounded-xl shadow-sm border border-emerald-100 hover:shadow-md transition-shadow relative group">
<div className="flex items-baseline justify-between mb-1">
<div className="flex items-baseline gap-2">
<span className="text-lg font-bold text-slate-800">{v.word}</span>
<span className="text-sm text-slate-500">({v.reading})</span>
</div>
<button
onClick={() => playVocab(v.word)}
className={`p-1.5 rounded-full transition-colors ${playingVocabWord === v.word ? 'bg-pink-100 text-pink-500' : 'text-slate-300 hover:bg-emerald-50 hover:text-emerald-600'}`}
>
{playingVocabWord === v.word ? <Loader2 size={14} className="animate-spin" /> : <Volume2 size={14} />}
</button>
</div>
<p className="text-sm text-emerald-700 font-medium">{v.meaning}</p>
</div>
))}
</div>
</div>
{/* Grammar Section */}
{lesson.grammarPoints && lesson.grammarPoints.length > 0 && (
<div className="bg-emerald-50/50 rounded-2xl p-6 border border-emerald-100/50 animate-fade-in-up delay-400 mt-6">
<h4 className="text-sm font-bold text-emerald-800 mb-4 flex items-center gap-2">
<PenTool size={18} /> {t.grammarHeader}
</h4>
<div className="space-y-4">
{lesson.grammarPoints.map((g, i) => (
<div key={i} className="bg-white p-4 rounded-xl border border-emerald-100">
<h5 className="font-bold text-emerald-900 mb-1">{g.point}</h5>
<p className="text-sm text-emerald-700 leading-relaxed">{g.explanation}</p>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
{/* Right: Tutor Chat (Only visible in lesson mode) */}
<div className={`w-full lg:w-96 bg-slate-50 flex-col h-full shadow-inner border-l border-slate-200 z-20 ${mobileTab === 'tutor' ? 'flex' : 'hidden lg:flex'}`}>
{/* Chat Header */}
<div className="p-3 lg:p-4 bg-white border-b border-slate-200 flex items-center gap-2 shadow-sm">
{/* Mobile back button for consistency, though tab works too */}
<button onClick={() => setMobileTab('text')} className="lg:hidden mr-2 text-slate-400"><ChevronLeft size={20} /></button>
<HelpCircle className="text-emerald-500 animate-pulse" size={20} />
<span className="font-bold text-slate-700">{t.qaTitle}</span>
</div>
{/* Chat Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-slate-50/50">
{chatMessages.map(msg => (
<ChatBubble key={msg.id} message={msg} language={language} />
))}
{isChatLoading && (
<div className="flex items-center gap-2 text-xs text-slate-400 px-2 animate-pulse">
<Loader2 size={14} className="animate-spin" /> {t.thinking}
</div>
)}
<div ref={chatEndRef} />
</div>
{/* Chat Input */}
<div className="p-3 bg-white border-t border-slate-200 pb-[env(safe-area-inset-bottom)]">
<div className="flex items-center gap-2 bg-slate-100 rounded-full px-2 py-1 border border-slate-200 focus-within:ring-2 focus-within:ring-emerald-500 focus-within:bg-white transition-all">
<input
className="flex-1 bg-transparent border-none focus:ring-0 text-sm px-3 py-2 outline-none text-slate-700 placeholder:text-slate-400"
placeholder={t.qaPlaceholder}
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAskTutor()}
/>
<button
onClick={handleAskTutor}
disabled={!chatInput.trim() || isChatLoading}
className="p-2 bg-emerald-500 text-white rounded-full hover:bg-emerald-600 disabled:opacity-50 disabled:cursor-not-allowed transform active:scale-95 transition-transform"
>
{isChatLoading ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
</button>
</div>
</div>
</div>
</div>
)}
</div>
{/* Sidebar History (Desktop - Collapsible) */}
<div className={`
hidden md:block h-full bg-white border-l border-slate-200 transition-all duration-300 ease-in-out overflow-hidden z-30
${isHistoryOpen ? 'w-80 opacity-100' : 'w-0 opacity-0 border-none'}
`}>
<HistoryContent />
</div>
{/* Mobile Drawer */}
{isHistoryOpen && (
<div className="fixed inset-0 z-50 md:hidden flex justify-end">
<div className="absolute inset-0 bg-slate-900/30 backdrop-blur-sm transition-opacity" onClick={() => setIsHistoryOpen(false)} />
<div className="relative w-[85%] max-w-sm bg-white h-full shadow-2xl animate-slide-in-right z-50 flex flex-col">
<HistoryContent />
</div>
</div>
)}
</div>
);
};
export default ReadingView;

View File

@@ -0,0 +1,397 @@
import React, { useState, useEffect, useRef } from 'react';
import { geminiService, decodeAudioData } from '../services/geminiService';
import AudioRecorder from '../components/AudioRecorder';
import { processAndDownloadAudio } from '../utils/audioUtils';
import { Scenario, PronunciationFeedback, Language } from '../types';
import { Mic, Volume2, ChevronRight, Award, AlertCircle, CheckCircle, User, Bot, ArrowLeft, Download, ToggleLeft, ToggleRight, PanelRightClose, PanelRightOpen, X } from 'lucide-react';
import { translations, getScenarios } from '../utils/localization';
interface SpeakingPracticeViewProps {
language: Language;
}
const SpeakingPracticeView: React.FC<SpeakingPracticeViewProps> = ({ language }) => {
const t = translations[language].speaking;
const tRecorder = translations[language].recorder;
const [activeScenario, setActiveScenario] = useState<Scenario | null>(null);
const [history, setHistory] = useState<{role: string, text: string, translation?: string}[]>([]);
const [feedback, setFeedback] = useState<PronunciationFeedback | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [isPlayingTTS, setIsPlayingTTS] = useState(false);
const [lastAudioUrl, setLastAudioUrl] = useState<string | null>(null);
const [showTranslation, setShowTranslation] = useState(false);
const [isFeedbackOpen, setIsFeedbackOpen] = useState(false); // New state for mobile feedback drawer
const audioContextRef = useRef<AudioContext | null>(null);
// Reset flow if language changes, to avoid mismatched text
useEffect(() => {
reset();
}, [language]);
const startScenario = (scenario: Scenario) => {
setActiveScenario(scenario);
setHistory([{
role: 'model',
text: scenario.initialMessage,
translation: scenario.initialTranslation
}]);
setFeedback(null);
playTTS(scenario.initialMessage);
};
const playTTS = async (text: string) => {
try {
setIsPlayingTTS(true);
const audioBase64 = await geminiService.generateSpeech(text);
if (audioBase64) {
setLastAudioUrl(audioBase64);
if (!audioContextRef.current) {
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
}
const buffer = await decodeAudioData(audioBase64, audioContextRef.current);
const source = audioContextRef.current.createBufferSource();
source.buffer = buffer;
source.connect(audioContextRef.current.destination);
source.onended = () => setIsPlayingTTS(false);
source.start();
}
} catch (e) {
console.error(e);
setIsPlayingTTS(false);
}
};
const downloadTTS = async (text: string) => {
try {
if (lastAudioUrl && history[history.length - 1]?.text === text) {
// If the last generated audio matches the current requested text, use cached
processAndDownloadAudio(lastAudioUrl, `sakura_roleplay_${Date.now()}.wav`);
return;
}
// Otherwise generate
const audioBase64 = await geminiService.generateSpeech(text);
if (audioBase64) {
processAndDownloadAudio(audioBase64, `sakura_roleplay_${Date.now()}.wav`);
}
} catch (e) {
console.error("Download failed", e);
}
};
const handleAudioInput = async (base64Audio: string) => {
if (!activeScenario) return;
setIsProcessing(true);
const historyText = history.slice(-4).map(h => `${h.role}: ${h.text}`).join('\n');
try {
const result = await geminiService.analyzeSpeakingPerformance(
base64Audio,
`Roleplay as ${activeScenario.role} in context: ${activeScenario.description}`,
historyText,
language
);
if (result) {
setFeedback(result);
setHistory(prev => [
...prev,
{ role: 'user', text: result.transcription },
{ role: 'model', text: result.response, translation: result.translation }
]);
setIsFeedbackOpen(true); // Open feedback automatically on new input
await playTTS(result.response);
}
} catch (e) {
console.error("Analysis failed", e);
} finally {
setIsProcessing(false);
}
};
const reset = () => {
setActiveScenario(null);
setHistory([]);
setFeedback(null);
setLastAudioUrl(null);
setShowTranslation(false);
setIsFeedbackOpen(false);
};
// Initial View: Scenario Selection
if (!activeScenario) {
const scenarios = getScenarios(language);
return (
<div className="h-full p-6 md:p-10 overflow-y-auto bg-slate-50/50">
<div className="max-w-6xl mx-auto">
<div className="mb-10 animate-fade-in-up">
<h2 className="text-4xl font-extrabold text-slate-800 mb-3 tracking-tight">{t.title}</h2>
<p className="text-lg text-slate-500 max-w-2xl">{t.subtitle}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 gap-6">
{scenarios.map((scenario, index) => (
<button
key={scenario.id}
onClick={() => startScenario(scenario)}
style={{ animationDelay: `${index * 100}ms` }}
className="animate-fade-in-up relative overflow-hidden bg-white p-6 rounded-3xl shadow-sm hover:shadow-xl border border-slate-100 hover:border-indigo-200 transition-all duration-300 group text-left hover:-translate-y-1"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-slate-50 to-slate-100 rounded-bl-full -mr-8 -mt-8 z-0 group-hover:scale-110 transition-transform duration-500" />
<div className="relative z-10 flex items-start">
<div className="w-20 h-20 bg-white rounded-2xl shadow-lg flex items-center justify-center text-5xl mr-6 border border-slate-50 group-hover:rotate-12 transition-transform duration-300">
{scenario.icon}
</div>
<div className="flex-1 pt-1">
<h3 className="text-xl font-bold text-slate-800 mb-2 group-hover:text-indigo-600 transition-colors">{scenario.title}</h3>
<p className="text-slate-500 text-sm mb-4 leading-relaxed">{scenario.description}</p>
<div className="inline-flex items-center px-4 py-2 bg-indigo-50 text-indigo-600 rounded-full text-sm font-bold group-hover:bg-indigo-600 group-hover:text-white transition-colors duration-300">
{t.start} <ChevronRight size={16} className="ml-1 group-hover:translate-x-1 transition-transform" />
</div>
</div>
</div>
</button>
))}
</div>
</div>
</div>
);
}
// Feedback Content Component
const FeedbackContent = () => (
<div className="h-full flex flex-col">
<div className="p-6 bg-slate-50 border-b border-slate-100 flex justify-between items-center">
<h3 className="text-lg font-extrabold text-slate-800 flex items-center gap-2">
<Award className="text-amber-500" fill="currentColor" />
{t.feedbackTitle}
</h3>
<button onClick={() => setIsFeedbackOpen(false)} className="md:hidden text-slate-400">
<X size={20} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
{feedback ? (
<div className="space-y-6 animate-slide-in-right">
{/* Score Card */}
<div className="bg-white rounded-3xl p-6 flex flex-col items-center shadow-sm border border-slate-100 relative overflow-hidden group">
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-green-400 to-emerald-500 group-hover:h-full group-hover:opacity-5 transition-all duration-500"></div>
<div className="relative w-28 h-28 flex items-center justify-center mb-2">
<svg className="transform -rotate-90 w-28 h-28">
<circle cx="56" cy="56" r="48" stroke="currentColor" strokeWidth="8" fill="transparent" className="text-slate-100" />
<circle cx="56" cy="56" r="48" stroke="currentColor" strokeWidth="8" fill="transparent"
strokeDasharray={301.6}
strokeDashoffset={301.6 - (301.6 * feedback.score) / 100}
strokeLinecap="round"
className={`${feedback.score > 80 ? 'text-green-500' : feedback.score > 60 ? 'text-amber-500' : 'text-red-500'} transition-all duration-1000 ease-out`}
/>
</svg>
<div className="absolute flex flex-col items-center animate-scale-in">
<span className="text-3xl font-black text-slate-800">{feedback.score}</span>
</div>
</div>
<span className="text-xs font-bold text-slate-400 uppercase tracking-widest">{t.score}</span>
</div>
{/* Issues List */}
<div className="bg-white rounded-2xl p-5 border border-slate-100 shadow-sm animate-fade-in-up delay-100">
<h4 className="text-xs font-bold text-slate-400 mb-4 uppercase tracking-wider flex items-center gap-2">
<AlertCircle size={14} className="text-red-500" /> {t.toImprove}
</h4>
{feedback.pronunciationIssues.length > 0 ? (
<ul className="space-y-2">
{feedback.pronunciationIssues.map((issue, i) => (
<li key={i} className="text-sm text-slate-700 bg-red-50/50 p-3 rounded-lg border border-red-100 flex items-start gap-2">
<span className="text-red-400 mt-0.5"></span> {issue}
</li>
))}
</ul>
) : (
<p className="text-sm font-medium text-green-600 bg-green-50 p-4 rounded-xl flex items-center gap-2 animate-pulse">
<CheckCircle size={16} /> {t.perfect}
</p>
)}
</div>
{/* Advice */}
<div className="bg-gradient-to-br from-indigo-500 to-purple-600 rounded-2xl p-5 text-white shadow-lg shadow-indigo-200 animate-fade-in-up delay-200 hover:shadow-xl transition-shadow">
<h4 className="text-xs font-bold text-indigo-200 mb-3 uppercase tracking-wider flex items-center gap-2">
<CheckCircle size={14} /> {t.advice}
</h4>
<p className="text-sm font-medium leading-relaxed opacity-95">
{feedback.advice}
</p>
</div>
{/* Metadata */}
<div className="space-y-2 animate-fade-in-up delay-300">
<div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
<h4 className="text-[10px] font-bold text-slate-400 mb-1 uppercase">{t.transcription}</h4>
<p className="text-sm text-slate-600 italic font-serif">"{feedback.transcription}"</p>
</div>
<div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
<h4 className="text-[10px] font-bold text-slate-400 mb-1 uppercase">{t.meaning}</h4>
<p className="text-sm text-slate-600">"{feedback.translation}"</p>
</div>
</div>
</div>
) : (
<div className="h-full flex flex-col items-center justify-center text-slate-400 p-8 animate-fade-in">
<div className="w-20 h-20 bg-slate-50 rounded-full flex items-center justify-center mb-6 shadow-inner animate-pulse">
<Mic size={32} className="text-slate-300" />
</div>
<p className="text-sm text-center font-medium leading-relaxed max-w-[200px]">{t.emptyFeedback}</p>
</div>
)}
</div>
</div>
);
// Active Conversation View
return (
<div className="h-full flex flex-row bg-slate-50 animate-fade-in relative overflow-hidden">
{/* Left: Conversation Area */}
<div className="flex-1 flex flex-col h-full min-w-0 border-r border-slate-200 bg-white/50 relative">
{/* Header */}
<div className="p-4 bg-white/80 backdrop-blur border-b border-slate-100 flex items-center justify-between shadow-sm z-10 sticky top-0">
<div className="flex items-center gap-2 overflow-hidden">
<button
onClick={reset}
className="p-2 rounded-full hover:bg-slate-100 text-slate-500 transition-colors hover:scale-110 flex-shrink-0"
>
<ArrowLeft size={20} />
</button>
<div className="flex items-center gap-3 animate-slide-in-right min-w-0">
<span className="text-2xl bg-slate-100 w-10 h-10 flex items-center justify-center rounded-full shadow-inner flex-shrink-0">{activeScenario.icon}</span>
<div className="truncate">
<h3 className="font-bold text-slate-800 truncate">{activeScenario.title}</h3>
<p className="text-xs text-slate-500 font-medium uppercase tracking-wider truncate">{t.roleplay}: {activeScenario.role}</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={() => setShowTranslation(!showTranslation)}
className="flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-bold bg-white border border-slate-200 text-slate-600 hover:bg-slate-50 transition-all"
>
{showTranslation ? <ToggleRight size={20} className="text-indigo-500" /> : <ToggleLeft size={20} />}
<span className="hidden sm:inline">{t.translation}</span>
</button>
{/* Feedback Toggle (Mobile) */}
<button
onClick={() => setIsFeedbackOpen(!isFeedbackOpen)}
className={`md:hidden p-2 rounded-lg border transition-colors flex items-center gap-2 text-sm font-medium ${
isFeedbackOpen
? 'bg-indigo-50 text-indigo-600 border-indigo-200'
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50'
}`}
>
{isFeedbackOpen ? <PanelRightClose size={18} /> : <PanelRightOpen size={18} />}
</button>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 md:p-8 space-y-8 pb-24">
{history.map((msg, idx) => {
const isUser = msg.role === 'user';
return (
<div key={idx} className={`flex ${isUser ? 'justify-end' : 'justify-start'} animate-fade-in-up`}>
<div className={`flex max-w-[85%] gap-4 ${isUser ? 'flex-row-reverse' : 'flex-row'}`}>
<div className={`w-10 h-10 rounded-2xl flex-shrink-0 flex items-center justify-center shadow-sm transition-transform hover:scale-110 ${isUser ? 'bg-indigo-600' : 'bg-pink-500'}`}>
{isUser ? <User size={18} className="text-white" /> : <Bot size={18} className="text-white" />}
</div>
<div className={`p-5 rounded-3xl shadow-sm transition-all hover:shadow-md ${isUser ? 'bg-indigo-600 text-white rounded-tr-sm' : 'bg-white text-slate-800 border border-slate-100 rounded-tl-sm'}`}>
<p className="text-lg leading-relaxed font-medium">{msg.text}</p>
{showTranslation && msg.translation && (
<p className={`text-sm mt-2 pt-2 border-t ${isUser ? 'border-white/20 text-indigo-100' : 'border-slate-100 text-slate-500'} italic`}>
{msg.translation}
</p>
)}
{!isUser && (
<div className="flex gap-2 mt-3">
<button
onClick={() => playTTS(msg.text)}
className="px-3 py-1 bg-pink-50 hover:bg-pink-100 text-pink-600 rounded-full flex items-center gap-1 text-xs font-bold transition-colors"
>
<Volume2 size={12} /> {t.replay}
</button>
<button
onClick={() => downloadTTS(msg.text)}
className="px-3 py-1 bg-slate-100 hover:bg-slate-200 text-slate-500 rounded-full flex items-center gap-1 text-xs font-bold transition-colors"
title="Download"
>
<Download size={12} />
</button>
</div>
)}
</div>
</div>
</div>
);
})}
{isProcessing && (
<div className="flex justify-center py-8">
<div className="flex items-center gap-3 px-6 py-3 bg-indigo-50 rounded-full text-indigo-600 font-bold text-sm animate-pulse shadow-inner">
<Mic size={18} className="animate-bounce" /> {t.listening}
</div>
</div>
)}
</div>
{/* Interaction Area */}
<div className="p-6 bg-white border-t border-slate-100 flex flex-col justify-center items-center relative shadow-[0_-4px_20px_rgba(0,0,0,0.02)] z-20 pb-[env(safe-area-inset-bottom)]">
<div className="absolute top-0 left-0 w-full h-1 bg-slate-100 overflow-hidden">
{isProcessing && <div className="h-full bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 animate-indeterminate" />}
</div>
<div className="flex flex-col items-center gap-4 w-full max-w-md">
<div className="transform hover:scale-110 transition-transform duration-300">
<AudioRecorder
onAudioCaptured={handleAudioInput}
disabled={isProcessing || isPlayingTTS}
titleStart={tRecorder.start}
titleStop={tRecorder.stop}
/>
</div>
<p className="text-xs text-slate-400 font-bold uppercase tracking-widest animate-pulse">
{isProcessing ? t.processing : t.tapSpeak}
</p>
</div>
</div>
</div>
{/* Right: Feedback Panel (Desktop) */}
<div className={`
hidden md:block w-80 lg:w-96 bg-white border-l border-slate-200 overflow-y-auto shadow-2xl z-30 transition-transform duration-500
${isFeedbackOpen ? 'translate-x-0' : 'translate-x-0'}
`}>
<FeedbackContent />
</div>
{/* Right: Feedback Drawer (Mobile) */}
<div className={`
fixed inset-y-0 right-0 z-50 w-full sm:w-96 bg-white shadow-2xl transform transition-transform duration-300 md:hidden
${isFeedbackOpen ? 'translate-x-0' : 'translate-x-full'}
`}>
<FeedbackContent />
</div>
</div>
);
};
export default SpeakingPracticeView;

451
views/TranslationView.tsx Normal file
View File

@@ -0,0 +1,451 @@
import React, { useState, useRef, useEffect } from 'react';
import { Language, TranslationRecord } from '../types';
import { geminiService, decodeAudioData } from '../services/geminiService';
import { processAndDownloadAudio } from '../utils/audioUtils';
import { translations } from '../utils/localization';
import { ArrowRightLeft, Copy, Languages, Sparkles, Loader2, Trash2, Camera, Image as ImageIcon, History, X, PanelRightClose, PanelRightOpen, Volume2, Square, Download } from 'lucide-react';
interface TranslationViewProps {
language: Language;
history: TranslationRecord[];
addToHistory: (record: TranslationRecord) => void;
clearHistory: () => void;
onDeleteHistoryItem: (id: string) => void;
}
const TranslationView: React.FC<TranslationViewProps> = ({ language, history, addToHistory, clearHistory, onDeleteHistoryItem }) => {
const t = translations[language].translation;
const [inputText, setInputText] = useState('');
const [outputText, setOutputText] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [loadingStatus, setLoadingStatus] = useState('');
const [sourceLang, setSourceLang] = useState('Auto');
const [targetLang, setTargetLang] = useState('Japanese');
// Audio State
const [playingId, setPlayingId] = useState<'input' | 'output' | null>(null);
const [downloadingId, setDownloadingId] = useState<'input' | 'output' | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const audioSourceRef = useRef<AudioBufferSourceNode | null>(null);
// Sidebar State - Default Closed
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const cameraInputRef = useRef<HTMLInputElement>(null);
const LANG_OPTIONS = [
{ value: 'Auto', label: t.langs.auto },
{ value: 'English', label: t.langs.en },
{ value: 'Japanese', label: t.langs.ja },
{ value: 'Chinese', label: t.langs.zh },
{ value: 'Korean', label: t.langs.ko },
{ value: 'French', label: t.langs.fr },
{ value: 'Spanish', label: t.langs.es },
];
const TARGET_OPTIONS = LANG_OPTIONS.filter(o => o.value !== 'Auto');
// Cleanup audio
useEffect(() => {
return () => stopAudio();
}, []);
const stopAudio = () => {
if (audioSourceRef.current) {
audioSourceRef.current.stop();
audioSourceRef.current = null;
}
setPlayingId(null);
};
const playAudio = async (text: string, type: 'input' | 'output') => {
if (!text.trim()) return;
if (playingId === type) {
stopAudio();
return;
}
if (playingId) stopAudio();
setPlayingId(type);
try {
const audioBase64 = await geminiService.generateSpeech(text);
if (audioBase64) {
if (!audioContextRef.current) {
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
}
const ctx = audioContextRef.current;
if (ctx.state === 'suspended') await ctx.resume();
const buffer = await decodeAudioData(audioBase64, ctx);
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(ctx.destination);
source.onended = () => setPlayingId(null);
source.start();
audioSourceRef.current = source;
} else {
setPlayingId(null);
}
} catch (e) {
console.error(e);
setPlayingId(null);
}
};
const handleDownload = async (text: string, type: 'input' | 'output') => {
if (!text.trim()) return;
setDownloadingId(type);
try {
const audioBase64 = await geminiService.generateSpeech(text);
if (audioBase64) {
processAndDownloadAudio(audioBase64, `translation_${type}_${Date.now()}.wav`);
}
} catch (e) {
console.error(e);
} finally {
setDownloadingId(null);
}
};
const handleTranslate = async () => {
if (!inputText.trim()) return;
setIsLoading(true);
setLoadingStatus(t.translating);
try {
const result = await geminiService.translateText(inputText, targetLang, sourceLang);
setOutputText(result);
addToHistory({
id: Date.now().toString(),
sourceText: inputText,
targetText: result,
sourceLang: sourceLang === 'Auto' ? 'Detected' : sourceLang,
targetLang: targetLang,
timestamp: Date.now()
});
} catch (e) {
console.error(e);
setOutputText(t.errorTranslating);
} finally {
setIsLoading(false);
}
};
const handleImageSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsLoading(true);
setLoadingStatus(t.extracting);
const reader = new FileReader();
reader.onloadend = async () => {
const base64 = reader.result as string;
try {
const result = await geminiService.translateImage(base64, targetLang, sourceLang);
if (result) {
setInputText(result.original);
setOutputText(result.translated);
addToHistory({
id: Date.now().toString(),
sourceText: result.original,
targetText: result.translated,
sourceLang: sourceLang === 'Auto' ? 'Detected (Image)' : sourceLang,
targetLang: targetLang,
timestamp: Date.now()
});
} else {
alert(t.imageReadError);
}
} catch (err) {
console.error(err);
alert(t.imageTransError);
} finally {
setIsLoading(false);
}
};
reader.readAsDataURL(file);
};
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
};
const handleSwap = () => {
if (sourceLang === 'Auto') return;
setSourceLang(targetLang);
setTargetLang(sourceLang);
setInputText(outputText);
setOutputText(inputText);
};
const HistoryContent = () => (
<div className="flex flex-col h-full bg-white">
<div className="p-4 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
<h3 className="font-bold text-slate-700 flex items-center gap-2">
<Languages size={16} /> {t.history}
</h3>
<div className="flex items-center gap-3">
{history.length > 0 && (
<button onClick={clearHistory} className="text-xs text-red-400 hover:text-red-600 hover:underline">{t.clear}</button>
)}
<button onClick={() => setIsHistoryOpen(false)} className="md:hidden text-slate-400 hover:text-slate-600">
<X size={20} />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{history.length === 0 && (
<div className="text-center text-slate-400 text-sm mt-10">{t.history}</div>
)}
{history.slice().reverse().map((rec) => (
<div
key={rec.id}
className="group flex items-start gap-3 p-3 rounded-xl bg-slate-50 border border-slate-100 hover:bg-white hover:shadow-md transition-all cursor-pointer relative"
onClick={() => { setInputText(rec.sourceText); setOutputText(rec.targetText); if(window.innerWidth < 768) setIsHistoryOpen(false); }}
>
{/* Icon */}
<div className="w-10 h-10 rounded-xl bg-indigo-100 flex-shrink-0 flex items-center justify-center text-indigo-600 shadow-inner">
<Languages size={20} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex justify-between items-start">
<h4 className="font-bold text-sm text-slate-700 truncate pr-6">
{rec.sourceLang} &rarr; {rec.targetLang}
</h4>
</div>
<div className="flex justify-between items-center mt-1">
<span className="text-[10px] text-slate-400">
{new Date(rec.timestamp).toLocaleDateString()} {new Date(rec.timestamp).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</span>
</div>
<p className="text-xs text-slate-500 line-clamp-1 mt-1">{rec.sourceText}</p>
</div>
{/* Delete Button */}
<button
onClick={(e) => { e.stopPropagation(); onDeleteHistoryItem(rec.id); }}
className="absolute bottom-2 right-2 p-1.5 text-slate-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
</div>
);
return (
<div className="h-full flex bg-slate-50 relative overflow-hidden">
{/* Main Translation Area */}
<div className="flex-1 flex flex-col h-full min-w-0 relative">
{/* Sticky Header / Toolbar */}
<div className="flex items-center justify-end px-4 py-3 bg-slate-50/90 backdrop-blur z-20 sticky top-0">
<button
onClick={() => setIsHistoryOpen(!isHistoryOpen)}
className={`p-2 rounded-lg border transition-colors flex items-center gap-2 text-sm font-medium ${
isHistoryOpen
? 'bg-indigo-50 text-indigo-600 border-indigo-200'
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50'
}`}
title={t.history}
>
<History size={18} />
<span className="hidden sm:inline">{t.history}</span>
{isHistoryOpen ? <PanelRightClose size={16} className="opacity-50" /> : <PanelRightOpen size={16} className="opacity-50" />}
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 pb-6 md:px-10 md:pb-10">
<div className="max-w-5xl mx-auto w-full space-y-6">
{/* Title Header */}
<div className="flex items-center gap-3 mb-2 animate-fade-in-up">
<div className="w-10 h-10 rounded-xl bg-indigo-100 text-indigo-600 flex items-center justify-center shadow-sm">
<Languages size={20} />
</div>
<h2 className="text-2xl font-extrabold text-slate-800">{t.title}</h2>
</div>
{/* Controls */}
<div className="bg-white p-2 rounded-2xl shadow-sm border border-slate-200 flex flex-col md:flex-row items-center justify-between gap-2 animate-scale-in">
<select
value={sourceLang}
onChange={(e) => setSourceLang(e.target.value)}
className="p-3 rounded-xl bg-slate-50 border-transparent focus:bg-white focus:ring-2 focus:ring-indigo-500 font-bold text-slate-600 w-full md:w-auto outline-none"
>
{LANG_OPTIONS.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
</select>
<button onClick={handleSwap} className="p-2 hover:bg-slate-100 rounded-full text-slate-400 hover:text-indigo-500 transition-colors transform hover:rotate-180 duration-300">
<ArrowRightLeft size={20} />
</button>
<select
value={targetLang}
onChange={(e) => setTargetLang(e.target.value)}
className="p-3 rounded-xl bg-slate-50 border-transparent focus:bg-white focus:ring-2 focus:ring-indigo-500 font-bold text-indigo-600 w-full md:w-auto outline-none"
>
{TARGET_OPTIONS.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
</select>
</div>
{/* Input/Output Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 min-h-[300px]">
{/* Source */}
<div className="bg-white rounded-3xl shadow-sm border border-slate-200 flex flex-col p-6 relative group focus-within:ring-2 focus-within:ring-indigo-500/50 transition-all animate-fade-in-up delay-100">
<textarea
value={inputText}
onChange={(e) => setInputText(e.target.value)}
className="flex-1 w-full resize-none outline-none text-lg text-slate-700 placeholder:text-slate-300 bg-transparent"
placeholder={t.inputLabel}
/>
<div className="flex items-center justify-between pt-2 border-t border-slate-100 mt-2">
<div className="flex items-center gap-2">
{/* Camera Button */}
<button
onClick={() => cameraInputRef.current?.click()}
className="p-2 hover:bg-slate-100 rounded-lg text-slate-500 flex items-center gap-2 text-xs font-bold transition-colors"
title={t.scanImage}
>
<Camera size={18} />
<span className="hidden sm:inline">{t.scanImage}</span>
</button>
<input
type="file"
ref={cameraInputRef}
className="hidden"
accept="image/*"
capture="environment"
onChange={handleImageSelect}
/>
{/* Upload Button */}
<button
onClick={() => fileInputRef.current?.click()}
className="p-2 hover:bg-slate-100 rounded-lg text-slate-500 flex items-center gap-2 text-xs font-bold transition-colors"
title={t.uploadImage}
>
<ImageIcon size={18} />
<span className="hidden sm:inline">{t.uploadImage}</span>
</button>
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleImageSelect}
/>
{/* Play/Download Buttons */}
{inputText && (
<>
<button
onClick={() => playAudio(inputText, 'input')}
className={`p-2 rounded-lg flex items-center gap-2 text-xs font-bold transition-colors ${playingId === 'input' ? 'bg-pink-100 text-pink-500' : 'hover:bg-slate-100 text-slate-500'}`}
title="Play Audio"
>
{playingId === 'input' ? <Square size={18} fill="currentColor" /> : <Volume2 size={18} />}
</button>
<button
onClick={() => handleDownload(inputText, 'input')}
className={`p-2 rounded-lg flex items-center gap-2 text-xs font-bold transition-colors ${downloadingId === 'input' ? 'bg-slate-100 text-slate-500' : 'hover:bg-slate-100 text-slate-500'}`}
title="Download Audio"
disabled={!!downloadingId}
>
{downloadingId === 'input' ? <Loader2 size={18} className="animate-spin" /> : <Download size={18} />}
</button>
</>
)}
</div>
{inputText && (
<button onClick={() => setInputText('')} className="p-2 hover:bg-red-50 rounded-full text-slate-300 hover:text-red-500 transition-colors" title={t.clear}>
<Trash2 size={18} />
</button>
)}
</div>
</div>
{/* Target */}
<div className="bg-slate-100/50 rounded-3xl shadow-inner border border-slate-200 flex flex-col p-6 relative animate-fade-in-up delay-200">
{isLoading ? (
<div className="flex-1 flex items-center justify-center text-slate-400 gap-2 animate-pulse">
<Loader2 className="animate-spin" /> {loadingStatus}
</div>
) : (
<div className="flex-1 text-lg text-indigo-900 font-medium whitespace-pre-wrap leading-relaxed">
{outputText}
</div>
)}
{outputText && !isLoading && (
<div className="flex items-center justify-end gap-2 mt-4 pt-2 border-t border-slate-200/50">
<button
onClick={() => playAudio(outputText, 'output')}
className={`p-2 rounded-lg flex items-center gap-2 text-xs font-bold transition-colors ${playingId === 'output' ? 'bg-pink-100 text-pink-500' : 'bg-white hover:bg-indigo-50 text-indigo-500 shadow-sm'}`}
title="Play Audio"
>
{playingId === 'output' ? <Square size={18} fill="currentColor" /> : <Volume2 size={18} />}
</button>
<button
onClick={() => handleDownload(outputText, 'output')}
className={`p-2 rounded-lg flex items-center gap-2 text-xs font-bold transition-colors ${downloadingId === 'output' ? 'bg-indigo-50 text-indigo-500' : 'bg-white hover:bg-indigo-50 text-indigo-500 shadow-sm'}`}
title="Download Audio"
disabled={!!downloadingId}
>
{downloadingId === 'output' ? <Loader2 size={18} className="animate-spin" /> : <Download size={18} />}
</button>
<button onClick={() => handleCopy(outputText)} className="p-2 bg-white hover:bg-indigo-50 shadow-sm rounded-lg text-indigo-500 transition-colors" title={t.copy}>
<Copy size={18} />
</button>
</div>
)}
</div>
</div>
<button
onClick={handleTranslate}
disabled={isLoading || !inputText.trim()}
className="w-full py-4 bg-indigo-600 hover:bg-indigo-700 text-white rounded-2xl font-bold shadow-lg shadow-indigo-200 transform active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 animate-fade-in-up delay-300"
>
<Sparkles size={20} /> {t.translateBtn}
</button>
</div>
</div>
</div>
{/* Sidebar History (Desktop) */}
<div className={`
hidden md:block h-full bg-white border-l border-slate-200 transition-all duration-300 ease-in-out overflow-hidden z-30 flex-col
${isHistoryOpen ? 'w-80 opacity-100' : 'w-0 opacity-0 border-none'}
`}>
<HistoryContent />
</div>
{/* Mobile Drawer (Slide Over) */}
{isHistoryOpen && (
<div className="fixed inset-0 z-50 md:hidden flex justify-end">
<div className="absolute inset-0 bg-slate-900/30 backdrop-blur-sm transition-opacity" onClick={() => setIsHistoryOpen(false)} />
<div className="relative w-[85%] max-w-sm bg-white h-full shadow-2xl animate-slide-in-right z-50 flex flex-col">
<HistoryContent />
</div>
</div>
)}
</div>
);
};
export default TranslationView;

21
vite.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
// Load env file based on `mode` in the current working directory.
// Cast process to any to fix property 'cwd' does not exist on type 'Process' error
const env = loadEnv(mode, (process as any).cwd(), '');
return {
plugins: [react()],
build: {
outDir: 'dist',
sourcemap: false
},
// This defines global constants that are replaced at build time
define: {
'process.env.API_KEY': JSON.stringify(env.VITE_API_KEY || process.env.VITE_API_KEY || '')
}
}
})