更新至 v0.8.0_20251223 版本
This commit is contained in:
46
App.tsx
46
App.tsx
@@ -1,4 +1,5 @@
|
||||
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import ChatView from './views/ChatView';
|
||||
import CreativeStudio from './views/CreativeStudio';
|
||||
@@ -10,7 +11,7 @@ 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 { MessageCircle, Palette, Mic2, Settings, Globe, Sparkles, BookOpen, Languages, Download, Upload, FileText, X, ScanText, Key, Save, Trash2, Menu, BrainCircuit, Link, Headphones, AlertTriangle } 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';
|
||||
@@ -312,6 +313,35 @@ const App: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearData = () => {
|
||||
setConfirmState({
|
||||
isOpen: true,
|
||||
title: t.settings.clearDataTitle,
|
||||
message: t.settings.clearDataConfirm,
|
||||
onConfirm: () => {
|
||||
// 1. Reset State
|
||||
setChatSessions([]);
|
||||
setTranslationHistory([]);
|
||||
setReadingHistory([]);
|
||||
setListeningHistory([]);
|
||||
setOcrHistory([]);
|
||||
|
||||
// 2. Clear Storage Explicitly
|
||||
localStorage.removeItem(STORAGE_KEYS.CHAT_SESSIONS);
|
||||
localStorage.removeItem(STORAGE_KEYS.TRANSLATION_HISTORY);
|
||||
localStorage.removeItem(STORAGE_KEYS.READING_HISTORY);
|
||||
localStorage.removeItem(STORAGE_KEYS.LISTENING_HISTORY);
|
||||
localStorage.removeItem(STORAGE_KEYS.OCR_HISTORY);
|
||||
|
||||
// 3. Re-init session
|
||||
createNewSession();
|
||||
|
||||
addToast('success', t.common.dataCleared);
|
||||
setConfirmState(prev => ({ ...prev, isOpen: false }));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const addToast = (type: 'success' | 'error' | 'info', message: string) => {
|
||||
const id = Date.now().toString();
|
||||
setToasts(prev => [...prev, { id, type, message }]);
|
||||
@@ -536,6 +566,18 @@ const App: React.FC = () => {
|
||||
<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>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="bg-red-50 p-5 rounded-2xl border border-red-100 mt-8">
|
||||
<h4 className="text-sm font-bold text-red-700 mb-2 flex items-center gap-2"><AlertTriangle size={16} /> {t.settings.clearDataTitle}</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-red-600 max-w-[70%] leading-relaxed">{t.settings.clearDataDesc}</div>
|
||||
<button onClick={handleClearData} className="px-4 py-2 bg-white border border-red-200 text-red-500 rounded-lg text-xs font-bold hover:bg-red-100 transition-colors shadow-sm active:scale-95">{t.settings.clearDataBtn}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -544,4 +586,4 @@ const App: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
10
README.md
10
README.md
@@ -1,3 +1,5 @@
|
||||
|
||||
|
||||
# Sakura Sensei 🌸 - AI Japanese Tutor
|
||||
|
||||

|
||||
@@ -18,7 +20,7 @@
|
||||
### ✨ Features
|
||||
|
||||
* **Tutor Dojo (Chat):**
|
||||
* Free chat with Sakura (AI Tutor) using `gemini-3-pro-preview` (Reasoning) or `gemini-2.5-flash`.
|
||||
* Free chat with Sakura (AI Tutor) using `gemini-3-pro-preview` (Reasoning) or `gemini-3-flash-preview`.
|
||||
* **Voice Interaction:** Real-time Speech-to-Text (STT) and high-quality Text-to-Speech (TTS).
|
||||
* **Thinking Mode:** Visualize the AI's reasoning process for complex grammar explanations.
|
||||
* **Share:** Export chat history as Text, File, or Image (screenshot).
|
||||
@@ -74,9 +76,9 @@
|
||||
* **Styling:** Tailwind CSS, Lucide React (Icons)
|
||||
* **AI Integration:** `@google/genai` SDK
|
||||
* **Models Used:**
|
||||
* Text/Reasoning: `gemini-3-pro-preview`, `gemini-2.5-flash`
|
||||
* Text/Reasoning: `gemini-3-pro-preview`, `gemini-3-flash-preview`
|
||||
* Audio: `gemini-2.5-flash-preview-tts`
|
||||
* Vision/OCR: `gemini-2.5-flash`
|
||||
* Vision/OCR: `gemini-3-flash-preview`
|
||||
* Image Gen: `imagen-4.0-generate-001`, `gemini-2.5-flash-image`
|
||||
* Video: `veo-3.1-fast-generate-preview`
|
||||
|
||||
@@ -189,4 +191,4 @@
|
||||
|
||||
MIT License.
|
||||
|
||||
Powered by [Google Gemini API](https://ai.google.dev/).
|
||||
Powered by [Google Gemini API](https://ai.google.dev/).
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { Component, ErrorInfo, ReactNode } from "react";
|
||||
import React, { ErrorInfo, ReactNode } from "react";
|
||||
import { AlertCircle, RefreshCw, Trash2 } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
@@ -10,7 +10,7 @@ interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
export class ErrorBoundary extends React.Component<Props, State> {
|
||||
public state: State = {
|
||||
hasError: false,
|
||||
error: null
|
||||
|
||||
BIN
releases/HTY1024-APP-SKR-0.8.0_20251223.zip
Normal file
BIN
releases/HTY1024-APP-SKR-0.8.0_20251223.zip
Normal file
Binary file not shown.
@@ -132,7 +132,7 @@ class GeminiService {
|
||||
// Ensure model name is clean
|
||||
let modelName = useThinking
|
||||
? 'gemini-3-pro-preview'
|
||||
: (imageBase64 ? 'gemini-3-pro-preview' : (modelOverride || 'gemini-2.5-flash'));
|
||||
: (imageBase64 ? 'gemini-3-pro-preview' : (modelOverride || 'gemini-3-flash-preview'));
|
||||
|
||||
// Extra safety: strip quotes just in case
|
||||
modelName = modelName.replace(/['"]/g, '');
|
||||
@@ -278,7 +278,7 @@ class GeminiService {
|
||||
const ai = this.getAi();
|
||||
return this.retryOperation(async () => {
|
||||
const response = await ai.models.generateContent({
|
||||
model: 'gemini-2.5-flash',
|
||||
model: 'gemini-3-flash-preview',
|
||||
contents: {
|
||||
parts: [
|
||||
{ inlineData: { mimeType: 'audio/wav', data: audioBase64 } },
|
||||
@@ -368,7 +368,7 @@ class GeminiService {
|
||||
|
||||
return this.retryOperation(async () => {
|
||||
const response = await ai.models.generateContent({
|
||||
model: 'gemini-2.5-flash',
|
||||
model: 'gemini-3-flash-preview',
|
||||
contents: {
|
||||
parts: [{ inlineData: { mimeType: 'audio/wav', data: audioBase64 } }, { text: prompt }]
|
||||
},
|
||||
@@ -401,7 +401,7 @@ class GeminiService {
|
||||
|
||||
return this.retryOperation(async () => {
|
||||
const response = await ai.models.generateContent({
|
||||
model: 'gemini-2.5-flash',
|
||||
model: 'gemini-3-flash-preview',
|
||||
contents: { parts: [{ text: prompt }] },
|
||||
config: {
|
||||
responseMimeType: "application/json",
|
||||
@@ -439,7 +439,7 @@ class GeminiService {
|
||||
|
||||
return this.retryOperation(async () => {
|
||||
const response = await ai.models.generateContent({
|
||||
model: 'gemini-2.5-flash',
|
||||
model: 'gemini-3-flash-preview',
|
||||
contents: { parts: [{ text: prompt }] },
|
||||
config: {
|
||||
responseMimeType: "application/json",
|
||||
@@ -481,7 +481,7 @@ class GeminiService {
|
||||
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',
|
||||
model: 'gemini-3-flash-preview',
|
||||
contents: { parts: [{ text: prompt }] }
|
||||
});
|
||||
return res.text || "";
|
||||
@@ -492,7 +492,7 @@ class GeminiService {
|
||||
const ai = this.getAi();
|
||||
return this.retryOperation(async () => {
|
||||
const res = await ai.models.generateContent({
|
||||
model: 'gemini-2.5-flash',
|
||||
model: 'gemini-3-flash-preview',
|
||||
contents: { parts: [{ text: `Translate the following text from ${source} to ${target}.` }, { text: text }] },
|
||||
config: {
|
||||
responseMimeType: "application/json",
|
||||
@@ -512,7 +512,7 @@ class GeminiService {
|
||||
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',
|
||||
model: 'gemini-3-flash-preview',
|
||||
contents: {
|
||||
parts: [{ inlineData: { mimeType: 'image/jpeg', data: cleanBase64 } }, { text: `Extract text (Language: ${source}) and translate to ${target}. JSON output: original, translated.` }]
|
||||
},
|
||||
@@ -537,7 +537,7 @@ class GeminiService {
|
||||
|
||||
return this.retryOperation(async () => {
|
||||
const res = await ai.models.generateContent({
|
||||
model: 'gemini-2.5-flash',
|
||||
model: 'gemini-3-flash-preview',
|
||||
contents: {
|
||||
parts: [{ inlineData: { mimeType: 'image/jpeg', data: cleanBase64 } }, { text: prompt }]
|
||||
},
|
||||
|
||||
6
types.ts
6
types.ts
@@ -51,18 +51,18 @@ export type Language = 'en' | 'ja' | 'zh';
|
||||
|
||||
// Specific Gemini Models
|
||||
export enum ModelNames {
|
||||
TEXT_FAST = 'gemini-2.5-flash',
|
||||
TEXT_FAST = 'gemini-3-flash-preview',
|
||||
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',
|
||||
TRANSCRIPTION = 'gemini-3-flash-preview',
|
||||
}
|
||||
|
||||
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)' }
|
||||
{ id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash (Fast & Balanced)' }
|
||||
];
|
||||
|
||||
// Speaking Mode Types
|
||||
|
||||
@@ -218,7 +218,8 @@ export const translations = {
|
||||
today: "Today",
|
||||
yesterday: "Yesterday",
|
||||
storageFull: "Storage full. History may not be saved.",
|
||||
storageOptimized: "Storage full. Cleared audio cache to save text."
|
||||
storageOptimized: "Storage full. Cleared audio cache to save text.",
|
||||
dataCleared: "All data has been cleared."
|
||||
},
|
||||
onboarding: {
|
||||
welcome: "Welcome to Sakura Sensei!",
|
||||
@@ -420,7 +421,11 @@ export const translations = {
|
||||
keyRemoved: "Settings cleared.",
|
||||
modelTitle: "AI Model",
|
||||
modelDesc: "Select model for chat/reasoning.",
|
||||
modelSaved: "Model updated!"
|
||||
modelSaved: "Model updated!",
|
||||
clearDataTitle: "Danger Zone",
|
||||
clearDataBtn: "Clear All Data",
|
||||
clearDataDesc: "Deletes all chats and history. Configuration is preserved.",
|
||||
clearDataConfirm: "Are you sure? This will delete ALL chat history, lessons, and scans.\n\n⚠️ WARNING: This action is irreversible.\n👉 TIP: Please BACKUP your data before proceeding!"
|
||||
},
|
||||
recorder: {
|
||||
start: "Start Mic",
|
||||
@@ -472,7 +477,8 @@ export const translations = {
|
||||
today: "今日",
|
||||
yesterday: "昨日",
|
||||
storageFull: "保存容量がいっぱいです。履歴が保存されない可能性があります。",
|
||||
storageOptimized: "保存容量がいっぱいのため、音声キャッシュを削除してテキストのみ保存しました。"
|
||||
storageOptimized: "保存容量がいっぱいのため、音声キャッシュを削除してテキストのみ保存しました。",
|
||||
dataCleared: "すべてのデータが消去されました。"
|
||||
},
|
||||
onboarding: {
|
||||
welcome: "さくら先生へようこそ!",
|
||||
@@ -674,7 +680,11 @@ export const translations = {
|
||||
keyRemoved: "設定をクリアしました。",
|
||||
modelTitle: "AIモデル",
|
||||
modelDesc: "チャット/推論用のモデルを選択。",
|
||||
modelSaved: "モデルを更新しました!"
|
||||
modelSaved: "モデルを更新しました!",
|
||||
clearDataTitle: "危険エリア",
|
||||
clearDataBtn: "すべてのデータを消去",
|
||||
clearDataDesc: "チャットや履歴をすべて削除します。設定は保持されます。",
|
||||
clearDataConfirm: "本当に実行しますか?すべてのチャット履歴、レッスン、スキャン記録が削除されます。\n\n⚠️ 警告:この操作は取り消せません。\n👉 ヒント:実行前にバックアップを作成することを強く推奨します!"
|
||||
},
|
||||
recorder: {
|
||||
start: "マイク開始",
|
||||
@@ -726,7 +736,8 @@ export const translations = {
|
||||
today: "今天",
|
||||
yesterday: "昨天",
|
||||
storageFull: "存储空间已满,历史记录可能无法保存。",
|
||||
storageOptimized: "存储空间已满,已自动清除音频缓存以保存文本。"
|
||||
storageOptimized: "存储空间已满,已自动清除音频缓存以保存文本。",
|
||||
dataCleared: "所有数据已清除。"
|
||||
},
|
||||
onboarding: {
|
||||
welcome: "欢迎来到樱花老师!",
|
||||
@@ -928,7 +939,11 @@ export const translations = {
|
||||
keyRemoved: "设置已清除。",
|
||||
modelTitle: "AI模型",
|
||||
modelDesc: "选择聊天/推理模型。",
|
||||
modelSaved: "模型已更新!"
|
||||
modelSaved: "模型已更新!",
|
||||
clearDataTitle: "危险区域",
|
||||
clearDataBtn: "清除所有数据",
|
||||
clearDataDesc: "删除所有聊天和历史记录。保留设置。",
|
||||
clearDataConfirm: "您确定吗?这将删除所有聊天记录、课程和扫描记录。\n\n⚠️ 警告:此操作无法撤销。\n👉 提示:请在继续之前先备份您的数据!"
|
||||
},
|
||||
recorder: {
|
||||
start: "开始录音",
|
||||
|
||||
@@ -746,23 +746,23 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating Ask Button */}
|
||||
{selectedText && (
|
||||
<div className="absolute bottom-6 left-0 right-0 flex justify-center z-50 animate-fade-in-up px-4 pointer-events-none">
|
||||
<button
|
||||
onClick={() => handleAskTutor(`Explain: "${selectedText}"`)}
|
||||
className="pointer-events-auto flex items-center gap-2 px-6 py-3 bg-slate-900 text-white rounded-full shadow-2xl hover:scale-105 active:scale-95 transition-all font-bold text-sm border border-white/20"
|
||||
>
|
||||
<Sparkles size={16} className="text-yellow-300 animate-pulse" />
|
||||
Explain: <span className="max-w-[150px] truncate">"{selectedText}"</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Ask Button */}
|
||||
{selectedText && (
|
||||
<div className="absolute bottom-6 left-0 right-0 flex justify-center z-50 animate-fade-in-up px-4 pointer-events-none">
|
||||
<button
|
||||
onClick={() => handleAskTutor(`Explain: "${selectedText}"`)}
|
||||
className="pointer-events-auto flex items-center gap-2 px-6 py-3 bg-slate-900 text-white rounded-full shadow-2xl hover:scale-105 active:scale-95 transition-all font-bold text-sm border border-white/20"
|
||||
>
|
||||
<Sparkles size={16} className="text-yellow-300 animate-pulse" />
|
||||
Explain: <span className="max-w-[150px] truncate">"{selectedText}"</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Tutor Chat */}
|
||||
|
||||
@@ -359,7 +359,7 @@ const OCRView: React.FC<OCRViewProps> = ({ language, history, onSaveToHistory, o
|
||||
<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'}`}>
|
||||
<div className={`flex-1 flex-col h-full overflow-hidden 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">
|
||||
@@ -402,108 +402,109 @@ const OCRView: React.FC<OCRViewProps> = ({ language, history, onSaveToHistory, o
|
||||
</div>
|
||||
|
||||
{/* Content Scroll Area */}
|
||||
<div className="p-6 lg:p-10 space-y-8 max-w-4xl mx-auto pb-24" ref={textRef}>
|
||||
|
||||
{/* 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">
|
||||
{imagePreview ? (
|
||||
<img src={imagePreview} className="w-full h-auto object-contain" alt="scan result" />
|
||||
) : (
|
||||
<div className="w-full h-48 flex items-center justify-center text-slate-400">
|
||||
<ImageIcon size={48} />
|
||||
</div>
|
||||
)}
|
||||
</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 className="flex-1 overflow-y-auto p-6 lg:p-10 space-y-8 pb-24" ref={textRef}>
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
{/* 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">
|
||||
{imagePreview ? (
|
||||
<img src={imagePreview} className="w-full h-auto object-contain" alt="scan result" />
|
||||
) : (
|
||||
<div className="w-full h-48 flex items-center justify-center text-slate-400">
|
||||
<ImageIcon size={48} />
|
||||
</div>
|
||||
)}
|
||||
</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) => (
|
||||
v ? (
|
||||
<div key={i} className="bg-slate-50 p-3 rounded-xl border border-slate-100 flex flex-col group transition-all duration-300 hover:-translate-y-1 hover:shadow-md hover:bg-white">
|
||||
<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>
|
||||
<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(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'}`}
|
||||
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 === `vocab-${i}` ? <Loader2 size={14} className="animate-spin" /> : <Volume2 size={14} />}
|
||||
{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>
|
||||
<span className="text-sm text-indigo-600 font-medium">{v.meaning || ''}</span>
|
||||
</div>
|
||||
) : null
|
||||
))}
|
||||
<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>
|
||||
</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) => (
|
||||
g ? (
|
||||
<div key={i} className="bg-emerald-50/50 p-4 rounded-xl border border-emerald-100 transition-all duration-300 hover:-translate-y-1 hover:shadow-md hover:bg-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>
|
||||
{/* 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) => (
|
||||
v ? (
|
||||
<div key={i} className="bg-slate-50 p-3 rounded-xl border border-slate-100 flex flex-col group transition-all duration-300 hover:-translate-y-1 hover:shadow-md hover:bg-white">
|
||||
<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>
|
||||
) : null
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating Ask Button */}
|
||||
{selectedText && (
|
||||
<div className="absolute bottom-6 left-0 right-0 flex justify-center z-50 animate-fade-in-up px-4 pointer-events-none">
|
||||
<button
|
||||
onClick={() => handleAskTutor(`Explain: "${selectedText}"`)}
|
||||
className="pointer-events-auto flex items-center gap-2 px-6 py-3 bg-slate-900 text-white rounded-full shadow-2xl hover:scale-105 active:scale-95 transition-all font-bold text-sm border border-white/20"
|
||||
>
|
||||
<Sparkles size={16} className="text-yellow-300 animate-pulse" />
|
||||
Explain: <span className="max-w-[150px] truncate">"{selectedText}"</span>
|
||||
</button>
|
||||
</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) => (
|
||||
g ? (
|
||||
<div key={i} className="bg-emerald-50/50 p-4 rounded-xl border border-emerald-100 transition-all duration-300 hover:-translate-y-1 hover:shadow-md hover:bg-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>
|
||||
) : null
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Ask Button - Fixed in parent */}
|
||||
{selectedText && (
|
||||
<div className="absolute bottom-6 left-0 right-0 flex justify-center z-50 animate-fade-in-up px-4 pointer-events-none">
|
||||
<button
|
||||
onClick={() => handleAskTutor(`Explain: "${selectedText}"`)}
|
||||
className="pointer-events-auto flex items-center gap-2 px-6 py-3 bg-slate-900 text-white rounded-full shadow-2xl hover:scale-105 active:scale-95 transition-all font-bold text-sm border border-white/20"
|
||||
>
|
||||
<Sparkles size={16} className="text-yellow-300 animate-pulse" />
|
||||
Explain: <span className="max-w-[150px] truncate">"{selectedText}"</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* RIGHT: Tutor Chat (Tab: tutor) */}
|
||||
|
||||
@@ -644,20 +644,20 @@ const ReadingView: React.FC<ReadingViewProps> = ({ language, history, onSaveToHi
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Floating Ask Button */}
|
||||
{selectedText && (
|
||||
<div className="absolute bottom-6 left-0 right-0 flex justify-center z-50 animate-fade-in-up px-4 pointer-events-none">
|
||||
<button
|
||||
onClick={() => handleAskTutor(`Explain: "${selectedText}"`)}
|
||||
className="pointer-events-auto flex items-center gap-2 px-6 py-3 bg-slate-900 text-white rounded-full shadow-2xl hover:scale-105 active:scale-95 transition-all font-bold text-sm border border-white/20"
|
||||
>
|
||||
<Sparkles size={16} className="text-yellow-300 animate-pulse" />
|
||||
Explain: <span className="max-w-[150px] truncate">"{selectedText}"</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Floating Ask Button - Fixed Position in Parent */}
|
||||
{selectedText && (
|
||||
<div className="absolute bottom-6 left-0 right-0 flex justify-center z-50 animate-fade-in-up px-4 pointer-events-none">
|
||||
<button
|
||||
onClick={() => handleAskTutor(`Explain: "${selectedText}"`)}
|
||||
className="pointer-events-auto flex items-center gap-2 px-6 py-3 bg-slate-900 text-white rounded-full shadow-2xl hover:scale-105 active:scale-95 transition-all font-bold text-sm border border-white/20"
|
||||
>
|
||||
<Sparkles size={16} className="text-yellow-300 animate-pulse" />
|
||||
Explain: <span className="max-w-[150px] truncate">"{selectedText}"</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Tutor Chat (Only visible in lesson mode) */}
|
||||
|
||||
Reference in New Issue
Block a user