Files
ai-app-skr/views/TranslationView.tsx
2025-11-21 00:24:18 +08:00

451 lines
21 KiB
TypeScript

import React, { useState, useRef, useEffect } from 'react';
import { Language, TranslationRecord } from '../types';
import { geminiService, decodeAudioData } from '../services/geminiService';
import { processAndDownloadAudio } from '../utils/audioUtils';
import { translations } from '../utils/localization';
import { ArrowRightLeft, Copy, Languages, Sparkles, Loader2, Trash2, Camera, Image as ImageIcon, History, X, PanelRightClose, PanelRightOpen, Volume2, Square, Download } from 'lucide-react';
interface TranslationViewProps {
language: Language;
history: TranslationRecord[];
addToHistory: (record: TranslationRecord) => void;
clearHistory: () => void;
onDeleteHistoryItem: (id: string) => void;
}
const TranslationView: React.FC<TranslationViewProps> = ({ language, history, addToHistory, clearHistory, onDeleteHistoryItem }) => {
const t = translations[language].translation;
const [inputText, setInputText] = useState('');
const [outputText, setOutputText] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [loadingStatus, setLoadingStatus] = useState('');
const [sourceLang, setSourceLang] = useState('Auto');
const [targetLang, setTargetLang] = useState('Japanese');
// Audio State
const [playingId, setPlayingId] = useState<'input' | 'output' | null>(null);
const [downloadingId, setDownloadingId] = useState<'input' | 'output' | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const audioSourceRef = useRef<AudioBufferSourceNode | null>(null);
// Sidebar State - Default Closed
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const cameraInputRef = useRef<HTMLInputElement>(null);
const LANG_OPTIONS = [
{ value: 'Auto', label: t.langs.auto },
{ value: 'English', label: t.langs.en },
{ value: 'Japanese', label: t.langs.ja },
{ value: 'Chinese', label: t.langs.zh },
{ value: 'Korean', label: t.langs.ko },
{ value: 'French', label: t.langs.fr },
{ value: 'Spanish', label: t.langs.es },
];
const TARGET_OPTIONS = LANG_OPTIONS.filter(o => o.value !== 'Auto');
// Cleanup audio
useEffect(() => {
return () => stopAudio();
}, []);
const stopAudio = () => {
if (audioSourceRef.current) {
audioSourceRef.current.stop();
audioSourceRef.current = null;
}
setPlayingId(null);
};
const playAudio = async (text: string, type: 'input' | 'output') => {
if (!text.trim()) return;
if (playingId === type) {
stopAudio();
return;
}
if (playingId) stopAudio();
setPlayingId(type);
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 = () => setPlayingId(null);
source.start();
audioSourceRef.current = source;
} else {
setPlayingId(null);
}
} catch (e) {
console.error(e);
setPlayingId(null);
}
};
const handleDownload = async (text: string, type: 'input' | 'output') => {
if (!text.trim()) return;
setDownloadingId(type);
try {
const audioBase64 = await geminiService.generateSpeech(text);
if (audioBase64) {
processAndDownloadAudio(audioBase64, `translation_${type}_${Date.now()}.wav`);
}
} catch (e) {
console.error(e);
} finally {
setDownloadingId(null);
}
};
const handleTranslate = async () => {
if (!inputText.trim()) return;
setIsLoading(true);
setLoadingStatus(t.translating);
try {
const result = await geminiService.translateText(inputText, targetLang, sourceLang);
setOutputText(result);
addToHistory({
id: Date.now().toString(),
sourceText: inputText,
targetText: result,
sourceLang: sourceLang === 'Auto' ? 'Detected' : sourceLang,
targetLang: targetLang,
timestamp: Date.now()
});
} catch (e) {
console.error(e);
setOutputText(t.errorTranslating);
} finally {
setIsLoading(false);
}
};
const handleImageSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsLoading(true);
setLoadingStatus(t.extracting);
const reader = new FileReader();
reader.onloadend = async () => {
const base64 = reader.result as string;
try {
const result = await geminiService.translateImage(base64, targetLang, sourceLang);
if (result) {
setInputText(result.original);
setOutputText(result.translated);
addToHistory({
id: Date.now().toString(),
sourceText: result.original,
targetText: result.translated,
sourceLang: sourceLang === 'Auto' ? 'Detected (Image)' : sourceLang,
targetLang: targetLang,
timestamp: Date.now()
});
} else {
alert(t.imageReadError);
}
} catch (err) {
console.error(err);
alert(t.imageTransError);
} finally {
setIsLoading(false);
}
};
reader.readAsDataURL(file);
};
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
};
const handleSwap = () => {
if (sourceLang === 'Auto') return;
setSourceLang(targetLang);
setTargetLang(sourceLang);
setInputText(outputText);
setOutputText(inputText);
};
const HistoryContent = () => (
<div className="flex flex-col h-full bg-white">
<div className="p-4 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
<h3 className="font-bold text-slate-700 flex items-center gap-2">
<Languages size={16} /> {t.history}
</h3>
<div className="flex items-center gap-3">
{history.length > 0 && (
<button onClick={clearHistory} className="text-xs text-red-400 hover:text-red-600 hover:underline">{t.clear}</button>
)}
<button onClick={() => setIsHistoryOpen(false)} className="md:hidden text-slate-400 hover:text-slate-600">
<X size={20} />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{history.length === 0 && (
<div className="text-center text-slate-400 text-sm mt-10">{t.history}</div>
)}
{history.slice().reverse().map((rec) => (
<div
key={rec.id}
className="group flex items-start gap-3 p-3 rounded-xl bg-slate-50 border border-slate-100 hover:bg-white hover:shadow-md transition-all cursor-pointer relative"
onClick={() => { setInputText(rec.sourceText); setOutputText(rec.targetText); if(window.innerWidth < 768) setIsHistoryOpen(false); }}
>
{/* Icon */}
<div className="w-10 h-10 rounded-xl bg-indigo-100 flex-shrink-0 flex items-center justify-center text-indigo-600 shadow-inner">
<Languages size={20} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex justify-between items-start">
<h4 className="font-bold text-sm text-slate-700 truncate pr-6">
{rec.sourceLang} &rarr; {rec.targetLang}
</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.sourceText}</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>
);
return (
<div className="h-full flex bg-slate-50 relative overflow-hidden">
{/* Main Translation Area */}
<div className="flex-1 flex flex-col h-full min-w-0 relative">
{/* Sticky Header / Toolbar */}
<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'
}`}
title={t.history}
>
<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 overflow-y-auto px-4 pb-6 md:px-10 md:pb-10">
<div className="max-w-5xl mx-auto w-full space-y-6">
{/* Title Header */}
<div className="flex items-center gap-3 mb-2 animate-fade-in-up">
<div className="w-10 h-10 rounded-xl bg-indigo-100 text-indigo-600 flex items-center justify-center shadow-sm">
<Languages size={20} />
</div>
<h2 className="text-2xl font-extrabold text-slate-800">{t.title}</h2>
</div>
{/* Controls */}
<div className="bg-white p-2 rounded-2xl shadow-sm border border-slate-200 flex flex-col md:flex-row items-center justify-between gap-2 animate-scale-in">
<select
value={sourceLang}
onChange={(e) => setSourceLang(e.target.value)}
className="p-3 rounded-xl bg-slate-50 border-transparent focus:bg-white focus:ring-2 focus:ring-indigo-500 font-bold text-slate-600 w-full md:w-auto outline-none"
>
{LANG_OPTIONS.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
</select>
<button onClick={handleSwap} className="p-2 hover:bg-slate-100 rounded-full text-slate-400 hover:text-indigo-500 transition-colors transform hover:rotate-180 duration-300">
<ArrowRightLeft size={20} />
</button>
<select
value={targetLang}
onChange={(e) => setTargetLang(e.target.value)}
className="p-3 rounded-xl bg-slate-50 border-transparent focus:bg-white focus:ring-2 focus:ring-indigo-500 font-bold text-indigo-600 w-full md:w-auto outline-none"
>
{TARGET_OPTIONS.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
</select>
</div>
{/* Input/Output Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 min-h-[300px]">
{/* Source */}
<div className="bg-white rounded-3xl shadow-sm border border-slate-200 flex flex-col p-6 relative group focus-within:ring-2 focus-within:ring-indigo-500/50 transition-all animate-fade-in-up delay-100">
<textarea
value={inputText}
onChange={(e) => setInputText(e.target.value)}
className="flex-1 w-full resize-none outline-none text-lg text-slate-700 placeholder:text-slate-300 bg-transparent"
placeholder={t.inputLabel}
/>
<div className="flex items-center justify-between pt-2 border-t border-slate-100 mt-2">
<div className="flex items-center gap-2">
{/* Camera Button */}
<button
onClick={() => cameraInputRef.current?.click()}
className="p-2 hover:bg-slate-100 rounded-lg text-slate-500 flex items-center gap-2 text-xs font-bold transition-colors"
title={t.scanImage}
>
<Camera size={18} />
<span className="hidden sm:inline">{t.scanImage}</span>
</button>
<input
type="file"
ref={cameraInputRef}
className="hidden"
accept="image/*"
capture="environment"
onChange={handleImageSelect}
/>
{/* Upload Button */}
<button
onClick={() => fileInputRef.current?.click()}
className="p-2 hover:bg-slate-100 rounded-lg text-slate-500 flex items-center gap-2 text-xs font-bold transition-colors"
title={t.uploadImage}
>
<ImageIcon size={18} />
<span className="hidden sm:inline">{t.uploadImage}</span>
</button>
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleImageSelect}
/>
{/* Play/Download Buttons */}
{inputText && (
<>
<button
onClick={() => playAudio(inputText, 'input')}
className={`p-2 rounded-lg flex items-center gap-2 text-xs font-bold transition-colors ${playingId === 'input' ? 'bg-pink-100 text-pink-500' : 'hover:bg-slate-100 text-slate-500'}`}
title="Play Audio"
>
{playingId === 'input' ? <Square size={18} fill="currentColor" /> : <Volume2 size={18} />}
</button>
<button
onClick={() => handleDownload(inputText, 'input')}
className={`p-2 rounded-lg flex items-center gap-2 text-xs font-bold transition-colors ${downloadingId === 'input' ? 'bg-slate-100 text-slate-500' : 'hover:bg-slate-100 text-slate-500'}`}
title="Download Audio"
disabled={!!downloadingId}
>
{downloadingId === 'input' ? <Loader2 size={18} className="animate-spin" /> : <Download size={18} />}
</button>
</>
)}
</div>
{inputText && (
<button onClick={() => setInputText('')} className="p-2 hover:bg-red-50 rounded-full text-slate-300 hover:text-red-500 transition-colors" title={t.clear}>
<Trash2 size={18} />
</button>
)}
</div>
</div>
{/* Target */}
<div className="bg-slate-100/50 rounded-3xl shadow-inner border border-slate-200 flex flex-col p-6 relative animate-fade-in-up delay-200">
{isLoading ? (
<div className="flex-1 flex items-center justify-center text-slate-400 gap-2 animate-pulse">
<Loader2 className="animate-spin" /> {loadingStatus}
</div>
) : (
<div className="flex-1 text-lg text-indigo-900 font-medium whitespace-pre-wrap leading-relaxed">
{outputText}
</div>
)}
{outputText && !isLoading && (
<div className="flex items-center justify-end gap-2 mt-4 pt-2 border-t border-slate-200/50">
<button
onClick={() => playAudio(outputText, 'output')}
className={`p-2 rounded-lg flex items-center gap-2 text-xs font-bold transition-colors ${playingId === 'output' ? 'bg-pink-100 text-pink-500' : 'bg-white hover:bg-indigo-50 text-indigo-500 shadow-sm'}`}
title="Play Audio"
>
{playingId === 'output' ? <Square size={18} fill="currentColor" /> : <Volume2 size={18} />}
</button>
<button
onClick={() => handleDownload(outputText, 'output')}
className={`p-2 rounded-lg flex items-center gap-2 text-xs font-bold transition-colors ${downloadingId === 'output' ? 'bg-indigo-50 text-indigo-500' : 'bg-white hover:bg-indigo-50 text-indigo-500 shadow-sm'}`}
title="Download Audio"
disabled={!!downloadingId}
>
{downloadingId === 'output' ? <Loader2 size={18} className="animate-spin" /> : <Download size={18} />}
</button>
<button onClick={() => handleCopy(outputText)} className="p-2 bg-white hover:bg-indigo-50 shadow-sm rounded-lg text-indigo-500 transition-colors" title={t.copy}>
<Copy size={18} />
</button>
</div>
)}
</div>
</div>
<button
onClick={handleTranslate}
disabled={isLoading || !inputText.trim()}
className="w-full py-4 bg-indigo-600 hover:bg-indigo-700 text-white rounded-2xl font-bold shadow-lg shadow-indigo-200 transform active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 animate-fade-in-up delay-300"
>
<Sparkles size={20} /> {t.translateBtn}
</button>
</div>
</div>
</div>
{/* Sidebar History (Desktop) */}
<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 flex-col
${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 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 TranslationView;