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:
148
components/api-config.tsx
Normal file
148
components/api-config.tsx
Normal 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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
30
components/ui/checkbox.tsx
Normal file
30
components/ui/checkbox.tsx
Normal 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 }
|
Reference in New Issue
Block a user