677 lines
33 KiB
TypeScript
677 lines
33 KiB
TypeScript
|
|
import React, { useState, useRef, useEffect } from 'react';
|
|
import { Language, ReadingLesson, ReadingDifficulty, ChatMessage, Role, MessageType, ReadingLessonRecord } from '../types';
|
|
import { geminiService, decodeAudioData } from '../services/geminiService';
|
|
import { processAndDownloadAudio } from '../utils/audioUtils';
|
|
import { BookOpen, Loader2, Send, ToggleLeft, ToggleRight, List, HelpCircle, ChevronLeft, RotateCcw, History, Trash2, X, PanelRightClose, PanelRightOpen, Volume2, Square, MessageCircle, FileText, PenTool, Download, Copy, Check } from 'lucide-react';
|
|
import { translations } from '../utils/localization';
|
|
import ChatBubble from '../components/ChatBubble';
|
|
|
|
interface ReadingViewProps {
|
|
language: Language;
|
|
history: ReadingLessonRecord[];
|
|
onSaveToHistory: (lesson: ReadingLessonRecord) => void;
|
|
onUpdateHistory: (lesson: ReadingLessonRecord) => void;
|
|
onClearHistory: () => void;
|
|
onDeleteHistoryItem: (id: string) => void;
|
|
}
|
|
|
|
// Internal Copy Button Component
|
|
const CopyButton: React.FC<{ text: string; label?: string }> = ({ text, label }) => {
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
const handleCopy = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
navigator.clipboard.writeText(text);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
};
|
|
|
|
return (
|
|
<button
|
|
onClick={handleCopy}
|
|
className="p-1.5 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors flex items-center gap-1"
|
|
title={label || "Copy"}
|
|
>
|
|
{copied ? <Check size={16} className="text-emerald-500" /> : <Copy size={16} />}
|
|
</button>
|
|
);
|
|
};
|
|
|
|
const ReadingView: React.FC<ReadingViewProps> = ({ language, history, onSaveToHistory, onUpdateHistory, onClearHistory, onDeleteHistoryItem }) => {
|
|
const t = translations[language].reading;
|
|
const tCommon = translations[language].common;
|
|
|
|
// Setup State
|
|
const [topic, setTopic] = useState('');
|
|
const [difficulty, setDifficulty] = useState<ReadingDifficulty>(ReadingDifficulty.INTERMEDIATE);
|
|
const [isGenerating, setIsGenerating] = useState(false);
|
|
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
|
|
|
// Content State
|
|
const [lesson, setLesson] = useState<ReadingLesson | null>(null);
|
|
const [currentRecordId, setCurrentRecordId] = useState<string | null>(null);
|
|
const [showTranslation, setShowTranslation] = useState(false);
|
|
|
|
// Mobile Tab State
|
|
const [mobileTab, setMobileTab] = useState<'text' | 'tutor'>('text');
|
|
|
|
// TTS State
|
|
const [isTTSLoading, setIsTTSLoading] = useState(false);
|
|
const [isPlayingTTS, setIsPlayingTTS] = useState(false);
|
|
const [playingVocabWord, setPlayingVocabWord] = useState<string | null>(null);
|
|
const [audioCache, setAudioCache] = useState<string | null>(null);
|
|
|
|
const audioContextRef = useRef<AudioContext | null>(null);
|
|
const audioSourceRef = useRef<AudioBufferSourceNode | null>(null);
|
|
|
|
// Tutor Chat State
|
|
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
|
const [chatInput, setChatInput] = useState('');
|
|
const [isChatLoading, setIsChatLoading] = useState(false);
|
|
const chatEndRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Cleanup audio when leaving lesson
|
|
useEffect(() => {
|
|
return () => {
|
|
if (audioSourceRef.current) {
|
|
audioSourceRef.current.stop();
|
|
}
|
|
setIsPlayingTTS(false);
|
|
setPlayingVocabWord(null);
|
|
};
|
|
}, [lesson]);
|
|
|
|
const generateLesson = async () => {
|
|
if (!topic.trim()) return;
|
|
setIsGenerating(true);
|
|
try {
|
|
const result = await geminiService.generateReadingLesson(topic, difficulty, language);
|
|
if (result) {
|
|
const newId = Date.now().toString();
|
|
const initialChat: ChatMessage[] = [{
|
|
id: 'init',
|
|
role: Role.MODEL,
|
|
type: MessageType.TEXT,
|
|
content: t.qaWelcome,
|
|
timestamp: Date.now()
|
|
}];
|
|
|
|
const record: ReadingLessonRecord = {
|
|
...result,
|
|
id: newId,
|
|
topic: topic,
|
|
difficulty: difficulty,
|
|
timestamp: Date.now(),
|
|
chatHistory: initialChat
|
|
};
|
|
|
|
onSaveToHistory(record);
|
|
|
|
// Set State
|
|
setLesson(result);
|
|
setCurrentRecordId(newId);
|
|
setChatMessages(initialChat);
|
|
setAudioCache(null);
|
|
|
|
// Collapse sidebar by default when entering detail view
|
|
setIsHistoryOpen(false);
|
|
setMobileTab('text'); // Default to text view
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setIsGenerating(false);
|
|
}
|
|
};
|
|
|
|
const loadFromHistory = (record: ReadingLessonRecord) => {
|
|
setLesson(record);
|
|
setCurrentRecordId(record.id);
|
|
setIsHistoryOpen(false); // Collapse sidebar by default
|
|
setMobileTab('text');
|
|
setAudioCache(null);
|
|
|
|
if (record.chatHistory && record.chatHistory.length > 0) {
|
|
setChatMessages(record.chatHistory);
|
|
} else {
|
|
setChatMessages([{
|
|
id: 'init',
|
|
role: Role.MODEL,
|
|
type: MessageType.TEXT,
|
|
content: t.qaWelcome,
|
|
timestamp: Date.now()
|
|
}]);
|
|
}
|
|
};
|
|
|
|
const updateCurrentLessonChat = (newMessages: ChatMessage[]) => {
|
|
setChatMessages(newMessages);
|
|
if (currentRecordId && lesson) {
|
|
// Find existing record details to preserve topic/difficulty
|
|
const existing = history.find(h => h.id === currentRecordId);
|
|
if (existing) {
|
|
onUpdateHistory({
|
|
...existing,
|
|
chatHistory: newMessages
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const initAudioContext = async () => {
|
|
if (!audioContextRef.current) {
|
|
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
|
|
}
|
|
if (audioContextRef.current.state === 'suspended') {
|
|
await audioContextRef.current.resume();
|
|
}
|
|
return audioContextRef.current;
|
|
};
|
|
|
|
const stopAudio = () => {
|
|
if (audioSourceRef.current) {
|
|
audioSourceRef.current.stop();
|
|
audioSourceRef.current = null;
|
|
}
|
|
setIsPlayingTTS(false);
|
|
setPlayingVocabWord(null);
|
|
};
|
|
|
|
const playAudioData = async (base64Data: string, onEnded: () => void) => {
|
|
stopAudio();
|
|
const ctx = await initAudioContext();
|
|
const buffer = await decodeAudioData(base64Data, ctx);
|
|
const source = ctx.createBufferSource();
|
|
source.buffer = buffer;
|
|
source.connect(ctx.destination);
|
|
source.onended = onEnded;
|
|
source.start();
|
|
audioSourceRef.current = source;
|
|
};
|
|
|
|
const toggleTTS = async () => {
|
|
if (isPlayingTTS) {
|
|
stopAudio();
|
|
return;
|
|
}
|
|
|
|
if (audioCache) {
|
|
setIsPlayingTTS(true);
|
|
await playAudioData(audioCache, () => setIsPlayingTTS(false));
|
|
return;
|
|
}
|
|
|
|
if (!lesson?.japaneseContent) return;
|
|
|
|
setIsTTSLoading(true);
|
|
try {
|
|
const audioBase64 = await geminiService.generateSpeech(lesson.japaneseContent);
|
|
if (!audioBase64) return;
|
|
|
|
setAudioCache(audioBase64);
|
|
setIsPlayingTTS(true);
|
|
await playAudioData(audioBase64, () => setIsPlayingTTS(false));
|
|
} catch (e) {
|
|
console.error("TTS Playback failed", e);
|
|
setIsPlayingTTS(false);
|
|
} finally {
|
|
setIsTTSLoading(false);
|
|
}
|
|
};
|
|
|
|
const downloadTTS = async () => {
|
|
if (audioCache) {
|
|
processAndDownloadAudio(audioCache, `sakura_reading_${Date.now()}.wav`);
|
|
return;
|
|
}
|
|
if (!lesson?.japaneseContent) return;
|
|
|
|
setIsTTSLoading(true);
|
|
try {
|
|
const audioBase64 = await geminiService.generateSpeech(lesson.japaneseContent);
|
|
if (audioBase64) {
|
|
setAudioCache(audioBase64);
|
|
processAndDownloadAudio(audioBase64, `sakura_reading_${Date.now()}.wav`);
|
|
}
|
|
} catch (e) {
|
|
console.error("TTS Download failed", e);
|
|
} finally {
|
|
setIsTTSLoading(false);
|
|
}
|
|
};
|
|
|
|
const playVocab = async (word: string) => {
|
|
if (playingVocabWord === word) {
|
|
stopAudio();
|
|
return;
|
|
}
|
|
|
|
setPlayingVocabWord(word);
|
|
try {
|
|
const audioBase64 = await geminiService.generateSpeech(word);
|
|
if (audioBase64) {
|
|
await playAudioData(audioBase64, () => setPlayingVocabWord(null));
|
|
} else {
|
|
setPlayingVocabWord(null);
|
|
}
|
|
} catch (e) {
|
|
setPlayingVocabWord(null);
|
|
}
|
|
};
|
|
|
|
const handleAskTutor = async () => {
|
|
if (!chatInput.trim() || !lesson) return;
|
|
|
|
const question = chatInput;
|
|
setChatInput('');
|
|
setIsChatLoading(true);
|
|
|
|
// Add User Message
|
|
const updatedMessages = [...chatMessages, {
|
|
id: Date.now().toString(),
|
|
role: Role.USER,
|
|
type: MessageType.TEXT,
|
|
content: question,
|
|
timestamp: Date.now()
|
|
}];
|
|
updateCurrentLessonChat(updatedMessages);
|
|
|
|
// Build history string for context
|
|
const historyText = updatedMessages.slice(-4).map(m => `${m.role}: ${m.content}`).join('\n');
|
|
|
|
try {
|
|
const answer = await geminiService.generateReadingTutorResponse(question, lesson, historyText, language);
|
|
const finalMessages = [...updatedMessages, {
|
|
id: (Date.now() + 1).toString(),
|
|
role: Role.MODEL,
|
|
type: MessageType.TEXT,
|
|
content: answer,
|
|
timestamp: Date.now()
|
|
}];
|
|
updateCurrentLessonChat(finalMessages);
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setIsChatLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}, [chatMessages, mobileTab]);
|
|
|
|
const HistoryContent = () => (
|
|
<div className="flex flex-col h-full bg-white">
|
|
<div className="p-4 border-b border-slate-100 bg-slate-50/50 flex items-center justify-between">
|
|
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
|
<History size={18} className="text-emerald-500" /> {t.historyTitle}
|
|
</h3>
|
|
<div className="flex items-center gap-3">
|
|
{history.length > 0 && (
|
|
<button onClick={onClearHistory} className="text-xs text-red-400 hover:text-red-600 hover:underline">
|
|
{t.clear}
|
|
</button>
|
|
)}
|
|
{/* Close button explicitly for mobile */}
|
|
<button onClick={() => setIsHistoryOpen(false)} className="md:hidden text-slate-400 hover:text-slate-600">
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="p-4 space-y-3 overflow-y-auto h-full pb-20">
|
|
{history.length === 0 && (
|
|
<div className="text-center text-slate-400 text-sm mt-10">{t.emptyHistory}</div>
|
|
)}
|
|
{history.slice().reverse().map(rec => (
|
|
<div
|
|
key={rec.id}
|
|
onClick={() => loadFromHistory(rec)}
|
|
className={`group flex items-start gap-3 p-3 rounded-xl border cursor-pointer relative transition-all ${
|
|
currentRecordId === rec.id
|
|
? 'bg-emerald-50 border-emerald-200 shadow-sm'
|
|
: 'bg-slate-50 border-slate-100 hover:bg-white hover:shadow-md'
|
|
}`}
|
|
>
|
|
{/* Difficulty Icon/Badge */}
|
|
<div className="w-10 h-10 rounded-xl bg-emerald-100 flex-shrink-0 flex items-center justify-center text-emerald-700 text-[10px] font-bold uppercase shadow-inner">
|
|
{rec.difficulty === ReadingDifficulty.BEGINNER ? 'N5' : rec.difficulty === ReadingDifficulty.INTERMEDIATE ? 'N3' : 'N1'}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex justify-between items-start">
|
|
<h4 className={`font-bold text-sm truncate pr-6 ${currentRecordId === rec.id ? 'text-emerald-900' : 'text-slate-700'}`}>
|
|
{rec.title}
|
|
</h4>
|
|
</div>
|
|
<div className="flex justify-between items-center mt-1">
|
|
<span className="text-[10px] text-slate-400">
|
|
{new Date(rec.timestamp).toLocaleDateString()} {new Date(rec.timestamp).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-slate-500 line-clamp-1 mt-1">{rec.topic}</p>
|
|
</div>
|
|
|
|
{/* Delete Button */}
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onDeleteHistoryItem(rec.id); }}
|
|
className="absolute bottom-2 right-2 p-1.5 text-slate-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// --- MAIN LAYOUT ---
|
|
return (
|
|
<div className="h-full flex bg-slate-50 relative overflow-hidden">
|
|
|
|
{/* Left Main Content */}
|
|
<div className="flex-1 flex flex-col h-full min-w-0 relative transition-all">
|
|
|
|
{/* Setup Mode */}
|
|
{!lesson && (
|
|
<div className="flex flex-col h-full">
|
|
{/* Sticky Header for Setup */}
|
|
<div className="flex items-center justify-end px-4 py-3 bg-slate-50/90 backdrop-blur z-20 sticky top-0">
|
|
<button
|
|
onClick={() => setIsHistoryOpen(!isHistoryOpen)}
|
|
className={`p-2 rounded-lg border transition-colors flex items-center gap-2 text-sm font-medium ${
|
|
isHistoryOpen
|
|
? 'bg-emerald-50 text-emerald-600 border-emerald-200'
|
|
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50'
|
|
}`}
|
|
>
|
|
<History size={18} />
|
|
<span className="hidden sm:inline">{t.historyTitle}</span>
|
|
{isHistoryOpen ? <PanelRightClose size={16} className="opacity-50" /> : <PanelRightOpen size={16} className="opacity-50" />}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 flex flex-col items-center justify-center p-6 overflow-y-auto">
|
|
<div className="max-w-2xl w-full bg-white p-8 rounded-3xl shadow-sm border border-slate-100 animate-scale-in relative">
|
|
<div className="text-center mb-10">
|
|
<div className="w-16 h-16 bg-emerald-100 text-emerald-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-inner animate-pulse">
|
|
<BookOpen size={32} />
|
|
</div>
|
|
<h2 className="text-3xl font-extrabold text-slate-800">{t.title}</h2>
|
|
<p className="text-slate-500 mt-2">{t.subtitle}</p>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
<div className="animate-fade-in-up delay-100">
|
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">{t.topicLabel}</label>
|
|
<input
|
|
type="text"
|
|
value={topic}
|
|
onChange={(e) => setTopic(e.target.value)}
|
|
placeholder={t.placeholder}
|
|
className="w-full p-4 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 focus:border-transparent outline-none font-medium transition-all"
|
|
/>
|
|
</div>
|
|
|
|
<div className="animate-fade-in-up delay-200">
|
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">{t.difficultyLabel}</label>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
{([ReadingDifficulty.BEGINNER, ReadingDifficulty.INTERMEDIATE, ReadingDifficulty.ADVANCED] as ReadingDifficulty[]).map((lvl) => (
|
|
<button
|
|
key={lvl}
|
|
onClick={() => setDifficulty(lvl)}
|
|
className={`p-3 rounded-xl border text-sm font-bold transition-all transform active:scale-95 ${
|
|
difficulty === lvl
|
|
? 'bg-emerald-50 border-emerald-500 text-emerald-700 shadow-sm ring-1 ring-emerald-500'
|
|
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
|
|
}`}
|
|
>
|
|
{t.levels[lvl]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={generateLesson}
|
|
disabled={!topic.trim() || isGenerating}
|
|
className="w-full py-4 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl font-bold shadow-lg shadow-emerald-200 transition-all disabled:opacity-50 flex items-center justify-center gap-2 mt-4 animate-fade-in-up delay-300 transform active:scale-95"
|
|
>
|
|
{isGenerating ? <Loader2 className="animate-spin" /> : <BookOpen size={20} />}
|
|
{isGenerating ? t.generating : t.generate}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Lesson Mode */}
|
|
{lesson && (
|
|
<div className="flex flex-col lg:flex-row h-full overflow-hidden">
|
|
{/* Left: Content */}
|
|
<div className={`flex-1 flex-col h-full overflow-hidden bg-white relative z-10 ${mobileTab === 'text' ? 'flex' : 'hidden lg:flex'}`}>
|
|
<div className="p-4 border-b border-slate-100 flex items-center justify-between bg-white/80 backdrop-blur z-10">
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
<button onClick={() => { setLesson(null); setCurrentRecordId(null); }} className="text-slate-400 hover:text-slate-600 p-2 hover:scale-110 transition-transform">
|
|
<ChevronLeft size={24} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 flex-1 justify-center min-w-0">
|
|
<h3 className="font-bold text-slate-700 truncate max-w-[100px] sm:max-w-md hidden sm:block">{lesson.title}</h3>
|
|
|
|
{/* Mobile Tab Switcher */}
|
|
<div className="lg:hidden flex bg-slate-100 rounded-lg p-1 mx-2">
|
|
<button
|
|
onClick={() => setMobileTab('text')}
|
|
className={`px-3 py-1 rounded-md text-xs font-bold transition-all ${mobileTab === 'text' ? 'bg-white text-emerald-600 shadow-sm' : 'text-slate-500'}`}
|
|
>
|
|
<FileText size={14} className="inline mr-1" /> {tCommon.text}
|
|
</button>
|
|
<button
|
|
onClick={() => setMobileTab('tutor')}
|
|
className={`px-3 py-1 rounded-md text-xs font-bold transition-all ${mobileTab === 'tutor' ? 'bg-white text-emerald-600 shadow-sm' : 'text-slate-500'}`}
|
|
>
|
|
<MessageCircle size={14} className="inline mr-1" /> {tCommon.tutor}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
<button
|
|
onClick={toggleTTS}
|
|
disabled={isTTSLoading || !lesson.japaneseContent}
|
|
className={`hidden sm:flex items-center gap-2 text-xs font-bold px-3 py-1.5 rounded-full transition-colors ${
|
|
isPlayingTTS
|
|
? 'bg-pink-100 text-pink-600 hover:bg-pink-200'
|
|
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
|
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
|
>
|
|
{isTTSLoading ? <Loader2 size={14} className="animate-spin" /> : isPlayingTTS ? <Square size={14} fill="currentColor" /> : <Volume2 size={14} />}
|
|
{isPlayingTTS ? t.stopAudio : t.playAudio}
|
|
</button>
|
|
|
|
{/* Download Button - Now visible on mobile (icon only) */}
|
|
<button
|
|
onClick={downloadTTS}
|
|
disabled={isTTSLoading || !lesson.japaneseContent}
|
|
className={`flex items-center justify-center w-8 h-8 sm:w-auto sm:px-3 sm:py-1.5 rounded-full transition-colors bg-slate-100 hover:bg-slate-200 text-slate-600 disabled:opacity-50 disabled:cursor-not-allowed`}
|
|
title="Download Audio"
|
|
>
|
|
<Download size={14} />
|
|
</button>
|
|
|
|
{/* Mobile Icon only for TTS Play */}
|
|
<button
|
|
onClick={toggleTTS}
|
|
disabled={isTTSLoading || !lesson.japaneseContent}
|
|
className={`sm:hidden flex items-center justify-center w-8 h-8 rounded-full transition-colors ${
|
|
isPlayingTTS
|
|
? 'bg-pink-100 text-pink-600'
|
|
: 'bg-slate-100 text-slate-600'
|
|
} disabled:opacity-50`}
|
|
>
|
|
{isTTSLoading ? <Loader2 size={14} className="animate-spin" /> : isPlayingTTS ? <Square size={14} fill="currentColor" /> : <Volume2 size={14} />}
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => setShowTranslation(!showTranslation)}
|
|
disabled={!lesson.translation}
|
|
className="flex items-center gap-2 text-xs font-bold px-3 py-1.5 bg-slate-100 hover:bg-slate-200 rounded-full transition-colors text-slate-600 disabled:opacity-50"
|
|
>
|
|
{showTranslation ? <ToggleRight className="text-emerald-600" /> : <ToggleLeft />}
|
|
<span className="hidden sm:inline">{t.translationLabel}</span>
|
|
</button>
|
|
|
|
{/* Copy Content Button - New */}
|
|
<CopyButton text={lesson.japaneseContent || ''} label={tCommon.copy} />
|
|
|
|
{/* Sidebar Toggle In Lesson View */}
|
|
<button
|
|
onClick={() => setIsHistoryOpen(!isHistoryOpen)}
|
|
className={`p-2 rounded-lg border transition-colors flex items-center gap-2 text-sm font-medium ${
|
|
isHistoryOpen
|
|
? 'bg-emerald-50 text-emerald-600 border-emerald-200'
|
|
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50'
|
|
}`}
|
|
>
|
|
{isHistoryOpen ? <PanelRightClose size={18} /> : <PanelRightOpen size={18} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-6 md:p-10">
|
|
<div className="max-w-3xl mx-auto">
|
|
<div className="mb-12 animate-fade-in-up delay-100">
|
|
<p className="text-xl md:text-3xl leading-loose font-serif text-slate-800 whitespace-pre-wrap">
|
|
{lesson.japaneseContent || <span className="text-red-400 italic text-base">{t.contentMissing}</span>}
|
|
</p>
|
|
</div>
|
|
{showTranslation && (
|
|
<div className="mb-12 p-6 bg-slate-50 rounded-2xl border border-slate-200 animate-scale-in relative">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h4 className="text-xs font-bold text-slate-400 uppercase">{t.translationLabel}</h4>
|
|
<CopyButton text={lesson.translation || ''} label={tCommon.copy} />
|
|
</div>
|
|
<p className="text-lg leading-relaxed text-slate-600">{lesson.translation || <span className="text-slate-400 italic">{t.translationMissing}</span>}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Vocabulary Section */}
|
|
<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">
|
|
<List size={18} /> {t.vocabTitle}
|
|
</h4>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{lesson.vocabulary?.map((v, i) => (
|
|
<div key={i} className="bg-emerald-50 p-3 rounded-xl border border-emerald-100 flex flex-col group relative">
|
|
<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-emerald-300 hover:bg-emerald-100 hover:text-emerald-600'}`}
|
|
>
|
|
{playingVocabWord === v.word ? <Loader2 size={14} className="animate-spin" /> : <Volume2 size={14} />}
|
|
</button>
|
|
</div>
|
|
<p className="text-sm text-emerald-700 font-medium">{v.meaning}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Grammar Section */}
|
|
{lesson.grammarPoints && lesson.grammarPoints.length > 0 && (
|
|
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm animate-fade-in-up delay-400 mt-6">
|
|
<h4 className="text-sm font-bold text-emerald-800 mb-4 flex items-center gap-2">
|
|
<PenTool size={18} /> {t.grammarHeader}
|
|
</h4>
|
|
<div className="space-y-4">
|
|
{lesson.grammarPoints.map((g, i) => (
|
|
<div key={i} className="bg-emerald-50/50 p-4 rounded-xl border border-emerald-100">
|
|
<h5 className="font-bold text-emerald-900 mb-1">{g.point}</h5>
|
|
<p className="text-sm text-emerald-700 leading-relaxed">{g.explanation}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right: Tutor Chat (Only visible in lesson mode) */}
|
|
<div className={`w-full lg:w-96 bg-slate-50 flex-col h-full shadow-inner border-l border-slate-200 z-20 ${mobileTab === 'tutor' ? 'flex' : 'hidden lg:flex'}`}>
|
|
{/* Chat Header */}
|
|
<div className="p-3 lg:p-4 bg-white border-b border-slate-200 flex items-center gap-2 shadow-sm">
|
|
{/* Mobile back button for consistency, though tab works too */}
|
|
<button onClick={() => setMobileTab('text')} className="lg:hidden mr-2 text-slate-400"><ChevronLeft size={20} /></button>
|
|
<HelpCircle className="text-emerald-500 animate-pulse" size={20} />
|
|
<span className="font-bold text-slate-700">{t.qaTitle}</span>
|
|
</div>
|
|
|
|
{/* Chat Messages */}
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-slate-50/50">
|
|
{chatMessages.map(msg => (
|
|
<ChatBubble key={msg.id} message={msg} language={language} />
|
|
))}
|
|
{isChatLoading && (
|
|
<div className="flex items-center gap-2 text-xs text-slate-400 px-2 animate-pulse">
|
|
<Loader2 size={14} className="animate-spin" /> {t.thinking}
|
|
</div>
|
|
)}
|
|
<div ref={chatEndRef} />
|
|
</div>
|
|
|
|
{/* Chat Input */}
|
|
<div className="p-3 bg-white border-t border-slate-200 pb-[env(safe-area-inset-bottom)]">
|
|
<div className="flex items-center gap-2 bg-slate-100 rounded-full px-2 py-1 border border-slate-200 focus-within:ring-2 focus-within:ring-emerald-500 focus-within:bg-white transition-all">
|
|
<input
|
|
className="flex-1 bg-transparent border-none focus:ring-0 text-sm px-3 py-2 outline-none text-slate-700 placeholder:text-slate-400"
|
|
placeholder={t.qaPlaceholder}
|
|
value={chatInput}
|
|
onChange={(e) => setChatInput(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleAskTutor()}
|
|
/>
|
|
<button
|
|
onClick={handleAskTutor}
|
|
disabled={!chatInput.trim() || isChatLoading}
|
|
className="p-2 bg-emerald-500 text-white rounded-full hover:bg-emerald-600 disabled:opacity-50 disabled:cursor-not-allowed transform active:scale-95 transition-transform"
|
|
>
|
|
{isChatLoading ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Sidebar History (Desktop - Collapsible) */}
|
|
<div className={`
|
|
hidden md:block h-full bg-white border-l border-slate-200 transition-all duration-300 ease-in-out overflow-hidden z-30
|
|
${isHistoryOpen ? 'w-80 opacity-100' : 'w-0 opacity-0 border-none'}
|
|
`}>
|
|
<HistoryContent />
|
|
</div>
|
|
|
|
{/* Mobile Drawer */}
|
|
{isHistoryOpen && (
|
|
<div className="fixed inset-0 z-50 md:hidden flex justify-end">
|
|
<div className="absolute inset-0 bg-slate-900/30 backdrop-blur-sm transition-opacity" onClick={() => setIsHistoryOpen(false)} />
|
|
<div className="relative w-[85%] max-w-sm bg-white h-full shadow-2xl animate-slide-in-right z-50 flex flex-col">
|
|
<HistoryContent />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ReadingView;
|