更新至 v0.8.0_20251223 版本

This commit is contained in:
2025-12-24 01:05:00 +08:00
parent 3528123bb0
commit 7a4d1c75a2
10 changed files with 199 additions and 139 deletions

46
App.tsx
View File

@@ -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;

View File

@@ -1,3 +1,5 @@
# Sakura Sensei 🌸 - AI Japanese Tutor
![React](https://img.shields.io/badge/React-19.0-blue?logo=react)
@@ -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/).

View File

@@ -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

Binary file not shown.

View File

@@ -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 }]
},

View File

@@ -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

View File

@@ -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: "开始录音",

View File

@@ -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 */}

View File

@@ -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) */}

View File

@@ -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) */}