451 lines
21 KiB
TypeScript
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} → {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; |