更新至 v0.7.0_20251126 版本
This commit is contained in:
@@ -11,13 +11,10 @@ interface State {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ErrorBoundary extends Component<Props, State> {
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
constructor(props: Props) {
|
public state: State = {
|
||||||
super(props);
|
hasError: false,
|
||||||
this.state = {
|
error: null
|
||||||
hasError: false,
|
};
|
||||||
error: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static getDerivedStateFromError(error: Error): State {
|
public static getDerivedStateFromError(error: Error): State {
|
||||||
return { hasError: true, error };
|
return { hasError: true, error };
|
||||||
|
|||||||
BIN
releases/HTY1024-APP-SKR-0.7.0_20251126.zip
Normal file
BIN
releases/HTY1024-APP-SKR-0.7.0_20251126.zip
Normal file
Binary file not shown.
@@ -1,4 +1,5 @@
|
|||||||
|
|
||||||
|
|
||||||
import { GoogleGenAI, Modality, Type } from "@google/genai";
|
import { GoogleGenAI, Modality, Type } from "@google/genai";
|
||||||
import { PronunciationFeedback, Language, ReadingLesson, ReadingDifficulty, OCRAnalysis, ListeningLesson } from "../types";
|
import { PronunciationFeedback, Language, ReadingLesson, ReadingDifficulty, OCRAnalysis, ListeningLesson } from "../types";
|
||||||
import { base64ToUint8Array, uint8ArrayToBase64 } from "../utils/audioUtils";
|
import { base64ToUint8Array, uint8ArrayToBase64 } from "../utils/audioUtils";
|
||||||
@@ -425,7 +426,7 @@ class GeminiService {
|
|||||||
const ai = this.getAi();
|
const ai = this.getAi();
|
||||||
const targetLangName = LANGUAGE_MAP[language];
|
const targetLangName = LANGUAGE_MAP[language];
|
||||||
// Prompt asks for a conversation or monologue suitable for listening practice
|
// 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.
|
const prompt = `Create a Japanese listening practice script on "${topic}", level ${difficulty}.
|
||||||
Output JSON with:
|
Output JSON with:
|
||||||
- title
|
- title
|
||||||
- script (The full Japanese text of the conversation/monologue)
|
- script (The full Japanese text of the conversation/monologue)
|
||||||
@@ -433,6 +434,7 @@ class GeminiService {
|
|||||||
- vocabulary (Key words with meanings in ${targetLangName})
|
- vocabulary (Key words with meanings in ${targetLangName})
|
||||||
- questions (3 multiple choice comprehension questions in ${targetLangName})
|
- questions (3 multiple choice comprehension questions in ${targetLangName})
|
||||||
- Each question needs: question, options (array of 3 strings), correctIndex (0-2), explanation (in ${targetLangName}).
|
- Each question needs: question, options (array of 3 strings), correctIndex (0-2), explanation (in ${targetLangName}).
|
||||||
|
- grammarPoints (explanations in ${targetLangName}).
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return this.retryOperation(async () => {
|
return this.retryOperation(async () => {
|
||||||
@@ -461,9 +463,10 @@ class GeminiService {
|
|||||||
},
|
},
|
||||||
required: ["question", "options", "correctIndex", "explanation"]
|
required: ["question", "options", "correctIndex", "explanation"]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
grammarPoints: { type: Type.ARRAY, items: { type: Type.OBJECT, properties: { point: { type: Type.STRING }, explanation: { type: Type.STRING } } } }
|
||||||
},
|
},
|
||||||
required: ["title", "script", "translation", "vocabulary", "questions"]
|
required: ["title", "script", "translation", "vocabulary", "questions", "grammarPoints"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -558,4 +561,4 @@ class GeminiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const geminiService = new GeminiService();
|
export const geminiService = new GeminiService();
|
||||||
1
types.ts
1
types.ts
@@ -123,6 +123,7 @@ export interface ListeningLesson {
|
|||||||
translation: string;
|
translation: string;
|
||||||
vocabulary: { word: string; reading: string; meaning: string }[];
|
vocabulary: { word: string; reading: string; meaning: string }[];
|
||||||
questions: QuizQuestion[];
|
questions: QuizQuestion[];
|
||||||
|
grammarPoints?: { point: string; explanation: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListeningLessonRecord extends ListeningLesson {
|
export interface ListeningLessonRecord extends ListeningLesson {
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export const getScenarios = (language: Language): Scenario[] => {
|
|||||||
title: '登机手续',
|
title: '登机手续',
|
||||||
icon: '✈️',
|
icon: '✈️',
|
||||||
description: '练习登机口的对话。',
|
description: '练习登机口的对话。',
|
||||||
initialMessage: 'ご搭乗ありがとうございます。パスポートと搭乗券を拝見します。',
|
initialMessage: 'ご搭乗ありがとうございます。パスポートと搭乗券を拝见します。',
|
||||||
initialTranslation: '感谢您的搭乘。请出示护照和登机牌。',
|
initialTranslation: '感谢您的搭乘。请出示护照和登机牌。',
|
||||||
role: '地勤人员'
|
role: '地勤人员'
|
||||||
}
|
}
|
||||||
@@ -341,7 +341,9 @@ export const translations = {
|
|||||||
emptyHistory: "No practice logs",
|
emptyHistory: "No practice logs",
|
||||||
qaWelcome: "I've generated a listening exercise. Listen to the audio first, try the quiz, then ask me anything!",
|
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.",
|
noScript: "No script available to play.",
|
||||||
scriptMissing: "No script generated. Please try generating again."
|
scriptMissing: "No script generated. Please try generating again.",
|
||||||
|
vocabTitle: "Vocabulary",
|
||||||
|
grammarHeader: "Grammar"
|
||||||
},
|
},
|
||||||
ocr: {
|
ocr: {
|
||||||
title: "Text Scanner 🔍",
|
title: "Text Scanner 🔍",
|
||||||
@@ -593,7 +595,9 @@ export const translations = {
|
|||||||
emptyHistory: "練習ログなし",
|
emptyHistory: "練習ログなし",
|
||||||
qaWelcome: "リスニング練習を作成しました。まず音声を聞いてクイズに挑戦し、その後何でも質問してください!",
|
qaWelcome: "リスニング練習を作成しました。まず音声を聞いてクイズに挑戦し、その後何でも質問してください!",
|
||||||
noScript: "再生できるスクリプトがありません。",
|
noScript: "再生できるスクリプトがありません。",
|
||||||
scriptMissing: "スクリプトが生成されませんでした。もう一度試してください。"
|
scriptMissing: "スクリプトが生成されませんでした。もう一度試してください。",
|
||||||
|
vocabTitle: "語彙",
|
||||||
|
grammarHeader: "文法"
|
||||||
},
|
},
|
||||||
ocr: {
|
ocr: {
|
||||||
title: "テキストスキャナー 🔍",
|
title: "テキストスキャナー 🔍",
|
||||||
@@ -845,7 +849,9 @@ export const translations = {
|
|||||||
emptyHistory: "暂无练习记录",
|
emptyHistory: "暂无练习记录",
|
||||||
qaWelcome: "我已生成听力练习。先听音频,尝试测验,然后尽管问我任何问题!",
|
qaWelcome: "我已生成听力练习。先听音频,尝试测验,然后尽管问我任何问题!",
|
||||||
noScript: "暂无脚本可播放。",
|
noScript: "暂无脚本可播放。",
|
||||||
scriptMissing: "未生成脚本。请重试。"
|
scriptMissing: "未生成脚本。请重试。",
|
||||||
|
vocabTitle: "词汇",
|
||||||
|
grammarHeader: "语法"
|
||||||
},
|
},
|
||||||
ocr: {
|
ocr: {
|
||||||
title: "文本扫描仪 🔍",
|
title: "文本扫描仪 🔍",
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { Language, ListeningLesson, ListeningLessonRecord, ReadingDifficulty, ChatMessage, Role, MessageType } from '../types';
|
import { Language, ListeningLesson, ListeningLessonRecord, ReadingDifficulty, ChatMessage, Role, MessageType } from '../types';
|
||||||
import { geminiService, decodeAudioData } from '../services/geminiService';
|
import { geminiService, decodeAudioData } from '../services/geminiService';
|
||||||
import { processAndDownloadAudio } from '../utils/audioUtils';
|
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, Copy, Check } from 'lucide-react';
|
import { Headphones, Loader2, Send, Eye, EyeOff, List, HelpCircle, ChevronLeft, History, Trash2, X, PanelRightClose, PanelRightOpen, Volume2, Square, Play, Pause, CheckCircle, AlertCircle, FileText, MessageCircle, Download, RotateCcw, Copy, Check, PenTool, Sparkles } from 'lucide-react';
|
||||||
import { translations } from '../utils/localization';
|
import { translations } from '../utils/localization';
|
||||||
import ChatBubble from '../components/ChatBubble';
|
import ChatBubble from '../components/ChatBubble';
|
||||||
|
|
||||||
@@ -61,6 +62,8 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
|
|||||||
const [isTTSLoading, setIsTTSLoading] = useState(false);
|
const [isTTSLoading, setIsTTSLoading] = useState(false);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [audioCache, setAudioCache] = useState<string | null>(null);
|
const [audioCache, setAudioCache] = useState<string | null>(null);
|
||||||
|
const [playingVocabWord, setPlayingVocabWord] = useState<string | null>(null);
|
||||||
|
|
||||||
const audioContextRef = useRef<AudioContext | null>(null);
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
const audioSourceRef = useRef<AudioBufferSourceNode | null>(null);
|
const audioSourceRef = useRef<AudioBufferSourceNode | null>(null);
|
||||||
|
|
||||||
@@ -70,10 +73,32 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
|
|||||||
const [isChatLoading, setIsChatLoading] = useState(false);
|
const [isChatLoading, setIsChatLoading] = useState(false);
|
||||||
const chatEndRef = useRef<HTMLDivElement>(null);
|
const chatEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Selection State
|
||||||
|
const [selectedText, setSelectedText] = useState<string | null>(null);
|
||||||
|
const scriptRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Cleanup audio when leaving lesson
|
// Cleanup audio when leaving lesson
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => stopAudio();
|
return () => stopAudio();
|
||||||
}, [lesson]);
|
}, [lesson]);
|
||||||
|
|
||||||
|
// Handle Selection
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSelectionChange = () => {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection && !selection.isCollapsed && scriptRef.current && scriptRef.current.contains(selection.anchorNode)) {
|
||||||
|
const text = selection.toString().trim();
|
||||||
|
if (text.length > 0) {
|
||||||
|
setSelectedText(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelectedText(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('selectionchange', handleSelectionChange);
|
||||||
|
return () => document.removeEventListener('selectionchange', handleSelectionChange);
|
||||||
|
}, [lesson, showScript]);
|
||||||
|
|
||||||
const stopAudio = () => {
|
const stopAudio = () => {
|
||||||
if (audioSourceRef.current) {
|
if (audioSourceRef.current) {
|
||||||
@@ -81,9 +106,10 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
|
|||||||
audioSourceRef.current = null;
|
audioSourceRef.current = null;
|
||||||
}
|
}
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
|
setPlayingVocabWord(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const playAudioData = async (base64Data: string) => {
|
const playAudioData = async (base64Data: string, onEnded?: () => void) => {
|
||||||
stopAudio();
|
stopAudio();
|
||||||
if (!audioContextRef.current) {
|
if (!audioContextRef.current) {
|
||||||
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
|
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||||
@@ -95,7 +121,7 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
|
|||||||
const source = ctx.createBufferSource();
|
const source = ctx.createBufferSource();
|
||||||
source.buffer = buffer;
|
source.buffer = buffer;
|
||||||
source.connect(ctx.destination);
|
source.connect(ctx.destination);
|
||||||
source.onended = () => setIsPlaying(false);
|
source.onended = onEnded || (() => setIsPlaying(false));
|
||||||
source.start();
|
source.start();
|
||||||
audioSourceRef.current = source;
|
audioSourceRef.current = source;
|
||||||
};
|
};
|
||||||
@@ -108,7 +134,7 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
|
|||||||
|
|
||||||
if (audioCache) {
|
if (audioCache) {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
await playAudioData(audioCache);
|
await playAudioData(audioCache, () => setIsPlaying(false));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +150,7 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
|
|||||||
|
|
||||||
setAudioCache(audioBase64);
|
setAudioCache(audioBase64);
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
await playAudioData(audioBase64);
|
await playAudioData(audioBase64, () => setIsPlaying(false));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("TTS Playback failed", e);
|
console.error("TTS Playback failed", e);
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
@@ -157,6 +183,25 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 generateLesson = async () => {
|
const generateLesson = async () => {
|
||||||
if (!topic.trim()) return;
|
if (!topic.trim()) return;
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
@@ -239,11 +284,21 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAskTutor = async () => {
|
const handleAskTutor = async (customQuestion?: string) => {
|
||||||
if (!chatInput.trim() || !lesson) return;
|
const question = customQuestion || chatInput;
|
||||||
|
if (!question.trim() || !lesson) return;
|
||||||
|
|
||||||
const question = chatInput;
|
setMobileTab('tutor');
|
||||||
setChatInput('');
|
|
||||||
|
if (!customQuestion) {
|
||||||
|
setChatInput('');
|
||||||
|
} else {
|
||||||
|
if (window.getSelection) {
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
}
|
||||||
|
setSelectedText(null);
|
||||||
|
}
|
||||||
|
|
||||||
setIsChatLoading(true);
|
setIsChatLoading(true);
|
||||||
|
|
||||||
// Add User Message
|
// Add User Message
|
||||||
@@ -433,9 +488,9 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
|
|||||||
{lesson && (
|
{lesson && (
|
||||||
<div className="flex flex-col lg:flex-row h-full overflow-hidden">
|
<div className="flex flex-col lg:flex-row h-full overflow-hidden">
|
||||||
{/* Left: Content */}
|
{/* 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={`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="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">
|
<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">
|
<button onClick={() => { setLesson(null); setCurrentRecordId(null); }} className="text-slate-400 hover:text-slate-600 p-2 hover:scale-110 transition-transform">
|
||||||
<ChevronLeft size={24} />
|
<ChevronLeft size={24} />
|
||||||
</button>
|
</button>
|
||||||
@@ -475,7 +530,7 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6 md:p-10 bg-slate-50/30">
|
<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">
|
<div className="max-w-3xl mx-auto space-y-8 pb-24">
|
||||||
|
|
||||||
{/* Audio Player Section - Modern Card Design */}
|
{/* 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">
|
<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">
|
||||||
@@ -622,7 +677,7 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
|
|||||||
|
|
||||||
{/* Script Reveal */}
|
{/* Script Reveal */}
|
||||||
{showScript && (
|
{showScript && (
|
||||||
<div className="animate-fade-in-up">
|
<div className="animate-fade-in-up" ref={scriptRef}>
|
||||||
<div className="bg-white p-6 md:p-8 rounded-2xl border border-slate-200 shadow-sm mb-6 relative">
|
<div className="bg-white p-6 md:p-8 rounded-2xl border border-slate-200 shadow-sm mb-6 relative">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider">{t.scriptTitle}</h4>
|
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider">{t.scriptTitle}</h4>
|
||||||
@@ -642,22 +697,68 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Vocabulary List */}
|
{/* Vocabulary List */}
|
||||||
<div className="bg-sky-50/50 rounded-2xl p-6 border border-sky-100/50">
|
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm mb-6">
|
||||||
<h4 className="text-sm font-bold text-sky-800 mb-4 flex items-center gap-2">
|
<h4 className="text-sm font-bold text-sky-800 mb-4 flex items-center gap-2">
|
||||||
<List size={18} /> {translations[language].reading.vocabTitle}
|
<List size={18} /> {t.vocabTitle}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{lesson.vocabulary?.map((v, i) => (
|
{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
|
||||||
<div className="flex items-baseline gap-2 mb-1">
|
key={i}
|
||||||
<span className="text-lg font-bold text-slate-800">{v.word}</span>
|
className="bg-sky-50 p-3 rounded-xl border border-sky-100 flex flex-col group relative animate-fade-in-up transition-all duration-300 hover:-translate-y-1 hover:shadow-md hover:bg-sky-100/50"
|
||||||
<span className="text-sm text-slate-500">({v.reading})</span>
|
style={{ animationDelay: `${i * 50}ms`, animationFillMode: 'both' }}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-1">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0">
|
||||||
|
<span className="text-lg font-bold text-slate-800">{v.word}</span>
|
||||||
|
{v.reading && <span className="text-sm text-slate-500">({v.reading})</span>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => playVocab(v.word)}
|
||||||
|
className={`p-1.5 rounded-full transition-colors flex-shrink-0 ml-2 ${playingVocabWord === v.word ? 'bg-pink-100 text-pink-500' : 'text-sky-300 hover:bg-sky-100 hover:text-sky-600'}`}
|
||||||
|
>
|
||||||
|
{playingVocabWord === v.word ? <Loader2 size={14} className="animate-spin" /> : <Volume2 size={14} />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-sky-700 font-medium">{v.meaning}</p>
|
<p className="text-sm text-sky-700 font-medium">{v.meaning}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Grammar Section */}
|
||||||
|
{lesson.grammarPoints && lesson.grammarPoints.length > 0 && (
|
||||||
|
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm">
|
||||||
|
<h4 className="text-sm font-bold text-sky-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-sky-50/50 p-4 rounded-xl border border-sky-100 animate-fade-in-up transition-all duration-300 hover:-translate-y-1 hover:shadow-md hover:bg-sky-100"
|
||||||
|
style={{ animationDelay: `${i * 100}ms`, animationFillMode: 'both' }}
|
||||||
|
>
|
||||||
|
<h5 className="font-bold text-sky-900 mb-1">{g.point}</h5>
|
||||||
|
<p className="text-sm text-sky-700 leading-relaxed">{g.explanation}</p>
|
||||||
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -694,7 +795,7 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
|
|||||||
onKeyDown={(e) => e.key === 'Enter' && handleAskTutor()}
|
onKeyDown={(e) => e.key === 'Enter' && handleAskTutor()}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleAskTutor}
|
onClick={() => handleAskTutor()}
|
||||||
disabled={!chatInput.trim() || isChatLoading}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
@@ -707,7 +808,7 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar History (Desktop) */}
|
{/* Sidebar History (Desktop - Collapsible) */}
|
||||||
<div className={`
|
<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
|
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'}
|
${isHistoryOpen ? 'w-80 opacity-100' : 'w-0 opacity-0 border-none'}
|
||||||
@@ -728,4 +829,4 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ListeningView;
|
export default ListeningView;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Language, OCRAnalysis, ChatMessage, Role, MessageType, OCRRecord } from
|
|||||||
import { geminiService, decodeAudioData } from '../services/geminiService';
|
import { geminiService, decodeAudioData } from '../services/geminiService';
|
||||||
import { translations } from '../utils/localization';
|
import { translations } from '../utils/localization';
|
||||||
import { processAndDownloadAudio } from '../utils/audioUtils';
|
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, Image as ImageIcon } from 'lucide-react';
|
import { ScanText, Upload, Camera, Loader2, Send, Book, PenTool, RotateCcw, History, Trash2, X, PanelRightClose, PanelRightOpen, Volume2, Square, MessageCircle, HelpCircle, ChevronLeft, FileText, Download, Image as ImageIcon, Sparkles } from 'lucide-react';
|
||||||
import ChatBubble from '../components/ChatBubble';
|
import ChatBubble from '../components/ChatBubble';
|
||||||
|
|
||||||
interface OCRViewProps {
|
interface OCRViewProps {
|
||||||
@@ -41,6 +41,10 @@ const OCRView: React.FC<OCRViewProps> = ({ language, history, onSaveToHistory, o
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Selection State
|
||||||
|
const [selectedText, setSelectedText] = useState<string | null>(null);
|
||||||
|
const textRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Scroll to bottom of chat
|
// Scroll to bottom of chat
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
@@ -53,6 +57,24 @@ const OCRView: React.FC<OCRViewProps> = ({ language, history, onSaveToHistory, o
|
|||||||
};
|
};
|
||||||
}, [analysis]);
|
}, [analysis]);
|
||||||
|
|
||||||
|
// Handle Selection
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSelectionChange = () => {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection && !selection.isCollapsed && textRef.current && textRef.current.contains(selection.anchorNode)) {
|
||||||
|
const text = selection.toString().trim();
|
||||||
|
if (text.length > 0) {
|
||||||
|
setSelectedText(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelectedText(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('selectionchange', handleSelectionChange);
|
||||||
|
return () => document.removeEventListener('selectionchange', handleSelectionChange);
|
||||||
|
}, [analysis]);
|
||||||
|
|
||||||
const handleImageInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImageInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -172,10 +194,21 @@ const OCRView: React.FC<OCRViewProps> = ({ language, history, onSaveToHistory, o
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAskTutor = async () => {
|
const handleAskTutor = async (customQuestion?: string) => {
|
||||||
if (!chatInput.trim() || !analysis) return;
|
const question = customQuestion || chatInput;
|
||||||
const question = chatInput;
|
if (!question.trim() || !analysis) return;
|
||||||
setChatInput('');
|
|
||||||
|
setMobileTab('tutor');
|
||||||
|
|
||||||
|
if (!customQuestion) {
|
||||||
|
setChatInput('');
|
||||||
|
} else {
|
||||||
|
if (window.getSelection) {
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
}
|
||||||
|
setSelectedText(null);
|
||||||
|
}
|
||||||
|
|
||||||
setIsChatLoading(true);
|
setIsChatLoading(true);
|
||||||
const newHistory = [...chatMessages, { id: Date.now().toString(), role: Role.USER, type: MessageType.TEXT, content: question, timestamp: Date.now() }];
|
const newHistory = [...chatMessages, { id: Date.now().toString(), role: Role.USER, type: MessageType.TEXT, content: question, timestamp: Date.now() }];
|
||||||
setChatMessages(newHistory);
|
setChatMessages(newHistory);
|
||||||
@@ -369,7 +402,7 @@ const OCRView: React.FC<OCRViewProps> = ({ language, history, onSaveToHistory, o
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Scroll Area */}
|
{/* Content Scroll Area */}
|
||||||
<div className="p-6 lg:p-10 space-y-8 max-w-4xl mx-auto">
|
<div className="p-6 lg:p-10 space-y-8 max-w-4xl mx-auto pb-24" ref={textRef}>
|
||||||
|
|
||||||
{/* 1. Image & Extracted Text */}
|
{/* 1. Image & Extracted Text */}
|
||||||
<div className="flex flex-col md:flex-row gap-6">
|
<div className="flex flex-col md:flex-row gap-6">
|
||||||
@@ -421,7 +454,7 @@ const OCRView: React.FC<OCRViewProps> = ({ language, history, onSaveToHistory, o
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{analysis?.vocabulary?.map((v, i) => (
|
{analysis?.vocabulary?.map((v, i) => (
|
||||||
v ? (
|
v ? (
|
||||||
<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 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 justify-between items-baseline mb-1">
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="font-bold text-slate-800">{v.word || ''}</span>
|
<span className="font-bold text-slate-800">{v.word || ''}</span>
|
||||||
@@ -448,7 +481,7 @@ const OCRView: React.FC<OCRViewProps> = ({ language, history, onSaveToHistory, o
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{analysis.grammarPoints.map((g, i) => (
|
{analysis.grammarPoints.map((g, i) => (
|
||||||
g ? (
|
g ? (
|
||||||
<div key={i} className="bg-emerald-50/50 p-4 rounded-xl border border-emerald-100">
|
<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>
|
<h5 className="font-bold text-emerald-900 mb-1">{g.point || ''}</h5>
|
||||||
<p className="text-sm text-emerald-700 leading-relaxed">{g.explanation || ''}</p>
|
<p className="text-sm text-emerald-700 leading-relaxed">{g.explanation || ''}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -457,6 +490,19 @@ const OCRView: React.FC<OCRViewProps> = ({ language, history, onSaveToHistory, o
|
|||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -491,7 +537,7 @@ const OCRView: React.FC<OCRViewProps> = ({ language, history, onSaveToHistory, o
|
|||||||
onKeyDown={(e) => e.key === 'Enter' && handleAskTutor()}
|
onKeyDown={(e) => e.key === 'Enter' && handleAskTutor()}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleAskTutor}
|
onClick={() => handleAskTutor()}
|
||||||
disabled={!chatInput.trim() || isChatLoading}
|
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"
|
className="p-2 bg-indigo-600 text-white rounded-full hover:bg-indigo-700 disabled:opacity-50 transform active:scale-95 transition-transform"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React, { useState, useRef, useEffect } from 'react';
|
|||||||
import { Language, ReadingLesson, ReadingDifficulty, ChatMessage, Role, MessageType, ReadingLessonRecord } from '../types';
|
import { Language, ReadingLesson, ReadingDifficulty, ChatMessage, Role, MessageType, ReadingLessonRecord } from '../types';
|
||||||
import { geminiService, decodeAudioData } from '../services/geminiService';
|
import { geminiService, decodeAudioData } from '../services/geminiService';
|
||||||
import { processAndDownloadAudio } from '../utils/audioUtils';
|
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, Copy, Check } from 'lucide-react';
|
import { BookOpen, Loader2, Send, ToggleLeft, ToggleRight, List, HelpCircle, ChevronLeft, RotateCcw, History, Trash2, X, PanelRightClose, PanelRightOpen, Volume2, Square, MessageCircle, FileText, PenTool, Download, Copy, Check, Sparkles } from 'lucide-react';
|
||||||
import { translations } from '../utils/localization';
|
import { translations } from '../utils/localization';
|
||||||
import ChatBubble from '../components/ChatBubble';
|
import ChatBubble from '../components/ChatBubble';
|
||||||
|
|
||||||
@@ -71,6 +71,10 @@ const ReadingView: React.FC<ReadingViewProps> = ({ language, history, onSaveToHi
|
|||||||
const [isChatLoading, setIsChatLoading] = useState(false);
|
const [isChatLoading, setIsChatLoading] = useState(false);
|
||||||
const chatEndRef = useRef<HTMLDivElement>(null);
|
const chatEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Selection State
|
||||||
|
const [selectedText, setSelectedText] = useState<string | null>(null);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Cleanup audio when leaving lesson
|
// Cleanup audio when leaving lesson
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -82,6 +86,24 @@ const ReadingView: React.FC<ReadingViewProps> = ({ language, history, onSaveToHi
|
|||||||
};
|
};
|
||||||
}, [lesson]);
|
}, [lesson]);
|
||||||
|
|
||||||
|
// Handle Text Selection
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSelectionChange = () => {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection && !selection.isCollapsed && contentRef.current && contentRef.current.contains(selection.anchorNode)) {
|
||||||
|
const text = selection.toString().trim();
|
||||||
|
if (text.length > 0) {
|
||||||
|
setSelectedText(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelectedText(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('selectionchange', handleSelectionChange);
|
||||||
|
return () => document.removeEventListener('selectionchange', handleSelectionChange);
|
||||||
|
}, [lesson]);
|
||||||
|
|
||||||
const generateLesson = async () => {
|
const generateLesson = async () => {
|
||||||
if (!topic.trim()) return;
|
if (!topic.trim()) return;
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
@@ -260,11 +282,23 @@ const ReadingView: React.FC<ReadingViewProps> = ({ language, history, onSaveToHi
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAskTutor = async () => {
|
const handleAskTutor = async (customQuestion?: string) => {
|
||||||
if (!chatInput.trim() || !lesson) return;
|
const question = customQuestion || chatInput;
|
||||||
|
if (!question.trim() || !lesson) return;
|
||||||
|
|
||||||
const question = chatInput;
|
// Switch to Tutor Tab if on mobile
|
||||||
setChatInput('');
|
setMobileTab('tutor');
|
||||||
|
|
||||||
|
if (!customQuestion) {
|
||||||
|
setChatInput('');
|
||||||
|
} else {
|
||||||
|
// Clear selection if it was a quick ask
|
||||||
|
if (window.getSelection) {
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
}
|
||||||
|
setSelectedText(null);
|
||||||
|
}
|
||||||
|
|
||||||
setIsChatLoading(true);
|
setIsChatLoading(true);
|
||||||
|
|
||||||
// Add User Message
|
// Add User Message
|
||||||
@@ -542,8 +576,8 @@ const ReadingView: React.FC<ReadingViewProps> = ({ language, history, onSaveToHi
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6 md:p-10">
|
<div className="flex-1 overflow-y-auto p-6 md:p-10" ref={contentRef}>
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto pb-24">
|
||||||
<div className="mb-12 animate-fade-in-up delay-100">
|
<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">
|
<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>}
|
{lesson.japaneseContent || <span className="text-red-400 italic text-base">{t.contentMissing}</span>}
|
||||||
@@ -566,7 +600,11 @@ const ReadingView: React.FC<ReadingViewProps> = ({ language, history, onSaveToHi
|
|||||||
</h4>
|
</h4>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{lesson.vocabulary?.map((v, i) => (
|
{lesson.vocabulary?.map((v, i) => (
|
||||||
<div key={i} className="bg-emerald-50 p-3 rounded-xl border border-emerald-100 flex flex-col group relative">
|
<div
|
||||||
|
key={i}
|
||||||
|
className="bg-emerald-50 p-3 rounded-xl border border-emerald-100 flex flex-col group relative animate-fade-in-up transition-all duration-300 hover:-translate-y-1 hover:shadow-md hover:bg-emerald-100/50"
|
||||||
|
style={{ animationDelay: `${i * 50}ms`, animationFillMode: 'both' }}
|
||||||
|
>
|
||||||
<div className="flex justify-between items-start mb-1">
|
<div className="flex justify-between items-start mb-1">
|
||||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0">
|
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0">
|
||||||
<span className="text-lg font-bold text-slate-800">{v.word}</span>
|
<span className="text-lg font-bold text-slate-800">{v.word}</span>
|
||||||
@@ -593,7 +631,11 @@ const ReadingView: React.FC<ReadingViewProps> = ({ language, history, onSaveToHi
|
|||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{lesson.grammarPoints.map((g, i) => (
|
{lesson.grammarPoints.map((g, i) => (
|
||||||
<div key={i} className="bg-emerald-50/50 p-4 rounded-xl border border-emerald-100">
|
<div
|
||||||
|
key={i}
|
||||||
|
className="bg-emerald-50/50 p-4 rounded-xl border border-emerald-100 animate-fade-in-up transition-all duration-300 hover:-translate-y-1 hover:shadow-md hover:bg-emerald-100"
|
||||||
|
style={{ animationDelay: `${i * 100}ms`, animationFillMode: 'both' }}
|
||||||
|
>
|
||||||
<h5 className="font-bold text-emerald-900 mb-1">{g.point}</h5>
|
<h5 className="font-bold text-emerald-900 mb-1">{g.point}</h5>
|
||||||
<p className="text-sm text-emerald-700 leading-relaxed">{g.explanation}</p>
|
<p className="text-sm text-emerald-700 leading-relaxed">{g.explanation}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -602,6 +644,19 @@ const ReadingView: React.FC<ReadingViewProps> = ({ language, history, onSaveToHi
|
|||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -639,7 +694,7 @@ const ReadingView: React.FC<ReadingViewProps> = ({ language, history, onSaveToHi
|
|||||||
onKeyDown={(e) => e.key === 'Enter' && handleAskTutor()}
|
onKeyDown={(e) => e.key === 'Enter' && handleAskTutor()}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleAskTutor}
|
onClick={() => handleAskTutor()}
|
||||||
disabled={!chatInput.trim() || isChatLoading}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user