All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m30s
- TermEditor: rewritten with expandable multilingual translation grid (13 languages), editorial styling, source/target + translations JSON - GlossarySelector: new component in translate page config panel, fetches user glossaries, shows flag + term count, Pro+LLM only - useTranslationConfig: added glossaryId state - useTranslationSubmit: sends glossary_id to backend - Context page: removed textarea glossary, presets now create API glossaries via template import, added link to Glossaries page - i18n: added 12 keys × 13 locales for glossary/translate/context Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
701 lines
40 KiB
TypeScript
701 lines
40 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useRef, useState, useMemo } from 'react';
|
|
import {
|
|
ShieldCheck, Clock, ArrowRight, RotateCcw, Loader2,
|
|
FileSpreadsheet, FileText, Presentation, Upload, X,
|
|
Zap, CheckCircle2,
|
|
Search, Languages, Wrench, Activity, Timer,
|
|
Download, AlertTriangle, FileType,
|
|
} from 'lucide-react';
|
|
import { useFileUpload } from './useFileUpload';
|
|
import { useTranslationConfig } from './useTranslationConfig';
|
|
import { useTranslationSubmit } from './useTranslationSubmit';
|
|
import LanguageSelector from './LanguageSelector';
|
|
import { ProviderSelector } from './ProviderSelector';
|
|
import { GlossarySelector } from './GlossarySelector';
|
|
import { useNotification } from '@/components/ui/notification';
|
|
import { useI18n } from '@/lib/i18n';
|
|
import { API_BASE } from '@/lib/config';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
/* ── helpers ─────────────────────────────────────────────────────── */
|
|
const FILE_ICONS: Record<string, React.ElementType> = {
|
|
xlsx: FileSpreadsheet, docx: FileText, pptx: Presentation, pdf: FileType,
|
|
};
|
|
const FILE_COLORS: Record<string, string> = {
|
|
xlsx: 'text-green-500', docx: 'text-blue-500', pptx: 'text-orange-500', pdf: 'text-red-500',
|
|
};
|
|
function fmt(bytes: number) {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / 1048576).toFixed(1)} MB`;
|
|
}
|
|
|
|
/* ── Quality label based on provider ────────────────────────────── */
|
|
function getQualityLabel(t: (key: string) => string, provider: string | null | undefined): string {
|
|
if (!provider) return t('dashboard.translate.highQuality');
|
|
if (['openai', 'openrouter', 'openrouter_premium'].includes(provider)) return t('dashboard.translate.highQuality');
|
|
if (provider === 'deepl') return t('dashboard.translate.highQuality');
|
|
return t('dashboard.translate.quality');
|
|
}
|
|
|
|
/* ── Pipeline step keys ─────────────────────────────────────────── */
|
|
const PIPELINE_STEP_KEYS = [
|
|
'dashboard.translate.pipeline.upload',
|
|
'dashboard.translate.pipeline.analyze',
|
|
'dashboard.translate.pipeline.translate',
|
|
'dashboard.translate.pipeline.rebuild',
|
|
'dashboard.translate.pipeline.finalize',
|
|
] as const;
|
|
|
|
const PIPELINE_ICONS = [Upload, Search, Languages, Wrench, CheckCircle2] as const;
|
|
const PIPELINE_STARTS = [0, 10, 25, 75, 92];
|
|
|
|
function getActiveStepIdx(progress: number) {
|
|
for (let i = PIPELINE_STARTS.length - 1; i >= 0; i--) {
|
|
if (progress >= PIPELINE_STARTS[i]) return i;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function formatElapsed(totalSeconds: number) {
|
|
const m = Math.floor(totalSeconds / 60);
|
|
const s = totalSeconds % 60;
|
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
/* ── Page ────────────────────────────────────────────────────────── */
|
|
export default function TranslatePage() {
|
|
const upload = useFileUpload();
|
|
const config = useTranslationConfig(!!upload.file);
|
|
const submit = useTranslationSubmit();
|
|
const { error: showError } = useNotification();
|
|
const { t } = useI18n();
|
|
const lastErrorRef = useRef<string | null>(null);
|
|
const replaceInputRef = useRef<HTMLInputElement>(null);
|
|
const dropzoneInputRef = useRef<HTMLInputElement>(null);
|
|
const [pdfMode, setPdfMode] = useState<'layout' | 'text_only'>('layout');
|
|
const [elapsed, setElapsed] = useState(0);
|
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
const isPdf = upload.file?.name.toLowerCase().endsWith('.pdf') ?? false;
|
|
|
|
useEffect(() => {
|
|
if (submit.error && submit.error !== lastErrorRef.current) {
|
|
lastErrorRef.current = submit.error;
|
|
showError({ title: t('dashboard.translate.errorNotificationTitle'), description: submit.error });
|
|
}
|
|
}, [submit.error, showError, t]);
|
|
|
|
// Elapsed timer
|
|
useEffect(() => {
|
|
if ((submit.status === 'processing' || submit.isSubmitting) && submit.status !== 'completed') {
|
|
setElapsed(0);
|
|
timerRef.current = setInterval(() => setElapsed((s) => s + 1), 1000);
|
|
} else {
|
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
}
|
|
return () => { if (timerRef.current) clearInterval(timerRef.current); };
|
|
}, [submit.status, submit.isSubmitting]);
|
|
|
|
const handleTranslate = async () => {
|
|
if (!upload.file || !config.isConfigValid) return;
|
|
const cfg = config.getConfig();
|
|
if (isPdf) cfg.pdfMode = pdfMode;
|
|
await submit.submitTranslation(upload.file, cfg);
|
|
};
|
|
|
|
const handleNewTranslation = () => { submit.reset(); upload.removeFile(); setElapsed(0); };
|
|
const handleDownload = async () => {
|
|
if (!submit.jobId) return;
|
|
const token = localStorage.getItem('token');
|
|
const headers: Record<string, string> = {};
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
const response = await fetch(`${API_BASE}/api/v1/download/${submit.jobId}`, { headers });
|
|
if (!response.ok) return;
|
|
const contentDisposition = response.headers.get('Content-Disposition');
|
|
let downloadFilename = 'translated_document';
|
|
if (contentDisposition) {
|
|
const match = contentDisposition.match(/filename\*?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']+)/i);
|
|
if (match?.[1]) downloadFilename = match[1];
|
|
} else if (submit.fileName) {
|
|
const ext = submit.fileName.split('.').pop() || '';
|
|
const base = submit.fileName.replace(/\.[^.]+$/, '');
|
|
downloadFilename = `${base}_translated.${ext}`;
|
|
}
|
|
const blob = await response.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url; a.download = downloadFilename;
|
|
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
};
|
|
|
|
/* ── Derived states ──────────────────────────────────────────── */
|
|
const isConfiguring = !!upload.file && submit.status === 'idle' && !submit.isSubmitting;
|
|
const isProcessing = (submit.status === 'processing' || submit.isSubmitting) && submit.status !== 'completed';
|
|
const isCompleted = submit.status === 'completed';
|
|
const isFailed = submit.status === 'failed';
|
|
|
|
const showUpload = !upload.file && !isProcessing && !isCompleted && !isFailed;
|
|
const showConfiguring = isConfiguring;
|
|
const showProcessing = isProcessing;
|
|
const showComplete = isCompleted && !!submit.jobId;
|
|
const showFailed = isFailed;
|
|
|
|
const currentProvider = config.availableProviders.find(p => p.id === config.provider);
|
|
const srcLangName = config.languages.find(l => l.code === config.sourceLang)?.name || config.sourceLang;
|
|
const tgtLangName = config.languages.find(l => l.code === config.targetLang)?.name || config.targetLang;
|
|
const activeStepIdx = getActiveStepIdx(submit.progress);
|
|
const qualityLabel = useMemo(() => getQualityLabel(t, config.provider), [t, config.provider]);
|
|
|
|
/* ═══════════════════════════════════════════════════════════════ */
|
|
/* EDITORIAL LAYOUT */
|
|
/* ═══════════════════════════════════════════════════════════════ */
|
|
return (
|
|
<div className="min-h-full p-6 lg:p-8 dark:bg-[#0a0a0a]">
|
|
<div className="max-w-6xl mx-auto">
|
|
|
|
{/* ── HEADER ────────────────────────────────────────────── */}
|
|
<div className="mb-12">
|
|
{showProcessing ? (
|
|
<>
|
|
<span className="accent-pill mb-4 block w-fit italic">{t('landing.translate.processing')}</span>
|
|
<h1 className="text-5xl font-black uppercase tracking-tighter mb-4 leading-none text-brand-dark dark:text-white">
|
|
{t('landing.translate.aiAnalysis')}
|
|
</h1>
|
|
<p className="text-brand-dark/40 font-medium dark:text-white/40">
|
|
{t('landing.translate.preservingLayout')}
|
|
</p>
|
|
</>
|
|
) : showComplete ? (
|
|
<>
|
|
<span className="accent-pill mb-4 block w-fit italic">{t('dashboard.translate.completed')}</span>
|
|
<h1 className="text-5xl font-black uppercase tracking-tighter mb-4 leading-none text-brand-dark dark:text-white">
|
|
{t('dashboard.translate.completed')}
|
|
</h1>
|
|
<p className="text-brand-dark/40 font-medium dark:text-white/40">
|
|
{submit.fileName}
|
|
</p>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="accent-pill mb-4 block w-fit">{t('landing.translate.newProject')}</span>
|
|
<h1 className="text-5xl font-black uppercase tracking-tighter mb-4 leading-none text-brand-dark dark:text-white">
|
|
{t('landing.translate.title')}
|
|
</h1>
|
|
<p className="text-brand-dark/40 font-medium dark:text-white/40">
|
|
{t('landing.translate.subtitle')}
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── GRID: 8/4 SPLIT ───────────────────────────────────── */}
|
|
<div className="grid lg:grid-cols-12 gap-12">
|
|
|
|
{/* ═══════════════════════════════════════════════════════ */}
|
|
{/* LEFT (8 cols) — content swaps based on state */}
|
|
{/* ═══════════════════════════════════════════════════════ */}
|
|
<div className="lg:col-span-8">
|
|
|
|
{/* ── UPLOAD STATE: Editorial Dropzone ──────────────── */}
|
|
{showUpload && (
|
|
<div
|
|
className="bg-white border-2 border-dashed border-brand-accent/20 rounded-[40px] p-20 flex flex-col items-center justify-center text-center group cursor-pointer hover:border-brand-accent transition-all shadow-editorial dark:bg-[#141414] dark:border-white/10"
|
|
onDragOver={upload.handleDragOver}
|
|
onDragLeave={upload.handleDragLeave}
|
|
onDrop={upload.handleDrop}
|
|
onClick={() => dropzoneInputRef.current?.click()}
|
|
>
|
|
<div className="w-20 h-20 bg-brand-muted rounded-3xl flex items-center justify-center text-brand-accent group-hover:scale-110 group-hover:bg-brand-dark group-hover:text-white transition-all mb-8 shadow-sm dark:bg-white/10">
|
|
<Upload size={32} />
|
|
</div>
|
|
<h3 className="text-2xl font-black uppercase tracking-tight mb-4 text-brand-dark dark:text-white">
|
|
{t('landing.translate.dropHere')}
|
|
</h3>
|
|
<p className="text-sm text-brand-dark/40 mb-12 font-medium dark:text-white/40">
|
|
{t('landing.translate.supportedFormats')}
|
|
</p>
|
|
<div className="flex flex-wrap justify-center gap-4">
|
|
{[
|
|
{ label: 'Word', icon: <FileText size={12} className="text-blue-500" /> },
|
|
{ label: 'Excel', icon: <FileSpreadsheet size={12} className="text-green-500" /> },
|
|
{ label: 'Slides', icon: <Presentation size={12} className="text-orange-500" /> },
|
|
{ label: 'PDF', icon: <FileType size={12} className="text-red-500" /> },
|
|
].map(f => (
|
|
<span key={f.label} className="flex items-center gap-3 px-4 py-2 bg-brand-muted rounded-xl text-[10px] font-black uppercase tracking-widest text-brand-dark/60 border border-transparent hover:border-brand-accent/30 transition-all dark:bg-white/10 dark:text-white/60">
|
|
{f.icon} {f.label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
{/* Hidden file input for click-to-upload */}
|
|
<input
|
|
ref={dropzoneInputRef}
|
|
type="file"
|
|
accept=".xlsx,.docx,.pptx,.pdf"
|
|
className="hidden"
|
|
onChange={upload.handleFileSelect}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── CONFIGURING STATE: File strip ─────────────────── */}
|
|
{showConfiguring && (
|
|
<div className="editorial-card p-10 bg-white border-none shadow-editorial dark:bg-[#141414]">
|
|
<h4 className="text-[10px] font-black uppercase tracking-[0.3em] mb-10 text-brand-dark/30 border-b border-black/5 pb-6 dark:text-white/30 dark:border-white/5">
|
|
{t('landing.translate.sourceDocument')}
|
|
</h4>
|
|
<FileStrip file={upload.file!} onRemove={upload.removeFile} onReplace={() => replaceInputRef.current?.click()} t={t} />
|
|
<input ref={replaceInputRef} type="file" accept=".xlsx,.docx,.pptx,.pdf" className="hidden" onChange={upload.handleFileSelect} />
|
|
{upload.error && <p className="mt-2 text-sm text-destructive">{upload.error}</p>}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── PROCESSING STATE: Rich progress ───────────────── */}
|
|
{showProcessing && (
|
|
<div className="editorial-card p-16 h-full border-none shadow-editorial bg-white dark:bg-[#141414]">
|
|
<div className="flex items-center gap-6 mb-20">
|
|
<div className="w-16 h-16 bg-brand-muted rounded-2xl flex items-center justify-center text-brand-accent border border-brand-accent/10 animate-pulse dark:bg-white/10 dark:border-white/10">
|
|
<Activity size={32} />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-2xl font-black uppercase tracking-tight mb-2 text-brand-dark dark:text-white">
|
|
{t('dashboard.translate.translating')}
|
|
</h3>
|
|
<p className="text-[10px] text-brand-dark/30 font-black uppercase tracking-widest dark:text-white/30">
|
|
{submit.fileName || upload.file?.name}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress Line with step icons */}
|
|
<div className="relative h-2 bg-brand-muted rounded-full mb-24 dark:bg-white/10">
|
|
<div className="absolute top-1/2 left-0 w-full -translate-y-1/2 flex justify-between px-2">
|
|
{PIPELINE_ICONS.map((Icon, i) => (
|
|
<div
|
|
key={i}
|
|
className={cn(
|
|
'w-12 h-12 rounded-2xl border-4 border-white dark:border-[#141414] shadow-xl flex items-center justify-center z-10 transition-all duration-500',
|
|
submit.progress > (i * 25)
|
|
? 'bg-brand-dark text-white scale-110 dark:bg-brand-accent'
|
|
: 'bg-brand-muted text-brand-dark/20 dark:bg-white/10 dark:text-white/20'
|
|
)}
|
|
>
|
|
<Icon size={18} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div
|
|
className="absolute top-0 left-0 h-full bg-brand-accent shadow-[0_0_20px_rgba(197,161,122,0.4)] transition-all duration-700 rounded-full"
|
|
style={{ width: `${Math.max(0, submit.progress)}%` }}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex justify-between items-end mb-8">
|
|
<span className="text-[10px] font-black text-brand-dark/30 uppercase tracking-[0.3em] dark:text-white/30">
|
|
{activeStepIdx < 2 ? t('dashboard.translate.steps.uploading') : t('dashboard.translate.steps.starting')}
|
|
</span>
|
|
<span className="text-7xl font-black text-brand-dark dark:text-white">
|
|
{Math.round(submit.progress)}%
|
|
</span>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-4 gap-6 pt-12 border-t border-black/5 dark:border-white/5">
|
|
<StatBox icon={<FileText size={18} />} value={`${Math.round(submit.progress)}%`} label={t('dashboard.translate.segments')} />
|
|
<StatBox icon={<Zap size={18} />} value="99.9%" label={t('dashboard.translate.quality')} />
|
|
<StatBox icon={<Clock size={18} />} value="Turbo" label={t('dashboard.translate.segPerMin')} />
|
|
<StatBox icon={<Activity size={18} />} value={formatElapsed(elapsed)} label={t('dashboard.translate.elapsed')} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── COMPLETE STATE: Success with download ─────────── */}
|
|
{showComplete && (
|
|
<div className="editorial-card p-16 h-full border-none shadow-editorial bg-white dark:bg-[#141414]">
|
|
<div className="h-full flex flex-col">
|
|
<div className="p-8 bg-brand-accent/5 border border-brand-accent/10 rounded-[32px] flex items-center justify-between mb-16 shadow-inner dark:bg-brand-accent/10 dark:border-brand-accent/20">
|
|
<div className="flex items-center gap-6">
|
|
<div className="w-14 h-14 bg-brand-accent rounded-full flex items-center justify-center text-white shadow-xl">
|
|
<CheckCircle2 size={28} />
|
|
</div>
|
|
<div>
|
|
<p className="text-[13px] font-black uppercase tracking-[0.1em] text-brand-dark dark:text-white">
|
|
{t('dashboard.translate.completed')}
|
|
</p>
|
|
<p className="text-[10px] text-brand-dark/40 font-bold uppercase mt-1 tracking-widest dark:text-white/40">
|
|
{submit.fileName}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<span className="px-5 py-2 bg-white dark:bg-[#1a1a1a] rounded-full text-[9px] font-black uppercase tracking-widest text-brand-accent border border-brand-accent/20 shadow-sm">
|
|
{qualityLabel}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex-1 flex flex-col items-center justify-center py-20 bg-brand-muted/20 rounded-[40px] border border-black/5 dark:bg-white/5 dark:border-white/5">
|
|
<button
|
|
onClick={handleDownload}
|
|
className="premium-button px-24 py-6 text-xl !rounded-full flex items-center gap-6 mb-8 group"
|
|
>
|
|
<Download size={28} className="group-hover:translate-y-1 transition-transform" />
|
|
{t('dashboard.translate.complete.download')}
|
|
</button>
|
|
<button
|
|
onClick={handleNewTranslation}
|
|
className="text-[10px] font-black uppercase tracking-[0.3em] text-brand-dark/20 hover:text-brand-dark transition-colors dark:text-white/20 dark:hover:text-white"
|
|
>
|
|
+ {t('dashboard.translate.complete.newTranslation')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── FAILED STATE ───────────────────────────────────── */}
|
|
{showFailed && (
|
|
<div className="editorial-card p-10 bg-white border-none shadow-editorial dark:bg-[#141414]">
|
|
<div className="rounded-[24px] bg-destructive/10 border border-destructive/20 p-6" role="alert">
|
|
<div className="flex items-start gap-3">
|
|
<AlertTriangle className="size-5 text-destructive shrink-0 mt-0.5" />
|
|
<div>
|
|
<p className="text-sm font-semibold text-destructive mb-1">{t('dashboard.translate.progress.failedTitle')}</p>
|
|
<p className="text-sm text-destructive/80">{submit.error}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{(submit.fileName || upload.file?.name) && upload.file && (
|
|
<div className="mt-6">
|
|
<FileStrip file={upload.file} onRemove={upload.removeFile} onReplace={() => replaceInputRef.current?.click()} t={t} />
|
|
<input ref={replaceInputRef} type="file" accept=".xlsx,.docx,.pptx,.pdf" className="hidden" onChange={upload.handleFileSelect} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ═══════════════════════════════════════════════════════ */}
|
|
{/* RIGHT (4 cols) — Config / Monitor / Summary */}
|
|
{/* ═══════════════════════════════════════════════════════ */}
|
|
<div className="lg:col-span-4">
|
|
|
|
{/* ── CONFIG (upload / configuring / failed) ──────────── */}
|
|
{(showUpload || showConfiguring || showFailed) && (
|
|
<div className="space-y-8">
|
|
<div className="editorial-card p-10 bg-white border-none shadow-editorial dark:bg-[#141414]">
|
|
<h4 className="text-[10px] font-black uppercase tracking-[0.3em] mb-10 text-brand-dark/30 border-b border-black/5 pb-6 dark:text-white/30 dark:border-white/5">
|
|
{t('landing.translate.configuration')}
|
|
</h4>
|
|
|
|
<div className="space-y-8">
|
|
<LanguageSelector
|
|
sourceLang={config.sourceLang} targetLang={config.targetLang}
|
|
languages={config.languages} isLoading={config.isLoadingLanguages}
|
|
error={config.languagesError} onSourceChange={config.setSourceLang}
|
|
onTargetChange={config.setTargetLang}
|
|
/>
|
|
|
|
<ProviderSelector
|
|
provider={config.provider} onProviderChange={config.setProvider}
|
|
availableProviders={config.availableProviders} isLoadingProviders={config.isLoadingProviders}
|
|
isPro={config.isPro}
|
|
/>
|
|
|
|
{config.isPro && config.mode === 'llm' && (
|
|
<GlossarySelector
|
|
glossaryId={config.glossaryId}
|
|
onChange={config.setGlossaryId}
|
|
disabled={submit.isSubmitting}
|
|
/>
|
|
)}
|
|
|
|
{/* PDF mode selector */}
|
|
{isPdf && (
|
|
<div className="space-y-2">
|
|
<label className="text-[9px] font-black text-brand-dark/40 uppercase tracking-[0.2em] block mb-3 dark:text-white/40">
|
|
{t('dashboard.translate.pdfMode.title')}
|
|
</label>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setPdfMode('layout')}
|
|
className={cn(
|
|
'flex flex-col items-start rounded-2xl border-2 p-3 text-start transition-all',
|
|
pdfMode === 'layout'
|
|
? 'border-brand-accent bg-brand-accent/5'
|
|
: 'border-black/5 bg-brand-muted/30 hover:border-brand-accent/20 dark:border-white/10 dark:bg-white/5'
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2 text-[11px] font-black uppercase tracking-tight text-brand-dark dark:text-white">
|
|
<FileText className="size-4 text-brand-accent" />
|
|
{t('dashboard.translate.pdfMode.preserveLayout')}
|
|
</div>
|
|
<p className="mt-1 text-[9px] text-brand-dark/50 font-bold uppercase tracking-widest leading-relaxed dark:text-white/40">
|
|
{t('dashboard.translate.pdfMode.preserveLayoutDesc')}
|
|
</p>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPdfMode('text_only')}
|
|
className={cn(
|
|
'flex flex-col items-start rounded-2xl border-2 p-3 text-start transition-all',
|
|
pdfMode === 'text_only'
|
|
? 'border-brand-accent bg-brand-accent/5'
|
|
: 'border-black/5 bg-brand-muted/30 hover:border-brand-accent/20 dark:border-white/10 dark:bg-white/5'
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2 text-[11px] font-black uppercase tracking-tight text-brand-dark dark:text-white">
|
|
<Languages className="size-4 text-brand-accent" />
|
|
{t('dashboard.translate.pdfMode.textOnly')}
|
|
</div>
|
|
<p className="mt-1 text-[9px] text-brand-dark/50 font-bold uppercase tracking-widest leading-relaxed dark:text-white/40">
|
|
{t('dashboard.translate.pdfMode.textOnlyDesc')}
|
|
</p>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
disabled={!config.isConfigValid || submit.isSubmitting || !upload.file}
|
|
onClick={handleTranslate}
|
|
className={cn(
|
|
'premium-button w-full py-6 text-[11px] uppercase tracking-[0.3em] flex items-center justify-center gap-3 !rounded-2xl',
|
|
(!config.isConfigValid || submit.isSubmitting || !upload.file) && 'opacity-40 cursor-not-allowed'
|
|
)}
|
|
>
|
|
{submit.isSubmitting ? (
|
|
<><Loader2 className="size-5 animate-spin" />{t('dashboard.translate.actions.uploading')}</>
|
|
) : (
|
|
<>{t('landing.translate.startTranslation')}<ArrowRight size={18} className="text-brand-accent" /></>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-between px-6 text-[9px] font-black uppercase tracking-[0.2em] text-brand-dark/20 dark:text-white/20">
|
|
<span className="flex items-center gap-2">
|
|
<ShieldCheck size={12} /> {t('landing.translate.zeroRetention')}
|
|
</span>
|
|
<span className="flex items-center gap-2">
|
|
<Clock size={12} /> {t('landing.translate.filesDeleted')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── MONITOR (processing) ────────────────────────────── */}
|
|
{showProcessing && (
|
|
<div className="editorial-card p-10 bg-white border-none shadow-editorial h-full dark:bg-[#141414]">
|
|
<h4 className="text-[10px] font-black uppercase tracking-[0.4em] mb-12 flex items-center gap-3 text-brand-dark/30 dark:text-white/30">
|
|
<div className="w-2 h-2 bg-brand-accent rounded-full animate-ping" />
|
|
{t('dashboard.translate.liveMonitor')}
|
|
</h4>
|
|
|
|
{/* File summary */}
|
|
{(submit.fileName || upload.file?.name) && (
|
|
<div className="p-6 bg-brand-muted rounded-[32px] mb-10 flex items-center gap-5 border border-black/5 dark:bg-white/5 dark:border-white/5">
|
|
<div className="w-12 h-12 bg-white rounded-2xl flex items-center justify-center text-brand-accent shadow-sm dark:bg-[#1a1a1a]">
|
|
{(() => {
|
|
const name = submit.fileName || upload.file?.name || '';
|
|
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
|
const FileIcon = FILE_ICONS[ext] ?? FileText;
|
|
return <FileIcon size={24} />;
|
|
})()}
|
|
</div>
|
|
<div className="overflow-hidden">
|
|
<p className="text-[11px] font-black uppercase tracking-tight truncate text-brand-dark dark:text-white">
|
|
{submit.fileName || upload.file?.name}
|
|
</p>
|
|
<p className="text-[9px] text-brand-dark/40 font-bold uppercase tracking-widest mt-1 dark:text-white/40">
|
|
{upload.file ? `${fmt(upload.file.size)} ` : ''}{(submit.fileName || upload.file?.name || '').split('.').pop()?.toUpperCase()}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Config summary */}
|
|
<div className="space-y-8 mb-16 px-2">
|
|
<div className="flex justify-between items-center text-[10px] font-black uppercase tracking-[0.4em] text-brand-dark/30 dark:text-white/30">
|
|
<span>{t('dashboard.translate.language.source')}</span>
|
|
<span className="text-brand-dark dark:text-white">{srcLangName.toUpperCase()}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center text-[10px] font-black uppercase tracking-[0.4em] text-brand-dark/30 dark:text-white/30">
|
|
<span>{t('dashboard.translate.language.target')}</span>
|
|
<span className="text-brand-accent">{tgtLangName.toUpperCase()}</span>
|
|
</div>
|
|
{currentProvider && (
|
|
<div className="flex justify-between items-center text-[10px] font-black uppercase tracking-[0.4em] text-brand-dark/30 dark:text-white/30">
|
|
<span>{t('dashboard.translate.engine')}</span>
|
|
<span className="text-brand-dark dark:text-white">{currentProvider.label.toUpperCase()}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Quality progress */}
|
|
<div className="pt-10 border-t border-black/10 dark:border-white/10">
|
|
<div className="flex justify-between text-[10px] font-black uppercase tracking-[0.4em] mb-4">
|
|
<span className="text-brand-dark/30 dark:text-white/30">{t('dashboard.translate.quality')}</span>
|
|
<span className="text-brand-accent">{qualityLabel}</span>
|
|
</div>
|
|
<div className="h-2 bg-brand-muted rounded-full overflow-hidden p-0.5 dark:bg-white/10">
|
|
<div
|
|
className="h-full bg-brand-accent rounded-full transition-all duration-700"
|
|
style={{ width: `${Math.min(95, 40 + submit.progress * 0.55)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleNewTranslation}
|
|
className="w-full mt-16 py-5 border border-red-50 text-red-500 rounded-2xl text-[10px] font-black uppercase tracking-[0.3em] flex items-center justify-center gap-3 hover:bg-red-50 transition-all dark:border-red-900/30 dark:text-red-400 dark:hover:bg-red-950/30"
|
|
>
|
|
<RotateCcw size={16} /> {t('dashboard.translate.cancel')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── SUMMARY (complete) ──────────────────────────────── */}
|
|
{showComplete && (
|
|
<div className="editorial-card p-10 bg-white border-none shadow-editorial h-full dark:bg-[#141414]">
|
|
<h4 className="text-[10px] font-black uppercase tracking-[0.4em] mb-12 flex items-center gap-3 text-brand-dark/30 dark:text-white/30">
|
|
<CheckCircle2 size={14} className="text-emerald-500" />
|
|
{t('dashboard.translate.summary')}
|
|
</h4>
|
|
|
|
<div className="space-y-8 mb-16 px-2">
|
|
<div className="flex justify-between items-center text-[10px] font-black uppercase tracking-[0.4em] text-brand-dark/30 dark:text-white/30">
|
|
<span>{t('dashboard.translate.language.source')}</span>
|
|
<span className="text-brand-dark dark:text-white">{srcLangName.toUpperCase()}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center text-[10px] font-black uppercase tracking-[0.4em] text-brand-dark/30 dark:text-white/30">
|
|
<span>{t('dashboard.translate.language.target')}</span>
|
|
<span className="text-brand-accent">{tgtLangName.toUpperCase()}</span>
|
|
</div>
|
|
{currentProvider && (
|
|
<div className="flex justify-between items-center text-[10px] font-black uppercase tracking-[0.4em] text-brand-dark/30 dark:text-white/30">
|
|
<span>{t('dashboard.translate.engine')}</span>
|
|
<span className="text-brand-dark dark:text-white">{currentProvider.label.toUpperCase()}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="pt-10 border-t border-black/10 dark:border-white/10">
|
|
<div className="flex justify-between text-[10px] font-black uppercase tracking-[0.4em] mb-4">
|
|
<span className="text-brand-dark/30 dark:text-white/30">{t('dashboard.translate.quality')}</span>
|
|
<span className="text-brand-accent">{qualityLabel}</span>
|
|
</div>
|
|
<div className="h-2 bg-brand-muted rounded-full overflow-hidden p-0.5 dark:bg-white/10">
|
|
<div className="h-full bg-brand-accent rounded-full" style={{ width: '95%' }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── MOMENTO PROMO BANNER ──────────────────────────────── */}
|
|
{(showUpload || showConfiguring || showFailed) && (
|
|
<div className="mt-20 editorial-card p-12 bg-white border-none shadow-editorial flex flex-col md:flex-row items-center gap-10 group overflow-hidden relative dark:bg-[#141414]">
|
|
<div className="absolute -right-20 -top-20 w-64 h-64 bg-brand-accent/5 rounded-full blur-3xl group-hover:bg-brand-accent/10 transition-colors" />
|
|
|
|
<div className="w-24 h-24 bg-brand-dark rounded-[32px] flex items-center justify-center text-white text-5xl font-black shadow-2xl shrink-0 group-hover:rotate-12 transition-transform duration-500 dark:bg-brand-accent dark:text-brand-dark">
|
|
M
|
|
</div>
|
|
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-4 mb-4">
|
|
<span className="accent-pill !px-3 !py-1 text-[9px] italic">{t('memento.title')}</span>
|
|
<h3 className="text-2xl font-black uppercase tracking-tight text-brand-dark dark:text-white">{t('memento.title')}</h3>
|
|
</div>
|
|
<p className="text-sm text-brand-dark/40 font-medium leading-relaxed max-w-2xl dark:text-white/40">
|
|
{t('memento.slogan')}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-4 shrink-0 w-full md:w-auto">
|
|
<button className="premium-button px-10 py-4 text-[11px] uppercase tracking-widest !rounded-2xl">
|
|
{t('memento.ctaFree')}
|
|
</button>
|
|
<button className="px-10 py-4 border border-black/5 bg-brand-muted text-brand-dark/40 rounded-2xl text-[10px] font-black uppercase tracking-widest hover:text-brand-dark transition-all dark:border-white/10 dark:bg-white/5 dark:text-white/40 dark:hover:text-white">
|
|
{t('memento.ctaMore')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ═══ Sub-components ═══════════════════════════════════════════════ */
|
|
|
|
/** Compact file strip */
|
|
function FileStrip({ file, onRemove, onReplace, t }: { file: File; onRemove: () => void; onReplace: () => void; t: (key: string) => string }) {
|
|
const ext = file.name.split('.').pop()?.toLowerCase() ?? '';
|
|
const FileIcon = FILE_ICONS[ext] ?? FileText;
|
|
const color = FILE_COLORS[ext] ?? 'text-muted-foreground';
|
|
return (
|
|
<div className="flex items-center gap-3 rounded-[24px] border border-black/5 bg-brand-muted/30 px-4 py-3 dark:border-white/5 dark:bg-white/5">
|
|
<FileIcon className={`size-5 shrink-0 ${color}`} />
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<span className="truncate text-[11px] font-black uppercase tracking-tight text-brand-dark dark:text-white">{file.name}</span>
|
|
<span className="text-[9px] text-brand-dark/40 font-bold uppercase tracking-widest dark:text-white/40">{fmt(file.size)} .{ext.toUpperCase()}</span>
|
|
</div>
|
|
<button type="button" onClick={onReplace} className="flex shrink-0 items-center gap-1 rounded-xl px-2 py-1 text-[10px] font-black uppercase tracking-widest text-brand-dark/30 transition hover:bg-brand-muted hover:text-brand-dark dark:text-white/30 dark:hover:bg-white/10 dark:hover:text-white">
|
|
<Upload className="size-3.5" />{t('dashboard.translate.replace')}
|
|
</button>
|
|
<button type="button" aria-label="Remove" onClick={onRemove} className="flex size-7 shrink-0 items-center justify-center rounded-xl text-brand-dark/20 transition hover:bg-brand-muted hover:text-brand-dark dark:text-white/20 dark:hover:bg-white/10 dark:hover:text-white">
|
|
<X className="size-4" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Pipeline stepper */
|
|
function PipelineStepper({ activeIdx, t }: { activeIdx: number; t: (key: string) => string }) {
|
|
return (
|
|
<div className="flex items-start justify-between">
|
|
{PIPELINE_STEP_KEYS.map((stepKey, i) => {
|
|
const isActive = i === activeIdx;
|
|
const isDone = i < activeIdx;
|
|
const Icon = PIPELINE_ICONS[i];
|
|
return (
|
|
<div key={stepKey} className="flex flex-col items-center gap-1.5 relative" style={{ flex: i < PIPELINE_STEP_KEYS.length - 1 ? 1 : 'none' }}>
|
|
<div className="flex items-center w-full">
|
|
<div className={cn(
|
|
'flex size-9 shrink-0 items-center justify-center rounded-full transition-all duration-500',
|
|
isDone
|
|
? 'bg-primary text-primary-foreground shadow-md shadow-primary/20'
|
|
: isActive
|
|
? 'bg-primary text-primary-foreground shadow-lg shadow-primary/25 ring-[3px] ring-primary/20'
|
|
: 'bg-muted text-muted-foreground',
|
|
)}>
|
|
{isDone ? <CheckCircle2 className="size-4" /> : <Icon className={cn('size-4', isActive && 'animate-pulse')} />}
|
|
</div>
|
|
{i < PIPELINE_STEP_KEYS.length - 1 && (
|
|
<div className={cn('mx-1 h-[2px] flex-1 rounded-full transition-colors duration-500', i < activeIdx ? 'bg-primary' : 'bg-border')} />
|
|
)}
|
|
</div>
|
|
<span className={cn('text-[11px] font-medium transition-colors', isDone || isActive ? 'text-primary' : 'text-muted-foreground')}>
|
|
{t(stepKey)}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Small stat box */
|
|
function StatBox({ icon, value, label }: { icon: React.ReactNode; value: string; label: string }) {
|
|
return (
|
|
<div className="p-6 bg-brand-muted/30 rounded-3xl text-center border border-transparent hover:border-brand-accent/10 transition-all dark:bg-white/5 dark:border-white/5">
|
|
<div className="text-brand-accent flex justify-center mb-4">{icon}</div>
|
|
<p className="text-[12px] font-black text-brand-dark mb-1 uppercase tracking-tight dark:text-white">{value}</p>
|
|
<p className="text-[8px] font-black text-brand-dark/30 uppercase tracking-[0.2em] dark:text-white/30">{label}</p>
|
|
</div>
|
|
);
|
|
}
|