All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m20s
Translation quality & format preservation: - Word: merge adjacent same-format runs into one unit (sentence-level coherence like inline-tag handling); translate comments/balloons; dedupe textbox collection (was translated twice); RTL no longer overrides center/justify alignment; CJK/Arabic font hints (eastAsia/cs) - PPTX: chart translations now actually reach the output file (ChartPart.blob is read-only — rewrite chart XML in the saved ZIP); CJK typeface hints (a:ea) - Excel: sheet renames no longer break references — rewrite cell formulas (3D/quoted), defined names, data validations, cond. formats - PDF: bold/italic honored (hebo/heit/hebi); table cells never merge; unchanged blocks left untouched (typography preserved, fixes duplicate hyperlinks); attempted/changed stats + route gate now cover PDF; CJK font paths; scanned PDFs via Mistral OCR (detection + admin settings) Features: - formality param (formal/informal) + automatic regional-variant prompts - output_mode=bilingual docx (source above translation) - per-user translation memory on Redis (falls back to LRU), context-hashed - QA report + 0-100 confidence score in job status; L0 on by default - OpenAI-compatible providers: whole chunk in ONE numbered-JSON request (~15x fewer calls) with per-item fallback; base prompt always present (custom prompt no longer replaces translation instructions) Infra & marketing alignment: - plan-based engine gating + vision gating (closes paid-engine leak); /providers/available filtered per plan; 107 languages exposed - zh-CN/zh-TW validation fixed; libmagic disabled on Windows (native crash) - admin: Mistral OCR settings + engine status dashboard; httpx<0.28 pin (TestClient breakage); Prometheus test fixture fixed - marketing docs aligned with code (PDF+OCR, retention, engines, pricing) - security: .env.ionos/.env.production/provider_settings.json removed Tests: 1173 passed / 0 failed (6 network tests deselected: free Google endpoint temporarily blocked from this machine)
338 lines
11 KiB
TypeScript
338 lines
11 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useCallback } from "react"
|
|
import {
|
|
Upload,
|
|
ArrowRight,
|
|
FileCheck,
|
|
X,
|
|
ShieldCheck,
|
|
Clock,
|
|
Loader2,
|
|
} from "lucide-react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { track } from "@vercel/analytics"
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
} from "@/components/ui/card"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select"
|
|
import { Progress } from "@/components/ui/progress"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
const LANGUAGES = [
|
|
"English",
|
|
"Spanish",
|
|
"French",
|
|
"German",
|
|
"Italian",
|
|
"Portuguese",
|
|
"Chinese (Simplified)",
|
|
"Chinese (Traditional)",
|
|
"Japanese",
|
|
"Korean",
|
|
"Arabic",
|
|
"Russian",
|
|
"Dutch",
|
|
"Swedish",
|
|
"Turkish",
|
|
]
|
|
|
|
type Engine = "classic" | "pro"
|
|
|
|
export function TranslationCard() {
|
|
const [file, setFile] = useState<File | null>(null)
|
|
const [isDragOver, setIsDragOver] = useState(false)
|
|
const [sourceLang, setSourceLang] = useState("")
|
|
const [targetLang, setTargetLang] = useState("")
|
|
const [engine, setEngine] = useState<Engine>("classic")
|
|
const [isProcessing, setIsProcessing] = useState(false)
|
|
const [progress, setProgress] = useState(0)
|
|
const [currentStep, setCurrentStep] = useState("")
|
|
|
|
const acceptedTypes = [
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
"application/pdf",
|
|
]
|
|
|
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
setIsDragOver(true)
|
|
}, [])
|
|
|
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
setIsDragOver(false)
|
|
}, [])
|
|
|
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
setIsDragOver(false)
|
|
const droppedFile = e.dataTransfer.files[0]
|
|
if (droppedFile) {
|
|
setFile(droppedFile)
|
|
}
|
|
}, [])
|
|
|
|
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const selected = e.target.files?.[0]
|
|
if (selected) {
|
|
setFile(selected)
|
|
}
|
|
}, [])
|
|
|
|
const removeFile = useCallback(() => {
|
|
setFile(null)
|
|
setIsProcessing(false)
|
|
setProgress(0)
|
|
setCurrentStep("")
|
|
}, [])
|
|
|
|
const handleTranslate = useCallback(() => {
|
|
if (!file || !sourceLang || !targetLang) return
|
|
track("translation_started", {
|
|
engine,
|
|
source: sourceLang,
|
|
target: targetLang,
|
|
})
|
|
setIsProcessing(true)
|
|
setProgress(0)
|
|
|
|
const steps = [
|
|
"Parsing document structure...",
|
|
"Extracting translatable content...",
|
|
"Processing... 3/15 slides",
|
|
"Processing... 7/15 slides",
|
|
"Processing... 11/15 slides",
|
|
"Processing... 14/15 slides",
|
|
"Rebuilding document formatting...",
|
|
"Finalizing output...",
|
|
]
|
|
|
|
let stepIndex = 0
|
|
setCurrentStep(steps[0])
|
|
|
|
const interval = setInterval(() => {
|
|
stepIndex++
|
|
if (stepIndex < steps.length) {
|
|
setCurrentStep(steps[stepIndex])
|
|
setProgress(Math.round(((stepIndex + 1) / steps.length) * 100))
|
|
} else {
|
|
clearInterval(interval)
|
|
setCurrentStep("Complete!")
|
|
setProgress(100)
|
|
setTimeout(() => {
|
|
setIsProcessing(false)
|
|
setProgress(0)
|
|
setCurrentStep("")
|
|
}, 2000)
|
|
}
|
|
}, 1200)
|
|
}, [file, sourceLang, targetLang])
|
|
|
|
const fileExtension = file?.name.split(".").pop()?.toLowerCase()
|
|
|
|
return (
|
|
<section className="flex flex-col items-center gap-5 px-6 pb-16">
|
|
<Card className="w-full max-w-xl border-border/70 shadow-lg">
|
|
<CardContent className="flex flex-col gap-5 pt-6">
|
|
{/* Drop Zone */}
|
|
<div
|
|
className={cn(
|
|
"relative flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed px-6 py-10 transition-colors",
|
|
isDragOver
|
|
? "border-accent bg-accent/5"
|
|
: file
|
|
? "border-success/40 bg-success/5"
|
|
: "border-border bg-muted/30 hover:border-muted-foreground/30"
|
|
)}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
>
|
|
{file ? (
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex size-10 items-center justify-center rounded-lg bg-secondary">
|
|
<FileCheck className="size-5 text-foreground" />
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<span className="text-sm font-medium text-foreground">
|
|
{file.name}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{(file.size / 1024).toFixed(1)} KB · .{fileExtension}
|
|
</span>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="ml-2 text-muted-foreground hover:text-foreground"
|
|
onClick={removeFile}
|
|
aria-label="Remove file"
|
|
>
|
|
<X className="size-4" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="flex size-12 items-center justify-center rounded-xl bg-secondary">
|
|
<Upload className="size-5 text-muted-foreground" />
|
|
</div>
|
|
<div className="flex flex-col items-center gap-1">
|
|
<p className="text-sm font-medium text-foreground">
|
|
Drag & drop your .xlsx, .docx, .pptx, or .pdf file here
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
or click to browse
|
|
</p>
|
|
</div>
|
|
<input
|
|
type="file"
|
|
accept={acceptedTypes.join(",")}
|
|
className="absolute inset-0 cursor-pointer opacity-0"
|
|
onChange={handleFileSelect}
|
|
aria-label="Upload file"
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Language Selectors */}
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex flex-1 flex-col gap-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
Source Language
|
|
</label>
|
|
<Select value={sourceLang} onValueChange={setSourceLang}>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="Detect / Select" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{LANGUAGES.map((lang) => (
|
|
<SelectItem key={lang} value={lang}>
|
|
{lang}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<ArrowRight className="mt-5 size-4 shrink-0 text-muted-foreground" />
|
|
|
|
<div className="flex flex-1 flex-col gap-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
Target Language
|
|
</label>
|
|
<Select value={targetLang} onValueChange={setTargetLang}>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="Select language" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{LANGUAGES.map((lang) => (
|
|
<SelectItem key={lang} value={lang}>
|
|
{lang}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Engine Toggle */}
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
Translation Engine
|
|
</label>
|
|
<div className="flex rounded-lg border border-border bg-muted p-1">
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"flex-1 rounded-md px-4 py-2 text-sm font-medium transition-all",
|
|
engine === "classic"
|
|
? "bg-card text-foreground shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
)}
|
|
onClick={() => setEngine("classic")}
|
|
>
|
|
Classic
|
|
<span className="ml-1.5 text-xs text-muted-foreground">
|
|
Fast & Free
|
|
</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"flex-1 rounded-md px-4 py-2 text-sm font-medium transition-all",
|
|
engine === "pro"
|
|
? "bg-card text-foreground shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
)}
|
|
onClick={() => setEngine("pro")}
|
|
>
|
|
Pro LLM
|
|
<span className="ml-1.5 text-xs text-muted-foreground">
|
|
Context-Aware
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Translate Button */}
|
|
<Button
|
|
size="lg"
|
|
className="w-full text-sm font-semibold"
|
|
disabled={!file || !sourceLang || !targetLang || isProcessing}
|
|
onClick={handleTranslate}
|
|
>
|
|
{isProcessing ? (
|
|
<>
|
|
<Loader2 className="size-4 animate-spin" />
|
|
Translating...
|
|
</>
|
|
) : (
|
|
"Translate Document"
|
|
)}
|
|
</Button>
|
|
|
|
{/* Progress Bar */}
|
|
{isProcessing && (
|
|
<div className="flex flex-col gap-2.5 rounded-lg border border-border bg-muted/50 px-4 py-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium text-foreground">
|
|
{currentStep}
|
|
</span>
|
|
<span className="text-xs tabular-nums text-muted-foreground">
|
|
{progress}%
|
|
</span>
|
|
</div>
|
|
<Progress value={progress} className="h-1.5" />
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Trust Badge */}
|
|
<div className="flex items-center gap-4 rounded-full border border-border bg-card px-5 py-2.5 shadow-sm">
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
<ShieldCheck className="size-3.5" />
|
|
<span className="font-medium">Privacy First:</span>
|
|
<span>Zero Data Retention</span>
|
|
</div>
|
|
<div className="h-3 w-px bg-border" />
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
<Clock className="size-3.5" />
|
|
<span>Files deleted after 60 min</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|