Files
pdf-translation-interface/components/pdf-translator.tsx
aken1023 39a4788cc4 Add PDF translation API, utilities, docs, and config
Introduces core backend and frontend infrastructure for a PDF translation interface. Adds API endpoints for translation, PDF testing, and AI provider testing; implements PDF text extraction, cost tracking, and pricing logic in the lib directory; adds reusable UI components; and provides comprehensive documentation (SDD, environment setup, Claude instructions). Updates Tailwind and global styles, and includes a sample test PDF and configuration files.
2025-10-15 23:34:44 +08:00

771 lines
35 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import type React from "react"
import { useState, useEffect } from "react"
import { Upload, FileText, Languages, Loader2, Download, FileDown, DollarSign, Hash, TrendingUp, Play, Pause, Square, Volume2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import { costTracker } from "@/lib/cost-tracker"
import type { CostSummary } from "@/lib/cost-tracker"
const LANGUAGES = [
{ code: "auto", name: "自動偵測" },
{ code: "zh-TW", name: "繁體中文" },
{ code: "zh-CN", name: "簡體中文" },
{ code: "en", name: "English" },
{ code: "ja", name: "日本語" },
{ code: "ko", name: "한국어" },
{ code: "es", name: "Español" },
{ code: "fr", name: "Français" },
{ code: "de", name: "Deutsch" },
{ code: "it", name: "Italiano" },
{ code: "pt", name: "Português" },
{ code: "ru", name: "Русский" },
{ code: "ar", name: "العربية" },
{ code: "hi", name: "हिन्दी" },
{ code: "th", name: "ไทย" },
{ code: "vi", name: "Tiếng Việt" },
]
export function PDFTranslator() {
const [file, setFile] = useState<File | null>(null)
const [sourceLanguage, setSourceLanguage] = useState<string>("auto")
const [targetLanguage, setTargetLanguage] = useState<string>("")
const [isTranslating, setIsTranslating] = useState(false)
const [translatedText, setTranslatedText] = useState<string>("")
const [translatedPDFBase64, setTranslatedPDFBase64] = useState<string>("")
const [isDragging, setIsDragging] = useState(false)
const [generatePDF, setGeneratePDF] = useState(true)
const [tokenUsage, setTokenUsage] = useState<any>(null)
const [cost, setCost] = useState<any>(null)
const [model, setModel] = useState<any>(null)
const [costSummary, setCostSummary] = useState<CostSummary | null>(null)
// Speech synthesis states
const [isPlaying, setIsPlaying] = useState(false)
const [isPaused, setIsPaused] = useState(false)
const [speechSupported, setSpeechSupported] = useState(false)
const [selectedVoice, setSelectedVoice] = useState<string>("")
const [availableVoices, setAvailableVoices] = useState<SpeechSynthesisVoice[]>([])
const [speechRate, setSpeechRate] = useState(1.0)
const [speechVolume, setSpeechVolume] = useState(1.0)
// Load cost summary on component mount
useEffect(() => {
const summary = costTracker.getCostSummary()
setCostSummary(summary)
}, [])
// Initialize speech synthesis
useEffect(() => {
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
setSpeechSupported(true)
// Load available voices
const loadVoices = () => {
const voices = speechSynthesis.getVoices()
setAvailableVoices(voices)
// Auto-select voice based on target language
if (targetLanguage && voices.length > 0) {
const preferredVoice = findPreferredVoice(voices, targetLanguage)
if (preferredVoice) {
setSelectedVoice(preferredVoice.name)
}
}
}
// Load voices immediately and on voiceschanged event
loadVoices()
speechSynthesis.addEventListener('voiceschanged', loadVoices)
return () => {
speechSynthesis.removeEventListener('voiceschanged', loadVoices)
speechSynthesis.cancel() // Cancel any ongoing speech
}
}
}, [targetLanguage])
// Helper function to find preferred voice for a language
const findPreferredVoice = (voices: SpeechSynthesisVoice[], langCode: string) => {
// Map language codes to speech synthesis language codes
const langMap: Record<string, string[]> = {
'zh-TW': ['zh-TW', 'zh-HK', 'zh'],
'zh-CN': ['zh-CN', 'zh'],
'en': ['en-US', 'en-GB', 'en'],
'ja': ['ja-JP', 'ja'],
'ko': ['ko-KR', 'ko'],
'es': ['es-ES', 'es-US', 'es'],
'fr': ['fr-FR', 'fr'],
'de': ['de-DE', 'de'],
'it': ['it-IT', 'it'],
'pt': ['pt-BR', 'pt-PT', 'pt'],
'ru': ['ru-RU', 'ru'],
'ar': ['ar-SA', 'ar'],
'hi': ['hi-IN', 'hi'],
'th': ['th-TH', 'th'],
'vi': ['vi-VN', 'vi']
}
const targetLangs = langMap[langCode] || [langCode]
for (const targetLang of targetLangs) {
const voice = voices.find(v => v.lang.startsWith(targetLang))
if (voice) return voice
}
return voices[0] // Fallback to first available voice
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0]
if (selectedFile) {
const isPDF = selectedFile.type === "application/pdf"
if (isPDF) {
setFile(selectedFile)
setTranslatedText("")
setTranslatedPDFBase64("")
setTokenUsage(null)
setCost(null)
setModel(null)
} else {
alert("目前僅支援 PDF 文件")
}
}
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}
const handleDragLeave = () => {
setIsDragging(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const droppedFile = e.dataTransfer.files[0]
if (droppedFile) {
const isPDF = droppedFile.type === "application/pdf"
if (isPDF) {
setFile(droppedFile)
setTranslatedText("")
setTranslatedPDFBase64("")
setTokenUsage(null)
setCost(null)
setModel(null)
} else {
alert("目前僅支援 PDF 文件")
}
}
}
const handleTranslate = async () => {
if (!file || !targetLanguage) {
alert("請選擇檔案和目標語言")
return
}
setIsTranslating(true)
setTranslatedText("")
setTranslatedPDFBase64("")
setTokenUsage(null)
setCost(null)
setModel(null)
try {
const formData = new FormData()
formData.append("file", file)
formData.append("sourceLanguage", sourceLanguage)
formData.append("targetLanguage", targetLanguage)
formData.append("returnPDF", generatePDF.toString())
const response = await fetch("/api/translate", {
method: "POST",
body: formData,
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || "翻譯失敗")
}
setTranslatedText(data.translatedText)
if (data.pdfBase64) {
setTranslatedPDFBase64(data.pdfBase64)
}
// Set token usage and cost information
if (data.tokenUsage) {
setTokenUsage(data.tokenUsage)
}
if (data.cost) {
setCost(data.cost)
}
if (data.model) {
setModel(data.model)
}
// Track cost in accumulator
if (data.costSession) {
const updatedSummary = costTracker.addCostSession(data.costSession)
setCostSummary(updatedSummary)
}
} catch (error) {
console.error("Translation error:", error)
alert(error instanceof Error ? error.message : "翻譯過程中發生錯誤")
} finally {
setIsTranslating(false)
}
}
const downloadTranslatedText = () => {
if (!translatedText) return
const blob = new Blob([translatedText], { type: "text/plain;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `translated_${file?.name?.replace(".pdf", ".txt") || "document.txt"}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
// Speech synthesis functions
const playText = () => {
if (!speechSupported || !translatedText) return
// If paused, resume
if (isPaused) {
speechSynthesis.resume()
setIsPaused(false)
setIsPlaying(true)
return
}
// If already playing, do nothing
if (isPlaying) return
// Cancel any existing speech
speechSynthesis.cancel()
const utterance = new SpeechSynthesisUtterance(translatedText)
// Configure voice
if (selectedVoice) {
const voice = availableVoices.find(v => v.name === selectedVoice)
if (voice) {
utterance.voice = voice
}
}
// Configure speech parameters
utterance.rate = speechRate
utterance.volume = speechVolume
utterance.pitch = 1.0
// Event handlers
utterance.onstart = () => {
setIsPlaying(true)
setIsPaused(false)
}
utterance.onend = () => {
setIsPlaying(false)
setIsPaused(false)
}
utterance.onerror = (event) => {
console.error('Speech synthesis error:', event.error)
setIsPlaying(false)
setIsPaused(false)
}
utterance.onpause = () => {
setIsPaused(true)
setIsPlaying(false)
}
utterance.onresume = () => {
setIsPaused(false)
setIsPlaying(true)
}
speechSynthesis.speak(utterance)
}
const pauseText = () => {
if (speechSupported && isPlaying) {
speechSynthesis.pause()
}
}
const stopText = () => {
if (speechSupported) {
speechSynthesis.cancel()
setIsPlaying(false)
setIsPaused(false)
}
}
const downloadTranslatedPDF = () => {
if (!translatedPDFBase64) return
// Convert base64 to blob
const byteCharacters = atob(translatedPDFBase64)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
const blob = new Blob([byteArray], { type: "application/pdf" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `translated_${file?.name || "document.pdf"}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
const resetCostTracking = () => {
if (confirm("確定要重置費用追蹤記錄嗎?這個操作無法復原。")) {
const resetSummary = costTracker.resetCostTracking()
setCostSummary(resetSummary)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-amber-50 via-green-50 to-teal-50 dark:from-slate-800 dark:via-slate-900 dark:to-slate-800">
{/* 文清楓背景裝飾 */}
<div className="absolute inset-0 opacity-10">
<div className="absolute top-20 left-10 w-32 h-32 bg-amber-200 rounded-full blur-xl"></div>
<div className="absolute top-40 right-20 w-40 h-40 bg-green-200 rounded-full blur-xl"></div>
<div className="absolute bottom-32 left-1/4 w-36 h-36 bg-teal-200 rounded-full blur-xl"></div>
<div className="absolute bottom-20 right-10 w-28 h-28 bg-amber-300 rounded-full blur-xl"></div>
</div>
<div className="relative container mx-auto p-4 sm:p-6 max-w-7xl">
<div className="text-center mb-8 sm:mb-12">
<div className="mb-4 sm:mb-6">
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-bold mb-3 sm:mb-4 bg-clip-text text-transparent bg-gradient-to-r from-amber-600 via-green-600 to-teal-600">
</h1>
<div className="flex items-center justify-center gap-2 mb-2 flex-wrap">
<span className="text-xl sm:text-2xl">🍃</span>
<span className="text-base sm:text-lg lg:text-xl text-amber-600 font-medium text-center"> · · </span>
<span className="text-xl sm:text-2xl">🍂</span>
</div>
</div>
<p className="text-amber-700 dark:text-amber-300 text-base sm:text-lg font-medium leading-relaxed px-4">
<br />
<span className="text-green-600 dark:text-green-400 text-sm sm:text-base">
PDF
</span>
</p>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6 lg:gap-8">
{/* Upload Section */}
<Card className="p-4 sm:p-6 lg:p-8 shadow-xl border-amber-200 dark:border-amber-800 bg-gradient-to-br from-amber-50/80 via-white to-green-50/80 dark:from-slate-800/90 dark:via-slate-900/90 dark:to-slate-800/90 backdrop-blur-sm">
<h2 className="text-xl sm:text-2xl font-semibold mb-4 sm:mb-6 flex items-center gap-2 text-amber-800 dark:text-amber-300">
<Upload className="h-5 w-5 sm:h-6 sm:w-6 text-green-600 dark:text-green-400" />
📄
</h2>
<div
className={`border-2 sm:border-3 border-dashed rounded-xl p-6 sm:p-8 lg:p-12 text-center transition-all ${
isDragging
? "border-green-500 bg-green-50/50 dark:bg-green-950/50 shadow-lg"
: "border-amber-300 hover:border-green-400 hover:shadow-md"
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<FileText className="h-12 w-12 sm:h-16 sm:w-16 mx-auto mb-3 sm:mb-4 text-amber-400 dark:text-amber-500" />
<p className="text-amber-700 dark:text-amber-300 mb-3 sm:mb-4 font-medium text-sm sm:text-base">
🍃 PDF 🍃
</p>
<p className="text-xs sm:text-sm text-green-600 dark:text-green-400 mb-3 sm:mb-4">
PDF
</p>
<div className="flex gap-2 sm:gap-3 justify-center">
<input
type="file"
accept=".pdf"
onChange={handleFileChange}
className="hidden"
id="file-upload"
/>
<label htmlFor="file-upload">
<Button variant="outline" asChild className="border-amber-300 text-amber-700 hover:bg-amber-50 hover:border-amber-400 dark:border-amber-600 dark:text-amber-300 dark:hover:bg-amber-950/30 text-sm sm:text-base px-4 sm:px-6">
<span>
<Upload className="mr-1 sm:mr-2 h-4 w-4" />
📁 PDF
</span>
</Button>
</label>
</div>
</div>
{file && (
<div className="mt-4 sm:mt-6 p-3 sm:p-4 bg-gradient-to-r from-green-50 via-teal-50 to-green-50 dark:from-green-950/50 dark:via-teal-950/50 dark:to-green-950/50 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-center gap-2 text-green-700 dark:text-green-300 font-medium mb-2 text-sm sm:text-base">
{file.type.startsWith("image/") ? (
<Image className="h-4 w-4 flex-shrink-0" />
) : (
<FileText className="h-4 w-4 flex-shrink-0" />
)}
<span className="truncate"> : {file.name}</span>
</div>
<div className="text-xs sm:text-sm text-green-600 dark:text-green-400 space-y-1">
<p>📄 檔案類型: PDF </p>
<p>📏 : {(file.size / 1024 / 1024).toFixed(2)} MB</p>
<p>🔧 處理方式: 文字提取</p>
</div>
</div>
)}
<div className="mt-4 sm:mt-6 space-y-3 sm:space-y-4">
<div>
<Label htmlFor="source-language" className="text-amber-700 dark:text-amber-300 font-medium text-sm sm:text-base">🌍 </Label>
<Select value={sourceLanguage} onValueChange={setSourceLanguage}>
<SelectTrigger id="source-language" className="mt-2 border-amber-200 focus:border-green-400 dark:border-amber-700 dark:focus:border-green-500 h-10 sm:h-auto">
<SelectValue placeholder="選擇來源語言" />
</SelectTrigger>
<SelectContent>
{LANGUAGES.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="target-language" className="text-amber-700 dark:text-amber-300 font-medium text-sm sm:text-base">🎯 </Label>
<Select value={targetLanguage} onValueChange={setTargetLanguage}>
<SelectTrigger id="target-language" className="mt-2 border-amber-200 focus:border-green-400 dark:border-amber-700 dark:focus:border-green-500 h-10 sm:h-auto">
<SelectValue placeholder="選擇目標語言" />
</SelectTrigger>
<SelectContent>
{LANGUAGES.filter(lang => lang.code !== "auto").map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="generate-pdf"
checked={generatePDF}
onCheckedChange={(checked) => setGeneratePDF(checked as boolean)}
/>
<Label htmlFor="generate-pdf" className="cursor-pointer text-green-700 dark:text-green-300 font-medium text-sm sm:text-base">
📋 PDF
</Label>
</div>
<Button
onClick={handleTranslate}
disabled={!file || !targetLanguage || isTranslating}
className="w-full h-10 sm:h-12 text-base sm:text-lg bg-gradient-to-r from-amber-500 via-green-500 to-teal-500 hover:from-amber-600 hover:via-green-600 hover:to-teal-600 text-white font-semibold shadow-lg transition-all duration-300 transform hover:scale-105"
size="lg"
>
{isTranslating ? (
<>
<Loader2 className="mr-1 sm:mr-2 h-4 w-4 sm:h-5 sm:w-5 animate-spin" />
🔄 ...
</>
) : (
<>
<Languages className="mr-1 sm:mr-2 h-4 w-4 sm:h-5 sm:w-5" />
</>
)}
</Button>
</div>
</Card>
{/* Result Section */}
<Card className="p-4 sm:p-6 lg:p-8 shadow-xl border-teal-200 dark:border-teal-800 bg-gradient-to-br from-teal-50/80 via-white to-green-50/80 dark:from-slate-800/90 dark:via-slate-900/90 dark:to-slate-800/90 backdrop-blur-sm">
<h2 className="text-xl sm:text-2xl font-semibold mb-4 sm:mb-6 flex items-center gap-2 text-teal-800 dark:text-teal-300">
<FileText className="h-5 w-5 sm:h-6 sm:w-6 text-amber-600 dark:text-amber-400" />
🌿
</h2>
{translatedText ? (
<div className="space-y-3 sm:space-y-4">
{/* Token Usage and Cost Information */}
{tokenUsage && cost && model && (
<div className="bg-gradient-to-r from-amber-50 via-green-50 to-teal-50 dark:from-amber-950/30 dark:via-green-950/30 dark:to-teal-950/30 rounded-lg p-3 sm:p-4 space-y-3 border border-amber-200 dark:border-amber-800">
<div className="flex items-center gap-2 text-amber-700 dark:text-amber-300 font-medium text-sm sm:text-base">
<Hash className="h-4 w-4 flex-shrink-0" />
📊 Token 使
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4 text-xs sm:text-sm">
<div>
<div className="text-gray-600 dark:text-gray-400">AI </div>
<div className="font-medium">{model.displayName}</div>
<div className="text-xs text-gray-500">({model.provider})</div>
</div>
<div>
<div className="text-gray-600 dark:text-gray-400"> Token </div>
<div className="font-medium">{tokenUsage.formattedCounts.total}</div>
<div className="text-xs text-gray-500">
: {tokenUsage.formattedCounts.prompt} | : {tokenUsage.formattedCounts.completion}
</div>
</div>
<div>
<div className="text-gray-600 dark:text-gray-400 flex items-center gap-1">
<DollarSign className="h-3 w-3" />
</div>
<div className="font-medium text-lg text-green-600 dark:text-green-400">
{cost.formattedCost}
</div>
</div>
<div>
<div className="text-gray-600 dark:text-gray-400"></div>
<div className="text-xs space-y-1">
<div>輸入: ${cost.inputCost.toFixed(6)}</div>
<div>輸出: ${cost.outputCost.toFixed(6)}</div>
</div>
</div>
</div>
</div>
)}
{/* Cumulative Cost Summary */}
{costSummary && costSummary.totalSessions > 0 && (
<div className="bg-gradient-to-r from-green-50 via-teal-50 to-green-50 dark:from-green-950/30 dark:via-teal-950/30 dark:to-green-950/30 rounded-lg p-3 sm:p-4 space-y-3 border border-green-200 dark:border-green-800">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2 text-green-700 dark:text-green-300 font-medium text-sm sm:text-base">
<TrendingUp className="h-4 w-4 flex-shrink-0" />
📈
</div>
<Button
onClick={resetCostTracking}
variant="outline"
size="sm"
className="text-xs border-green-300 text-green-700 hover:bg-green-50 dark:border-green-600 dark:text-green-300 dark:hover:bg-green-950/30 px-2 sm:px-3 py-1"
>
<span className="hidden sm:inline">🔄 </span>
<span className="sm:hidden">🔄</span>
</Button>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 sm:gap-3 text-xs sm:text-sm">
<div className="text-center p-2 bg-gradient-to-br from-amber-50 to-yellow-50 dark:from-amber-950/20 dark:to-yellow-950/20 rounded border border-amber-200 dark:border-amber-800">
<div className="text-amber-600 dark:text-amber-400 text-xs">📊 </div>
<div className="font-bold text-lg">{costSummary.totalSessions}</div>
</div>
<div className="text-center p-2 bg-gradient-to-br from-green-50 to-teal-50 dark:from-green-950/20 dark:to-teal-950/20 rounded border border-green-200 dark:border-green-800">
<div className="text-green-600 dark:text-green-400 text-xs">💬 Token </div>
<div className="font-bold text-lg">{costTracker.formatNumber(costSummary.totalTokens)}</div>
</div>
<div className="text-center p-2 bg-gradient-to-br from-red-50 to-pink-50 dark:from-red-950/20 dark:to-pink-950/20 rounded border border-red-200 dark:border-red-800">
<div className="text-red-600 dark:text-red-400 text-xs">💰 </div>
<div className="font-bold text-lg text-red-600 dark:text-red-400">
{costTracker.formatCost(costSummary.totalCost, costSummary.currency)}
</div>
</div>
<div className="text-center p-2 bg-gradient-to-br from-teal-50 to-cyan-50 dark:from-teal-950/20 dark:to-cyan-950/20 rounded border border-teal-200 dark:border-teal-800">
<div className="text-teal-600 dark:text-teal-400 text-xs">💵 </div>
<div className="font-bold text-lg">
{costTracker.formatCost(costSummary.totalCost / costSummary.totalSessions, costSummary.currency)}
</div>
</div>
</div>
{Object.keys(costSummary.byProvider).length > 0 && (
<div className="pt-2 border-t border-green-200 dark:border-green-700">
<div className="text-xs text-green-600 dark:text-green-400 mb-2">📉 使:</div>
<div className="flex flex-wrap gap-2">
{Object.entries(costSummary.byProvider).map(([provider, stats]) => (
<div key={provider} className="text-xs bg-gradient-to-r from-amber-50 to-green-50 dark:from-amber-950/20 dark:to-green-950/20 border border-amber-200 dark:border-amber-700 px-2 py-1 rounded">
{provider}: {stats.sessions} ({costTracker.formatCost(stats.cost, costSummary.currency)})
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Speech Synthesis Controls */}
{speechSupported && (
<div className="bg-gradient-to-r from-teal-50 via-green-50 to-amber-50 dark:from-teal-950/40 dark:via-green-950/40 dark:to-amber-950/40 rounded-lg p-3 sm:p-4 border border-teal-200 dark:border-teal-800">
<div className="flex items-center gap-2 mb-3">
<Volume2 className="h-4 w-4 sm:h-5 sm:w-5 text-teal-600 dark:text-teal-400 flex-shrink-0" />
<span className="font-medium text-teal-700 dark:text-teal-300 text-sm sm:text-base">🔊 </span>
</div>
<div className="flex items-center gap-2 sm:gap-3 flex-wrap">
{/* Play/Pause/Stop Controls */}
<div className="flex gap-1 sm:gap-2">
<Button
onClick={playText}
disabled={!translatedText || isPlaying}
size="sm"
variant={isPlaying ? "secondary" : "default"}
className="flex items-center gap-1 sm:gap-2 bg-gradient-to-r from-green-500 to-teal-500 hover:from-green-600 hover:to-teal-600 text-white text-xs sm:text-sm px-2 sm:px-3"
>
<Play className="h-3 w-3 sm:h-4 sm:w-4" />
<span className="hidden sm:inline">{isPaused ? "繼續" : "播放"}</span>
<span className="sm:hidden">{isPaused ? "▶️" : "🔊"}</span>
</Button>
<Button
onClick={pauseText}
disabled={!isPlaying}
size="sm"
variant="outline"
className="flex items-center gap-1 sm:gap-2 border-amber-300 text-amber-700 hover:bg-amber-50 dark:border-amber-600 dark:text-amber-300 dark:hover:bg-amber-950/30 text-xs sm:text-sm px-2 sm:px-3"
>
<Pause className="h-3 w-3 sm:h-4 sm:w-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</Button>
<Button
onClick={stopText}
disabled={!isPlaying && !isPaused}
size="sm"
variant="outline"
className="flex items-center gap-1 sm:gap-2 border-red-300 text-red-700 hover:bg-red-50 dark:border-red-600 dark:text-red-300 dark:hover:bg-red-950/30 text-xs sm:text-sm px-2 sm:px-3"
>
<Square className="h-3 w-3 sm:h-4 sm:w-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</Button>
</div>
{/* Voice Selection */}
{availableVoices.length > 0 && (
<div className="flex items-center gap-1 sm:gap-2 w-full sm:w-auto">
<Label htmlFor="voice-select" className="text-xs sm:text-sm text-teal-700 dark:text-teal-300 flex-shrink-0">
<span className="hidden sm:inline">🎤 :</span>
<span className="sm:hidden">🎤</span>
</Label>
<Select value={selectedVoice} onValueChange={setSelectedVoice}>
<SelectTrigger className="w-full sm:w-40 lg:w-48 h-8 text-xs sm:text-sm border-teal-200 focus:border-green-400 dark:border-teal-700 dark:focus:border-green-500">
<SelectValue placeholder="選擇語音" />
</SelectTrigger>
<SelectContent>
{availableVoices
.filter(voice => voice.lang.includes(targetLanguage?.split('-')[0] || 'en'))
.map((voice) => (
<SelectItem key={voice.name} value={voice.name}>
{voice.name} ({voice.lang})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Speech Rate Control */}
<div className="flex items-center gap-1 sm:gap-2 w-full sm:w-auto">
<Label className="text-xs sm:text-sm text-teal-700 dark:text-teal-300 flex-shrink-0">
<span className="hidden sm:inline"> :</span>
<span className="sm:hidden"></span>
</Label>
<input
type="range"
min="0.5"
max="2"
step="0.1"
value={speechRate}
onChange={(e) => setSpeechRate(Number(e.target.value))}
className="flex-1 sm:w-16 lg:w-20 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
<span className="text-xs sm:text-sm text-gray-600 dark:text-gray-400 w-8 sm:w-10 text-center">
{speechRate.toFixed(1)}x
</span>
</div>
</div>
{/* Status Indicator */}
{(isPlaying || isPaused) && (
<div className="mt-3 flex items-center gap-2 text-sm">
<div className={`w-2 h-2 rounded-full ${isPlaying ? 'bg-green-500 animate-pulse' : 'bg-yellow-500'}`}></div>
<span className="text-gray-600 dark:text-gray-400">
{isPlaying ? '正在播放...' : isPaused ? '已暫停' : ''}
</span>
</div>
)}
</div>
)}
<div className="bg-gradient-to-br from-amber-50/50 via-green-50/50 to-teal-50/50 dark:from-slate-800/50 dark:via-slate-900/50 dark:to-slate-800/50 rounded-lg p-3 sm:p-4 lg:p-6 max-h-64 sm:max-h-80 lg:max-h-96 overflow-y-auto border border-amber-200 dark:border-amber-800">
<pre className="whitespace-pre-wrap font-sans text-amber-800 dark:text-amber-200 text-sm sm:text-base">
{translatedText}
</pre>
</div>
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3">
<Button
onClick={downloadTranslatedText}
variant="outline"
className="flex-1 border-green-300 text-green-700 hover:bg-green-50 dark:border-green-600 dark:text-green-300 dark:hover:bg-green-950/30 text-sm sm:text-base h-9 sm:h-10"
>
<Download className="mr-1 sm:mr-2 h-4 w-4" />
<span className="hidden sm:inline">📄 (.txt)</span>
<span className="sm:hidden">📄 </span>
</Button>
{translatedPDFBase64 && (
<Button
onClick={downloadTranslatedPDF}
className="flex-1 bg-gradient-to-r from-teal-500 via-green-500 to-amber-500 hover:from-teal-600 hover:via-green-600 hover:to-amber-600 text-white font-semibold text-sm sm:text-base h-9 sm:h-10"
>
<FileDown className="mr-1 sm:mr-2 h-4 w-4" />
<span className="hidden sm:inline">📁 PDF </span>
<span className="sm:hidden">📁 PDF</span>
</Button>
)}
</div>
</div>
) : (
<div className="h-64 sm:h-80 lg:h-96 flex items-center justify-center text-amber-400 dark:text-amber-500">
<div className="text-center px-4">
<Languages className="h-12 w-12 sm:h-16 sm:w-16 mx-auto mb-3 sm:mb-4 opacity-50 text-green-400 dark:text-green-500" />
<p className="text-teal-600 dark:text-teal-400 font-medium text-sm sm:text-base">🌿 </p>
</div>
</div>
)}
</Card>
</div>
</div>
</div>
)
}