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