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.
This commit is contained in:
2025-10-15 23:34:44 +08:00
parent c899702d51
commit 39a4788cc4
21 changed files with 11041 additions and 251 deletions

148
components/api-config.tsx Normal file
View File

@@ -0,0 +1,148 @@
"use client"
import { useState } from "react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Settings, Key, AlertCircle, CheckCircle } from "lucide-react"
interface APIConfigProps {
onConfigUpdate?: () => void
}
export function APIConfig({ onConfigUpdate }: APIConfigProps) {
const [provider, setProvider] = useState("deepseek")
const [apiKey, setApiKey] = useState("")
const [isTestingAPI, setIsTestingAPI] = useState(false)
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
const handleTestAPI = async () => {
if (!apiKey) {
setTestResult({ success: false, message: "請輸入 API 金鑰" })
return
}
setIsTestingAPI(true)
setTestResult(null)
try {
const response = await fetch("/api/test-api", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
provider,
apiKey,
}),
})
const data = await response.json()
if (response.ok) {
setTestResult({ success: true, message: "API 連接成功!" })
if (onConfigUpdate) {
onConfigUpdate()
}
} else {
setTestResult({ success: false, message: data.error || "API 測試失敗" })
}
} catch (error) {
setTestResult({ success: false, message: "網路連接錯誤" })
} finally {
setIsTestingAPI(false)
}
}
return (
<Card className="p-6 shadow-lg">
<div className="flex items-center gap-2 mb-4">
<Settings className="h-5 w-5 text-blue-600" />
<h3 className="text-lg font-semibold">API </h3>
</div>
<div className="space-y-4">
<div>
<Label htmlFor="provider"> AI </Label>
<Select value={provider} onValueChange={setProvider}>
<SelectTrigger className="mt-2">
<SelectValue placeholder="選擇提供者" />
</SelectTrigger>
<SelectContent>
<SelectItem value="deepseek">DeepSeek ( - )</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="api-key">API </Label>
<div className="mt-2 flex gap-2">
<div className="flex-1 relative">
<Key className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
id="api-key"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={provider === "deepseek" ? "sk-xxxxxxxx" : "sk-proj-xxxxxxxx"}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<Button
onClick={handleTestAPI}
disabled={isTestingAPI || !apiKey}
variant="outline"
>
{isTestingAPI ? "測試中..." : "測試"}
</Button>
</div>
</div>
{testResult && (
<div className={`flex items-center gap-2 p-3 rounded-lg ${
testResult.success
? "bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300"
: "bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300"
}`}>
{testResult.success ? (
<CheckCircle className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
<span className="text-sm">{testResult.message}</span>
</div>
)}
<div className="text-sm text-gray-600 dark:text-gray-400 space-y-2">
<h4 className="font-medium"> API </h4>
{provider === "deepseek" ? (
<div>
<p><strong>DeepSeek API</strong></p>
<ol className="list-decimal list-inside space-y-1 ml-4">
<li> <a href="https://platform.deepseek.com" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">platform.deepseek.com</a></li>
<li></li>
<li> API </li>
<li> API </li>
<li></li>
</ol>
<p className="mt-2 text-xs text-green-600">💡 DeepSeek </p>
</div>
) : (
<div>
<p><strong>OpenAI API</strong></p>
<ol className="list-decimal list-inside space-y-1 ml-4">
<li> <a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">platform.openai.com/api-keys</a></li>
<li> OpenAI </li>
<li> "Create new secret key"</li>
<li></li>
<li></li>
</ol>
</div>
)}
</div>
</div>
</Card>
)
}

View File

@@ -2,14 +2,18 @@
import type React from "react"
import { useState } from "react"
import { Upload, FileText, Languages, Loader2 } from "lucide-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" },
@@ -22,24 +26,116 @@ const LANGUAGES = [
{ 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 && selectedFile.type === "application/pdf") {
setFile(selectedFile)
setTranslatedText("")
} else {
alert("請選擇PDF文件")
if (selectedFile) {
const isPDF = selectedFile.type === "application/pdf"
if (isPDF) {
setFile(selectedFile)
setTranslatedText("")
setTranslatedPDFBase64("")
setTokenUsage(null)
setCost(null)
setModel(null)
} else {
alert("目前僅支援 PDF 文件")
}
}
}
@@ -48,175 +144,628 @@ export function PDFTranslator() {
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
const handleDragLeave = () => {
setIsDragging(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const droppedFile = e.dataTransfer.files[0]
if (droppedFile && droppedFile.type === "application/pdf") {
setFile(droppedFile)
setTranslatedText("")
} else {
alert("請選擇PDF文件")
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("請上傳PDF文件並選擇目標語言")
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("翻譯失敗")
throw new Error(data.error || "翻譯失敗")
}
const data = await response.json()
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("翻譯錯誤:", error)
alert("翻譯過程中發生錯誤,請稍後再試")
console.error("Translation error:", error)
alert(error instanceof Error ? error.message : "翻譯過程中發生錯誤")
} finally {
setIsTranslating(false)
}
}
return (
<div className="space-y-8">
{/* Step 1: Upload File */}
<Card className="border-4 border-primary p-8">
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-foreground mb-2 uppercase tracking-wide"> 1: 上傳文件</h2>
<p className="text-foreground/80">PDF文件</p>
</div>
const downloadTranslatedText = () => {
if (!translatedText) return
<div className="space-y-4">
<div className="flex gap-4">
<div className="flex-1">
<Label htmlFor="file-upload" className="block w-full cursor-pointer">
<div className="border-4 border-primary bg-card hover:bg-secondary transition-colors p-6 text-center">
<Upload className="w-8 h-8 mx-auto mb-2 text-primary" />
<span className="text-foreground font-semibold">{file ? file.name : "選擇文件"}</span>
</div>
</Label>
<input id="file-upload" type="file" accept=".pdf" onChange={handleFileChange} className="hidden" />
</div>
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}
className={`border-4 border-primary bg-card p-8 text-center transition-colors ${
isDragging ? "bg-secondary" : ""
}`}
>
<FileText className="w-12 h-12 mx-auto mb-3 text-primary" />
<p className="text-foreground font-semibold">PDF文件到這裡</p>
<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>
</div>
</div>
</Card>
{/* Step 2: Select Language */}
<Card className="border-4 border-primary p-8">
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-foreground mb-2 uppercase tracking-wide"> 2: 選擇目標語言</h2>
<p className="text-foreground/80"></p>
</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="space-y-4">
<div>
<Label htmlFor="language" className="text-foreground font-bold uppercase text-sm mb-2 block">
:
</Label>
<Select value={targetLanguage} onValueChange={setTargetLanguage}>
<SelectTrigger
id="language"
className="border-4 border-primary bg-card text-foreground font-semibold h-14"
>
<SelectValue placeholder="選擇語言" />
</SelectTrigger>
<SelectContent>
{LANGUAGES.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
<div className="flex items-center gap-2">
<Languages className="w-4 h-4" />
<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>
</SelectItem>
))}
</SelectContent>
</Select>
</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>
)}
<Button
onClick={handleTranslate}
disabled={!file || !targetLanguage || isTranslating}
className="w-full h-14 text-lg font-bold bg-accent hover:bg-accent/90 text-accent-foreground"
>
{isTranslating ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
...
</>
) : (
"開始翻譯"
)}
</Button>
</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>
</Card>
{/* Step 3: Translation Result */}
{translatedText && (
<Card className="border-4 border-primary p-8">
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-foreground mb-2 uppercase tracking-wide"> 3: 翻譯結果</h2>
<p className="text-foreground/80"></p>
</div>
<div className="bg-card border-4 border-primary p-6 max-h-96 overflow-y-auto">
<pre className="whitespace-pre-wrap text-foreground font-sans leading-relaxed">{translatedText}</pre>
</div>
<Button
onClick={() => {
const blob = new Blob([translatedText], { type: "text/plain" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `translated-${file?.name.replace(".pdf", ".txt")}`
a.click()
URL.revokeObjectURL(url)
}}
className="w-full h-14 text-lg font-bold bg-accent hover:bg-accent/90 text-accent-foreground"
>
</Button>
</div>
</Card>
)}
</div>
</div>
)
}
}

View File

@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }