初始化项目
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
494
App.tsx
Normal 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
34
Dockerfile
Normal 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
20
README.md
Normal 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`
|
||||
235
components/AudioRecorder.tsx
Normal file
235
components/AudioRecorder.tsx
Normal 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
267
components/ChatBubble.tsx
Normal 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;
|
||||
52
components/ConfirmModal.tsx
Normal file
52
components/ConfirmModal.tsx
Normal 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
127
components/Onboarding.tsx
Normal 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
64
components/Toast.tsx
Normal 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
84
index.html
Normal 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
15
index.tsx
Normal 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
22
manifest.json
Normal 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
8
metadata.json
Normal 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
19
nginx.conf
Normal 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
31
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
releases/HTY1024-APP-SKR-0.1.0_20251120.zip
Normal file
BIN
releases/HTY1024-APP-SKR-0.1.0_20251120.zip
Normal file
Binary file not shown.
BIN
releases/HTY1024-APP-SKR-0.2.0_20251120.zip
Normal file
BIN
releases/HTY1024-APP-SKR-0.2.0_20251120.zip
Normal file
Binary file not shown.
BIN
releases/HTY1024-APP-SKR-0.3.0_20251120.zip
Normal file
BIN
releases/HTY1024-APP-SKR-0.3.0_20251120.zip
Normal file
Binary file not shown.
BIN
releases/HTY1024-APP-SKR-main_20251121_0023.zip
Normal file
BIN
releases/HTY1024-APP-SKR-main_20251121_0023.zip
Normal file
Binary file not shown.
42
service-worker.js
Normal file
42
service-worker.js
Normal 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
543
services/geminiService.ts
Normal 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
14
tailwind.config.js
Normal 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
20
tsconfig.json
Normal 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
172
types.ts
Normal 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
80
utils/audioUtils.ts
Normal 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
926
utils/localization.ts
Normal 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
546
views/ChatView.tsx
Normal 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
201
views/CreativeStudio.tsx
Normal 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
705
views/ListeningView.tsx
Normal 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
513
views/OCRView.tsx
Normal 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
647
views/ReadingView.tsx
Normal 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;
|
||||
397
views/SpeakingPracticeView.tsx
Normal file
397
views/SpeakingPracticeView.tsx
Normal 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
451
views/TranslationView.tsx
Normal 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} → {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
21
vite.config.ts
Normal 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 || '')
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user