All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m11s
1113 lines
64 KiB
TypeScript
1113 lines
64 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import Link from 'next/link';
|
|
import {
|
|
ShieldCheck, Clock, ArrowRight, RotateCcw, Loader2,
|
|
FileSpreadsheet, FileText, Presentation, Upload, X,
|
|
Zap, CheckCircle2,
|
|
Search, Languages, Wrench, Activity,
|
|
Download, AlertTriangle, FileType,
|
|
Image as ImageIcon,
|
|
} from 'lucide-react';
|
|
import { useFileUpload } from './useFileUpload';
|
|
import { useTranslationConfig } from './useTranslationConfig';
|
|
import { useTranslationSubmit, getRecentJobs, fetchServerHistory, pushRecentJob, type RecentJob } from './useTranslationSubmit';
|
|
import { runTranslationJob } from './translationRunner';
|
|
import type { BatchItem } from './types';
|
|
import LanguageSelector from './LanguageSelector';
|
|
import { ProviderSelector } from './ProviderSelector';
|
|
import { GlossarySelector } from './GlossarySelector';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { useNotification } from '@/components/ui/notification';
|
|
import { useTranslationStore } from '@/lib/store';
|
|
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`;
|
|
}
|
|
|
|
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')}`;
|
|
}
|
|
|
|
/** "≈ 3 min" / "45 s" — honest remaining-time from the API estimate */
|
|
function formatRemaining(seconds: number | null): string {
|
|
if (seconds == null) return '—';
|
|
if (seconds < 60) return `≈ ${Math.max(1, Math.round(seconds))} s`;
|
|
return `≈ ${Math.round(seconds / 60)} min`;
|
|
}
|
|
|
|
/** Title with an italic accent word — two explicit keys, no string surgery */
|
|
function SplitTitle({ base, accent }: { base: string; accent: string }) {
|
|
return <>{base} <span className="italic">{accent}</span></>;
|
|
}
|
|
|
|
/* ── Page ────────────────────────────────────────────────────────── */
|
|
export default function TranslatePage() {
|
|
const upload = useFileUpload();
|
|
const config = useTranslationConfig(upload.files.length > 0);
|
|
const submit = useTranslationSubmit();
|
|
const { error: showError } = useNotification();
|
|
const { t } = useI18n();
|
|
const systemPrompt = useTranslationStore((s) => s.settings.systemPrompt);
|
|
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 [batch, setBatch] = useState<{ items: BatchItem[]; running: boolean } | null>(null);
|
|
const batchAbortRef = useRef(false);
|
|
const batchJobRef = useRef<string | null>(null);
|
|
const [elapsed, setElapsed] = useState(0);
|
|
const [recentJobs, setRecentJobs] = useState<RecentJob[]>([]);
|
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
const isPdf = upload.file?.name.toLowerCase().endsWith('.pdf') ?? false;
|
|
|
|
/* ── Human-friendly error messages ──────────────────────────── */
|
|
const humanFriendlyError = (raw: string | null): string => {
|
|
if (!raw) return t('dashboard.translate.error.unexpected');
|
|
const r = raw.toLowerCase();
|
|
if (r.includes('0 out of') || r.includes('0 textes') || r.includes('0 texts'))
|
|
return t('dashboard.translate.error.noResult');
|
|
if (r.includes('api key') || r.includes('api_key') || r.includes('unauthorized') || r.includes('401'))
|
|
return t('dashboard.translate.error.apiKey');
|
|
if (r.includes('quota') || r.includes('rate limit') || r.includes('429'))
|
|
return t('dashboard.translate.error.quota');
|
|
if (r.includes('timeout') || r.includes('timed out') || r.includes('connexion'))
|
|
return t('dashboard.translate.error.timeout');
|
|
if (r.includes('not found') || r.includes('404'))
|
|
return t('dashboard.translate.error.sessionExpired');
|
|
if (r.includes('empty') || r.includes('vide') || r.includes('no translatable'))
|
|
return t('dashboard.translate.error.empty');
|
|
if (r.includes('unsupported') || r.includes('format'))
|
|
return t('dashboard.translate.error.unsupported');
|
|
if (r.includes('lost connection') || r.includes('internet'))
|
|
return t('dashboard.translate.error.connection');
|
|
// Generic fallback — show raw but readable
|
|
return t('dashboard.translate.error.generic', { detail: raw.charAt(0).toUpperCase() + raw.slice(1) });
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (submit.error && submit.error !== lastErrorRef.current) {
|
|
lastErrorRef.current = submit.error;
|
|
showError({ title: t('dashboard.translate.error.title'), description: humanFriendlyError(submit.error) });
|
|
}
|
|
}, [submit.error, showError]);
|
|
|
|
// 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]);
|
|
|
|
// History: server list first (survives any device), localStorage as fallback
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
fetchServerHistory().then((server) => {
|
|
if (cancelled) return;
|
|
setRecentJobs(server.length > 0 ? server : getRecentJobs());
|
|
});
|
|
return () => { cancelled = true; };
|
|
}, [submit.status]);
|
|
|
|
const handleTranslate = async () => {
|
|
if (upload.files.length === 0 || !config.isConfigValid) return;
|
|
const cfg = config.getConfig();
|
|
if (isPdf) cfg.pdfMode = pdfMode;
|
|
if (upload.files.length === 1) {
|
|
await submit.submitTranslation(upload.files[0], cfg);
|
|
} else {
|
|
await handleBatch(cfg);
|
|
}
|
|
};
|
|
|
|
const updateBatchItem = (index: number, patch: Partial<BatchItem>) => {
|
|
setBatch((b) => (b ? { ...b, items: b.items.map((it, i) => (i === index ? { ...it, ...patch } : it)) } : b));
|
|
};
|
|
|
|
const handleBatch = async (cfg: ReturnType<typeof config.getConfig>) => {
|
|
const files = upload.files;
|
|
batchAbortRef.current = false;
|
|
setBatch({
|
|
running: true,
|
|
items: files.map((f) => ({ name: f.name, size: f.size, status: 'pending', progress: 0 })),
|
|
});
|
|
for (let i = 0; i < files.length; i++) {
|
|
if (batchAbortRef.current) {
|
|
updateBatchItem(i, { status: 'aborted' });
|
|
continue;
|
|
}
|
|
updateBatchItem(i, { status: 'processing', progress: 0 });
|
|
const result = await runTranslationJob(files[i], cfg, {
|
|
onJobId: (jobId) => { batchJobRef.current = jobId; },
|
|
onProgress: ({ progress }) => updateBatchItem(i, { progress }),
|
|
shouldAbort: () => batchAbortRef.current,
|
|
});
|
|
batchJobRef.current = null;
|
|
if (result.status === 'completed') {
|
|
updateBatchItem(i, { status: 'completed', jobId: result.jobId ?? undefined, progress: 100 });
|
|
pushRecentJob({ jobId: result.jobId ?? '', fileName: result.fileName, completedAt: Date.now() });
|
|
} else if (result.status === 'aborted') {
|
|
updateBatchItem(i, { status: 'aborted' });
|
|
} else {
|
|
updateBatchItem(i, { status: 'failed', error: result.error });
|
|
}
|
|
// small breather between jobs so the backend never sees a burst
|
|
if (i < files.length - 1) await new Promise((r) => setTimeout(r, 600));
|
|
}
|
|
setBatch((b) => (b ? { ...b, running: false } : b));
|
|
setRecentJobs(getRecentJobs());
|
|
};
|
|
|
|
const handleBatchAbort = () => {
|
|
batchAbortRef.current = true;
|
|
const jobId = batchJobRef.current;
|
|
if (jobId) {
|
|
const token = localStorage.getItem('token');
|
|
const headers: Record<string, string> = {};
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
fetch(`${API_BASE}/api/v1/translations/${jobId}/cancel`, { method: 'POST', headers }).catch(() => {});
|
|
}
|
|
};
|
|
|
|
const handleBatchClose = () => {
|
|
setBatch(null);
|
|
upload.removeFile();
|
|
setElapsed(0);
|
|
setRecentJobs(getRecentJobs());
|
|
};
|
|
|
|
const handleDownloadAll = () => {
|
|
const items = batch?.items ?? [];
|
|
let delay = 0;
|
|
for (const item of items) {
|
|
if (item.status === 'completed' && item.jobId) {
|
|
// stagger downloads so browsers don't block them as popups
|
|
const jobId = item.jobId;
|
|
setTimeout(() => { handleDownload(jobId); }, delay);
|
|
delay += 400;
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleRetry = async () => {
|
|
submit.reset();
|
|
// Small delay to ensure reset completes
|
|
await new Promise(r => setTimeout(r, 50));
|
|
await handleTranslate();
|
|
};
|
|
|
|
const handleCancel = async () => {
|
|
const ok = await submit.cancelJob();
|
|
if (ok) {
|
|
submit.reset();
|
|
setElapsed(0);
|
|
showError({ title: t('translate.cancelledTitle'), description: t('translate.cancelledDesc') });
|
|
} else {
|
|
showError({ title: t('translate.cancelFailedTitle'), description: t('translate.cancelFailedDesc') });
|
|
}
|
|
};
|
|
|
|
const handleNewTranslation = () => { batchAbortRef.current = true; setBatch(null); submit.reset(); upload.removeFile(); setElapsed(0); setRecentJobs(getRecentJobs()); };
|
|
const handleDownload = async (jobId: string = submit.jobId ?? '') => {
|
|
if (!jobId) return;
|
|
const token = localStorage.getItem('token');
|
|
const headers: Record<string, string> = {};
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
try {
|
|
const response = await fetch(`${API_BASE}/api/v1/download/${jobId}`, { headers });
|
|
if (!response.ok) {
|
|
showError({ title: t('dashboard.translate.error.title'), description: t('translate.downloadFailed') });
|
|
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);
|
|
} catch {
|
|
showError({ title: t('dashboard.translate.error.title'), description: t('translate.downloadFailed') });
|
|
}
|
|
};
|
|
|
|
/* ── Derived states ──────────────────────────────────────────── */
|
|
const isConfiguring = upload.files.length > 0 && submit.status === 'idle' && !submit.isSubmitting && batch === null;
|
|
const isProcessing = (submit.status === 'processing' || submit.isSubmitting) && submit.status !== 'completed';
|
|
const isCompleted = submit.status === 'completed';
|
|
const isFailed = submit.status === 'failed';
|
|
|
|
// Ctrl/Cmd+Enter launches the translation from anywhere on the page
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && upload.file && config.isConfigValid && !submit.isSubmitting && !isProcessing) {
|
|
e.preventDefault();
|
|
handleTranslate();
|
|
}
|
|
};
|
|
window.addEventListener('keydown', onKey);
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [upload.file, config.isConfigValid, submit.isSubmitting, isProcessing]);
|
|
|
|
const showBatch = batch !== null;
|
|
const showUpload = !upload.file && !isProcessing && !isCompleted && !isFailed && !showBatch;
|
|
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);
|
|
// Supported formats — informational chips, not file injections
|
|
const formatChips = [
|
|
{ label: t('translate.fileType.word'), icon: <FileText size={11} className="text-blue-500" /> },
|
|
{ label: t('translate.fileType.excel'), icon: <FileSpreadsheet size={11} className="text-green-500" /> },
|
|
{ label: t('translate.fileType.slides'), icon: <Presentation size={11} className="text-orange-500" /> },
|
|
{ label: t('translate.fileType.pdf'), icon: <FileType size={11} className="text-red-500" /> },
|
|
];
|
|
|
|
return (
|
|
<div className="min-h-full p-6 pb-24 lg:pb-8 lg:p-8 dark:bg-[#0a0a0a] selection:bg-brand-accent/10">
|
|
<div className="max-w-6xl mx-auto">
|
|
|
|
{/* ── HEADER (Landing Page Style) ───────────────────────── */}
|
|
<div className="mb-12">
|
|
{showProcessing ? (
|
|
<>
|
|
<span className="accent-pill mb-4 block w-fit italic">{t('translate.header.processing')}</span>
|
|
<h1 className="text-3xl md:text-4xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
|
|
<SplitTitle base={t('translate.header.aiActiveTitle')} accent={t('translate.header.aiActiveAccent')} />
|
|
</h1>
|
|
<p className="text-brand-dark/50 dark:text-white/50 text-sm font-light leading-relaxed">
|
|
{t('translate.header.aiActiveDesc')}
|
|
</p>
|
|
</>
|
|
) : showComplete ? (
|
|
<>
|
|
<span className="accent-pill mb-4 block w-fit italic">{t('translate.header.completed')}</span>
|
|
<h1 className="text-3xl md:text-4xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
|
|
<SplitTitle base={t('translate.header.completedTitleBase')} accent={t('translate.header.completedTitleAccent')} />
|
|
</h1>
|
|
<p className="text-brand-dark/50 dark:text-white/50 text-sm font-light leading-relaxed truncate max-w-xl">
|
|
{submit.fileName}
|
|
</p>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="accent-pill mb-4 block w-fit">{t('translate.header.workspace')}</span>
|
|
<h1 className="text-3xl md:text-4xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
|
|
<SplitTitle base={t('translate.header.translateDocBase')} accent={t('translate.header.translateDocAccent')} />
|
|
</h1>
|
|
<p className="text-brand-dark/50 dark:text-white/50 text-sm font-light leading-relaxed">
|
|
{t('translate.header.translateDocDesc')}
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── GRID: 7/5 SPLIT ───────────────────────────────────── */}
|
|
<div className="grid lg:grid-cols-12 gap-8 items-start">
|
|
|
|
{/* ═══════════════════════════════════════════════════════ */}
|
|
{/* LEFT (7 cols) — content swaps based on state */}
|
|
{/* ═══════════════════════════════════════════════════════ */}
|
|
<div className="lg:col-span-7 space-y-6">
|
|
|
|
{/* ── UPLOAD STATE: Editorial Dropzone ──────────────── */}
|
|
{showUpload && (
|
|
<div
|
|
className={cn(
|
|
'relative bg-white border-2 border-dashed border-brand-accent/15 dark:border-white/10 rounded-[32px] p-12 flex flex-col items-center justify-center text-center group cursor-pointer hover:border-brand-accent/40 dark:hover:border-brand-accent/40 hover:bg-brand-muted/10 dark:hover:bg-brand-muted/5 transition-all shadow-editorial dark:bg-[#141414] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-accent/60 focus-visible:border-brand-accent/60',
|
|
upload.isDragOver && 'border-brand-accent bg-brand-accent/10 scale-[1.01] shadow-lg'
|
|
)}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={t('translate.upload.ariaDropzone')}
|
|
aria-describedby="translate-format-hint"
|
|
onDragOver={upload.handleDragOver}
|
|
onDragLeave={upload.handleDragLeave}
|
|
onDrop={upload.handleDrop}
|
|
onClick={() => dropzoneInputRef.current?.click()}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
dropzoneInputRef.current?.click();
|
|
}
|
|
}}
|
|
>
|
|
<div className="absolute top-4 right-4 text-[10px] font-bold uppercase tracking-widest text-brand-dark/50 dark:text-white/50 bg-brand-muted dark:bg-white/5 px-3 py-1 rounded-full border border-black/[0.03] dark:border-white/[0.03]">
|
|
{t('translate.upload.nativeFormat')}
|
|
</div>
|
|
|
|
<div className="w-16 h-16 bg-brand-muted dark:bg-white/5 rounded-2xl flex items-center justify-center text-brand-goldink dark:text-brand-accent group-hover:scale-105 group-hover:bg-brand-dark dark:group-hover:bg-brand-accent group-hover:text-white dark:group-hover:text-brand-dark transition-all duration-300 mb-6 shadow-sm">
|
|
<Upload size={24} />
|
|
</div>
|
|
|
|
<h3 className="text-xl font-bold tracking-tight mb-2 text-brand-dark dark:text-white uppercase">
|
|
{t('landing.translate.dropHere')}
|
|
</h3>
|
|
<p className="text-xs text-brand-dark/50 dark:text-white/50 mb-8 font-medium">
|
|
{t('landing.translate.supportedFormats')}
|
|
</p>
|
|
|
|
{/* Supported formats — informational chips */}
|
|
<div id="translate-format-hint" className="flex flex-wrap justify-center gap-2.5">
|
|
{formatChips.map(f => (
|
|
<span
|
|
key={f.label}
|
|
className="flex items-center gap-2 px-3.5 py-2 bg-brand-muted dark:bg-white/10 rounded-xl text-[10px] font-bold uppercase tracking-wider text-brand-dark/60 dark:text-white/60 border border-transparent"
|
|
>
|
|
{f.icon} {f.label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
{/* Hidden file input for click-to-upload */}
|
|
<input
|
|
ref={dropzoneInputRef}
|
|
type="file"
|
|
accept=".xlsx,.docx,.pptx,.pdf"
|
|
multiple
|
|
className="hidden"
|
|
onChange={upload.handleFileSelect}
|
|
aria-hidden="true"
|
|
tabIndex={-1}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── BATCH QUEUE: sequential multi-file translations ── */}
|
|
{showBatch && batch && (
|
|
<div className="editorial-card p-8 bg-white shadow-editorial dark:bg-[#141414] space-y-6">
|
|
<div className="flex items-center justify-between gap-4 border-b border-black/5 pb-4 dark:border-white/5">
|
|
<div>
|
|
<h3 className="text-xl font-serif font-medium tracking-tight text-brand-dark dark:text-white">
|
|
{batch.running
|
|
? t('translate.batch.runningTitle')
|
|
: t('translate.batch.doneTitle', {
|
|
done: batch.items.filter((i) => i.status === 'completed').length,
|
|
total: batch.items.length,
|
|
})}
|
|
</h3>
|
|
<p className="mt-1 text-[11px] font-semibold uppercase tracking-wider text-brand-dark/50 dark:text-white/50">
|
|
{srcLangName} → {tgtLangName}
|
|
</p>
|
|
</div>
|
|
{batch.running ? (
|
|
<button
|
|
onClick={handleBatchAbort}
|
|
className="shrink-0 rounded-xl border border-red-200 px-4 py-2 text-[10px] font-bold uppercase tracking-wider text-red-500 transition-colors hover:bg-red-50 dark:border-red-900/40 dark:hover:bg-red-950/30"
|
|
>
|
|
{t('translate.batch.stop')}
|
|
</button>
|
|
) : (
|
|
<div className="flex shrink-0 gap-2">
|
|
<button
|
|
onClick={handleDownloadAll}
|
|
disabled={!batch.items.some((i) => i.status === 'completed' && i.jobId)}
|
|
className="rounded-xl border border-brand-accent/30 px-4 py-2 text-[10px] font-bold uppercase tracking-wider text-brand-goldink dark:text-brand-accent transition-colors hover:bg-brand-accent/10 disabled:opacity-40"
|
|
>
|
|
<Download size={11} className="mr-1 inline" /> {t('translate.batch.downloadAll')}
|
|
</button>
|
|
<button
|
|
onClick={handleBatchClose}
|
|
className="rounded-xl bg-brand-dark px-4 py-2 text-[10px] font-bold uppercase tracking-wider text-white transition-colors hover:bg-brand-accent dark:bg-brand-accent dark:text-brand-dark"
|
|
>
|
|
{t('translate.batch.newSession')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<ul className="space-y-2">
|
|
{batch.items.map((item, i) => (
|
|
<li
|
|
key={`${item.name}-${i}`}
|
|
className="flex items-center gap-3 rounded-2xl border border-black/5 bg-brand-muted/30 px-4 py-3 dark:border-white/5 dark:bg-white/5"
|
|
>
|
|
{item.status === 'processing' ? (
|
|
<Loader2 className="size-4 shrink-0 animate-spin text-brand-goldink dark:text-brand-accent" />
|
|
) : item.status === 'completed' ? (
|
|
<CheckCircle2 className="size-4 shrink-0 text-emerald-500" />
|
|
) : item.status === 'failed' ? (
|
|
<AlertTriangle className="size-4 shrink-0 text-red-500" />
|
|
) : item.status === 'aborted' ? (
|
|
<X className="size-4 shrink-0 text-brand-dark/40 dark:text-white/40" />
|
|
) : (
|
|
<span className="size-4 shrink-0 rounded-full border-2 border-brand-dark/20 dark:border-white/20" />
|
|
)}
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<span className="truncate text-xs font-bold text-brand-dark dark:text-white">{item.name}</span>
|
|
{item.status === 'processing' && (
|
|
<span className="mt-1 h-1 w-full overflow-hidden rounded-full bg-brand-dark/10 dark:bg-white/10">
|
|
<span
|
|
className="block h-full rounded-full bg-brand-accent transition-all"
|
|
style={{ width: `${item.progress}%` }}
|
|
/>
|
|
</span>
|
|
)}
|
|
{item.status === 'failed' && item.error && (
|
|
<span className="truncate text-[10px] text-red-500">{humanFriendlyError(item.error)}</span>
|
|
)}
|
|
</div>
|
|
{item.status === 'completed' && item.jobId ? (
|
|
<span className="flex shrink-0 items-center gap-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDownload(item.jobId!)}
|
|
aria-label={`${t('translate.download')} — ${item.name}`}
|
|
className="rounded-lg border border-black/10 px-2.5 py-1.5 text-[10px] font-bold uppercase tracking-wider text-brand-dark/60 dark:border-white/10 dark:text-white/60"
|
|
>
|
|
<Download size={11} />
|
|
</button>
|
|
<Link
|
|
href={`/dashboard/reviews/${item.jobId}`}
|
|
className="rounded-lg border border-brand-accent/25 px-2.5 py-1.5 text-[10px] font-bold uppercase tracking-wider text-brand-goldink dark:text-brand-accent"
|
|
>
|
|
{t('translate.recent.review')}
|
|
</Link>
|
|
</span>
|
|
) : (
|
|
<span className="w-10 shrink-0 text-right text-[10px] font-bold uppercase tracking-wider text-brand-dark/50 dark:text-white/50">
|
|
{item.status === 'processing'
|
|
? `${Math.round(item.progress)}%`
|
|
: item.status === 'completed'
|
|
? '100%'
|
|
: ''}
|
|
</span>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── RECENT JOBS: client-side history with review access ── */}
|
|
{showUpload && recentJobs.length > 0 && (
|
|
<div className="editorial-card p-6 bg-white dark:bg-[#141414] border-none shadow-editorial">
|
|
<h4 className="text-[11px] font-bold uppercase tracking-[0.18em] text-brand-dark/50 dark:text-white/50 pb-3 border-b border-black/[0.03] dark:border-white/[0.03]">
|
|
{t('translate.recent.title')}
|
|
</h4>
|
|
<ul className="divide-y divide-black/[0.03] dark:divide-white/[0.03]">
|
|
{recentJobs.slice(0, 6).map((job) => (
|
|
<li key={job.jobId} className="flex items-center justify-between gap-3 py-3">
|
|
<span className="flex min-w-0 items-center gap-2.5 text-xs font-semibold text-brand-dark dark:text-white">
|
|
<FileText className="size-3.5 shrink-0 text-brand-goldink dark:text-brand-accent" />
|
|
<span className="truncate">{job.fileName || job.jobId}</span>
|
|
</span>
|
|
<span className="flex shrink-0 items-center gap-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDownload(job.jobId)}
|
|
className="rounded-lg px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-brand-dark/60 dark:text-white/60 border border-black/10 dark:border-white/10 hover:bg-brand-muted/50 transition-colors"
|
|
>
|
|
<Download size={11} className="inline" />
|
|
</button>
|
|
<Link
|
|
href={`/dashboard/reviews/${job.jobId}`}
|
|
className="rounded-lg px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-brand-goldink dark:text-brand-accent border border-brand-accent/25 hover:bg-brand-accent/10 transition-colors"
|
|
>
|
|
{t('translate.recent.review')}
|
|
</Link>
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── CONFIGURING STATE: File indicator ──────────────── */}
|
|
{showConfiguring && (
|
|
<div className="editorial-card p-8 bg-white border-none shadow-editorial dark:bg-[#141414] space-y-6">
|
|
<h4 className="text-[10px] font-bold uppercase tracking-[0.2em] text-brand-dark/30 dark:text-white/30 border-b border-black/5 dark:border-white/5 pb-4">
|
|
{t('landing.translate.sourceDocument') || 'Document Source'}
|
|
</h4>
|
|
<FileStrip file={upload.file!} onRemove={upload.removeFile} onReplace={() => replaceInputRef.current?.click()} t={t} />
|
|
<input ref={replaceInputRef} type="file" accept=".xlsx,.docx,.pptx,.pdf" multiple className="hidden" onChange={upload.handleFileSelect} />
|
|
{upload.error && <p className="mt-2 text-xs text-destructive">{t(upload.error)}</p>}
|
|
{upload.files.length > 1 && (
|
|
<ul className="space-y-2">
|
|
{upload.files.map((f, i) => (
|
|
<li key={`${f.name}-${f.lastModified}-${i}`} className="flex items-center gap-3 rounded-2xl border border-black/5 bg-brand-muted/30 px-4 py-2.5 dark:border-white/5 dark:bg-white/5">
|
|
{(() => {
|
|
const ext = f.name.split('.').pop()?.toLowerCase() ?? '';
|
|
const FileIcon = FILE_ICONS[ext] ?? FileText;
|
|
return <FileIcon className={`size-4 shrink-0 ${FILE_COLORS[ext] ?? ''}`} />;
|
|
})()}
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<span className="truncate text-[11px] font-bold text-brand-dark dark:text-white">{f.name}</span>
|
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-brand-dark/50 dark:text-white/50">{fmt(f.size)}</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => upload.removeAt(i)}
|
|
aria-label={`${t('dashboard.translate.replace')} — ${f.name}`}
|
|
className="flex size-6 shrink-0 items-center justify-center rounded-lg text-brand-dark/40 transition hover:bg-brand-muted hover:text-brand-dark dark:text-white/40 dark:hover:bg-white/10"
|
|
>
|
|
<X className="size-3.5" />
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Desktop Submit Button & Actions (shown when not processing/complete) ── */}
|
|
{(showUpload || showConfiguring) && (
|
|
<div className="hidden lg:block editorial-card p-6 bg-white dark:bg-[#141414] border-none shadow-editorial space-y-4">
|
|
<button
|
|
disabled={!config.isConfigValid || submit.isSubmitting || upload.files.length === 0}
|
|
onClick={handleTranslate}
|
|
className={cn(
|
|
'w-full py-4 text-xs font-bold uppercase tracking-[0.18em] flex items-center justify-center gap-2 rounded-2xl transition-all shadow-sm active:scale-98',
|
|
config.isConfigValid && upload.files.length > 0 && !submit.isSubmitting
|
|
? 'bg-brand-dark text-white hover:bg-brand-accent dark:bg-brand-accent dark:text-brand-dark hover:shadow-xl cursor-pointer'
|
|
: 'bg-brand-muted/70 text-brand-dark/40 dark:bg-white/5 dark:text-white/30 cursor-not-allowed border border-black/[0.03] dark:border-white/[0.03]'
|
|
)}
|
|
>
|
|
{submit.isSubmitting ? (
|
|
<><Loader2 className="size-4 animate-spin" /> {t('translate.submit')}</>
|
|
) : upload.files.length > 1 ? (
|
|
<>{t('translate.startBatch', { count: upload.files.length })} <ArrowRight size={13} className="text-brand-goldink dark:text-brand-accent" /></>
|
|
) : (
|
|
<>{t('translate.startTranslation')} <ArrowRight size={13} className={upload.files.length > 0 ? 'text-brand-goldink dark:text-brand-accent' : 'opacity-20'} /></>
|
|
)}
|
|
</button>
|
|
|
|
{upload.files.length === 0 && (
|
|
<p className="text-center text-[10px] text-brand-dark/50 dark:text-white/50 font-semibold uppercase tracking-wider">{t('translate.pleaseLoadFile')}</p>
|
|
)}
|
|
{upload.files.length > 0 && !config.targetLang && (
|
|
<p className="text-center text-[10px] text-brand-dark/50 dark:text-white/50 font-semibold uppercase tracking-wider">{t('translate.chooseTargetLang')}</p>
|
|
)}
|
|
{upload.files.length > 0 && config.targetLang && config.sourceLang !== 'auto' && config.sourceLang === config.targetLang && (
|
|
<p className="text-center text-[10px] text-amber-600 dark:text-amber-400 font-semibold uppercase tracking-wider">{t('translate.sameLanguageError')}</p>
|
|
)}
|
|
|
|
<div className="flex justify-between gap-4 text-[11px] font-semibold uppercase tracking-[0.06em] text-brand-dark/55 dark:text-white/55 border-t border-black/5 dark:border-white/5 pt-4">
|
|
<span className="flex items-center gap-1.5">
|
|
<ShieldCheck size={13} className="text-brand-goldink dark:text-brand-accent" /> {t('landing.translate.zeroRetention') || 'Rétention Zéro'}
|
|
</span>
|
|
<span className="flex items-center gap-1.5">
|
|
<Clock size={13} className="text-brand-goldink dark:text-brand-accent" /> {t('landing.translate.filesDeleted') || 'Fichiers supprimés post-traitement'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── PROCESSING STATE: Rich progress ───────────────── */}
|
|
{showProcessing && (
|
|
<div className="editorial-card p-12 h-full border-none shadow-editorial bg-white dark:bg-[#141414] space-y-12">
|
|
<div className="flex items-center gap-6">
|
|
<div className="w-16 h-16 bg-brand-muted dark:bg-white/5 rounded-2xl flex items-center justify-center text-brand-goldink dark:text-brand-accent border border-brand-accent/10 animate-pulse">
|
|
<Activity size={32} />
|
|
</div>
|
|
<div className="text-left">
|
|
<h3 className="text-2xl font-serif font-medium text-brand-dark dark:text-white tracking-tight">
|
|
{t('translate.contextEngineActive')}
|
|
</h3>
|
|
<p className="text-[11px] text-brand-dark/50 dark:text-white/50 font-semibold uppercase tracking-wider mt-1 truncate">
|
|
{submit.fileName || upload.file?.name}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress Line with step icons */}
|
|
<div className="relative h-2 bg-brand-muted dark:bg-white/5 rounded-full my-12">
|
|
<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 dark:text-brand-dark font-bold'
|
|
: 'bg-brand-muted text-brand-dark/25 dark:bg-[#1f1f1f] 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 mt-12 pt-6">
|
|
<span className="text-[11px] font-semibold text-brand-dark/50 dark:text-white/50 uppercase tracking-[0.2em]">
|
|
{activeStepIdx < 2 ? t('translate.phase1') : t('translate.phase2')}
|
|
</span>
|
|
<span className="text-7xl font-serif font-medium text-brand-dark dark:text-white leading-none" aria-live="polite">
|
|
{Math.round(submit.progress)}%
|
|
</span>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-4 pt-12 border-t border-black/5 dark:border-white/5">
|
|
<StatBox icon={<Activity size={18} />} value={`${Math.round(submit.progress)}%`} label={t('translate.stat.progress')} />
|
|
<StatBox icon={<Clock size={18} />} value={formatRemaining(submit.estimatedRemaining)} label={t('translate.stat.remaining')} />
|
|
<StatBox icon={<Clock size={18} />} value={formatElapsed(elapsed)} label={t('translate.stat.elapsed')} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── COMPLETE STATE: Success with download ─────────── */}
|
|
{showComplete && (
|
|
<div className="editorial-card p-12 h-full border-none shadow-editorial bg-white dark:bg-[#141414] flex flex-col space-y-12">
|
|
<div className="p-8 bg-brand-accent/5 border border-brand-accent/10 rounded-[32px] flex items-center justify-between 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 className="text-left">
|
|
<p className="text-[13px] font-bold uppercase tracking-[0.1em] text-brand-dark dark:text-white">
|
|
{t('translate.header.completedTitleBase')} {t('translate.header.completedTitleAccent')}
|
|
</p>
|
|
<p className="text-[11px] text-brand-dark/50 dark:text-white/50 font-semibold uppercase mt-1 tracking-wider max-w-[300px] truncate">
|
|
{submit.fileName}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 flex flex-col items-center justify-center py-16 bg-brand-muted/20 dark:bg-white/5 rounded-[40px] border border-black/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 cursor-pointer hover:scale-[1.02] active:scale-95"
|
|
>
|
|
<Download size={28} className="group-hover:translate-y-1 transition-transform" />
|
|
{t('translate.download')}
|
|
</button>
|
|
<Link
|
|
href={`/dashboard/reviews/${submit.jobId}`}
|
|
className="mb-6 flex items-center gap-2 rounded-2xl border border-brand-accent/30 px-6 py-3 text-xs font-bold uppercase tracking-[0.15em] text-brand-goldink dark:text-brand-accent transition-colors hover:bg-brand-accent/10"
|
|
>
|
|
<Search size={14} />
|
|
{t('translate.reviewCta')}
|
|
</Link>
|
|
<button
|
|
onClick={handleNewTranslation}
|
|
className="text-[11px] font-bold uppercase tracking-[0.2em] text-brand-dark/50 hover:text-brand-dark dark:text-white/50 dark:hover:text-white transition-colors"
|
|
>
|
|
{t('translate.newTranslation')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── FAILED STATE ───────────────────────────────────── */}
|
|
{showFailed && (
|
|
<div className="editorial-card p-10 bg-white dark:bg-[#141414] border-none shadow-editorial space-y-6">
|
|
<div className="rounded-[24px] bg-red-50 border-2 border-red-200 dark:bg-red-950/20 dark:border-red-900/30 p-6" role="alert">
|
|
<div className="flex items-start gap-4">
|
|
<div className="w-10 h-10 rounded-2xl bg-red-100 dark:bg-red-900/30 flex items-center justify-center shrink-0">
|
|
<AlertTriangle className="size-5 text-red-500" />
|
|
</div>
|
|
<div className="flex-1 min-w-0 text-left">
|
|
<p className="text-sm font-bold uppercase tracking-tight text-red-600 dark:text-red-400 mb-2">{t('translate.failedTitle')}</p>
|
|
<p className="text-xs text-red-600/80 dark:text-red-300/80 leading-relaxed font-medium">{humanFriendlyError(submit.error)}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{(submit.fileName || upload.file?.name) && upload.file && (
|
|
<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 className="flex flex-col gap-3">
|
|
{upload.file && config.isConfigValid && (
|
|
<button
|
|
onClick={handleRetry}
|
|
className="premium-button w-full py-5 text-[12px] uppercase tracking-[0.25em] flex items-center justify-center gap-3 !rounded-2xl cursor-pointer hover:scale-[1.01] active:scale-98"
|
|
>
|
|
<RotateCcw size={18} />
|
|
{t('translate.retry')}
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={handleNewTranslation}
|
|
className="w-full py-4 border border-black/10 dark:border-white/10 rounded-2xl text-[11px] font-bold uppercase tracking-[0.2em] text-brand-dark/50 dark:text-white/50 hover:text-brand-dark dark:hover:text-white transition-all flex items-center justify-center gap-3 cursor-pointer hover:bg-brand-muted/30 dark:hover:bg-white/5"
|
|
title={t('translate.leaveScreenHint')}
|
|
>
|
|
<Upload size={16} />
|
|
{t('translate.uploadAnother')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ═══════════════════════════════════════════════════════ */}
|
|
{/* RIGHT (5 cols) — Config / Monitor / Summary */}
|
|
{/* ═══════════════════════════════════════════════════════ */}
|
|
<div className="lg:col-span-5 space-y-6">
|
|
|
|
{/* ── CONFIG (upload / configuring / failed) ──────────── */}
|
|
{(showUpload || showConfiguring || showFailed) && !showBatch && (
|
|
<div className="editorial-card bg-white dark:bg-[#141414] border-none shadow-editorial overflow-hidden flex flex-col lg:sticky lg:top-8 lg:max-h-[calc(100vh-6rem)]">
|
|
{/* Scrollable config content */}
|
|
<div className="flex-1 overflow-y-auto p-6 space-y-5">
|
|
<h4 className="text-[11px] font-bold uppercase tracking-[0.18em] text-brand-dark/50 dark:text-white/50 pb-3 border-b border-black/[0.03] dark:border-white/[0.03]">
|
|
{t('landing.translate.configuration') || 'Configuration'}
|
|
</h4>
|
|
|
|
<div className="space-y-6">
|
|
<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}
|
|
/>
|
|
|
|
{/* Active mode badge */}
|
|
{config.provider && (
|
|
<div className="flex items-center gap-2">
|
|
<span className={cn(
|
|
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-[0.1em]",
|
|
config.mode === 'llm'
|
|
? "bg-brand-accent/10 text-brand-goldink dark:text-brand-accent border border-brand-accent/20"
|
|
: "bg-brand-muted/50 text-brand-dark/50 dark:bg-white/5 dark:text-white/50 border border-transparent"
|
|
)}>
|
|
{config.mode === 'llm' ? (
|
|
<><Zap size={10} /> {t('dashboard.translate.modeAI')}</>
|
|
) : (
|
|
<><Languages size={10} /> {t('dashboard.translate.modeClassic')}</>
|
|
)}
|
|
</span>
|
|
{config.mode === 'classic' && config.isPro && (
|
|
<span className="text-[10px] text-brand-dark/50 dark:text-white/50 italic font-medium leading-none">
|
|
{t('dashboard.translate.glossaryLLMHint')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Context guidelines indicator — ties the glossaries tab to this flow */}
|
|
{config.mode === 'llm' && !!systemPrompt?.trim() && (
|
|
<Link
|
|
href="/dashboard/glossaries"
|
|
className="flex items-center gap-2 rounded-xl border border-brand-accent/25 bg-brand-accent/5 px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-brand-goldink dark:text-brand-accent transition-colors hover:bg-brand-accent/10"
|
|
>
|
|
<FileText className="size-3.5 shrink-0" />
|
|
{t('translate.contextActive')}
|
|
</Link>
|
|
)}
|
|
|
|
{/* Glossary selector — Pro only; hidden entirely for free users */}
|
|
{config.isPro && (
|
|
<GlossarySelector
|
|
sourceLang={config.sourceLang}
|
|
targetLang={config.targetLang}
|
|
isPro={config.isPro}
|
|
mode={config.mode}
|
|
glossaryId={config.glossaryId}
|
|
onChange={config.setGlossaryId}
|
|
disabled={submit.isSubmitting}
|
|
/>
|
|
)}
|
|
|
|
{/* Translate Images — LLM mode only; hidden for free users */}
|
|
{config.isPro && (
|
|
<div className="bg-brand-muted/30 dark:bg-white/[0.02] border border-black/[0.03] dark:border-white/[0.03] p-4 rounded-xl space-y-3">
|
|
<div className="flex justify-between items-center">
|
|
<div className="flex items-center gap-2">
|
|
<ImageIcon className="size-3.5 text-brand-goldink dark:text-brand-accent shrink-0" />
|
|
<span className="text-[10px] font-black uppercase tracking-[0.1em] text-brand-dark dark:text-white">
|
|
{t('dashboard.translate.translateImages') || "Traduire les images"}
|
|
</span>
|
|
</div>
|
|
<Switch
|
|
checked={config.translateImages && config.mode === 'llm'}
|
|
onCheckedChange={config.setTranslateImages}
|
|
disabled={submit.isSubmitting || config.mode === 'classic'}
|
|
aria-label={t('dashboard.translate.translateImages') || "Traduire les images"}
|
|
/>
|
|
</div>
|
|
|
|
{config.mode === 'classic' ? (
|
|
<div className="p-2.5 bg-brand-dark/5 dark:bg-white/5 rounded-lg text-center">
|
|
<span className="text-[10px] font-semibold uppercase text-brand-dark/50 dark:text-white/50 block">
|
|
{t('translate.unavailableStandard')}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<div className="px-1">
|
|
<span className="text-[10px] font-medium text-brand-dark/50 dark:text-white/50 block leading-normal">
|
|
{t('dashboard.translate.translateImagesDesc') || "Détecter et traduire automatiquement les textes incrustés dans vos images."}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
)}
|
|
|
|
{/* PDF mode selector */}
|
|
{isPdf && (
|
|
<div className="space-y-2 text-left">
|
|
<label className="text-[10px] font-bold text-brand-dark/50 dark:text-white/50 uppercase tracking-[0.15em] block mb-2">
|
|
{t('dashboard.translate.pdfMode.title') || 'Mode PDF'}
|
|
</label>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setPdfMode('layout')}
|
|
aria-pressed={pdfMode === 'layout'}
|
|
className={cn(
|
|
'flex flex-col items-start rounded-2xl border p-3.5 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-[10px] font-bold uppercase tracking-tight text-brand-dark dark:text-white">
|
|
<FileText className="size-3.5 text-brand-goldink dark:text-brand-accent" />
|
|
{t('dashboard.translate.pdfMode.preserveLayout') || 'Mise en page'}
|
|
</div>
|
|
<p className="mt-1.5 text-[10px] text-brand-dark/50 dark:text-white/50 font-medium leading-relaxed">
|
|
{t('dashboard.translate.pdfMode.preserveLayoutDesc')}
|
|
</p>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPdfMode('text_only')}
|
|
aria-pressed={pdfMode === 'text_only'}
|
|
className={cn(
|
|
'flex flex-col items-start rounded-2xl border p-3.5 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-[10px] font-bold uppercase tracking-tight text-brand-dark dark:text-white">
|
|
<Languages className="size-3.5 text-brand-goldink dark:text-brand-accent" />
|
|
{t('translate.textOnly')}
|
|
</div>
|
|
<p className="mt-1.5 text-[10px] text-brand-dark/50 dark:text-white/50 font-medium leading-relaxed">
|
|
{t('dashboard.translate.pdfMode.textOnlyDesc')}
|
|
</p>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── MONITOR (processing) ────────────────────────────── */}
|
|
{showProcessing && (
|
|
<div className="editorial-card p-6 bg-white dark:bg-[#141414] border-none shadow-editorial h-full">
|
|
<h4 className="text-[11px] font-bold uppercase tracking-[0.18em] mb-8 flex items-center gap-3 text-brand-dark/50 dark:text-white/50 pb-3 border-b border-black/[0.03] dark:border-white/[0.03]">
|
|
<div className="w-2 h-2 bg-brand-accent rounded-full animate-ping" aria-hidden="true" />
|
|
{t('translate.monitor')}
|
|
</h4>
|
|
|
|
{/* File summary */}
|
|
{(submit.fileName || upload.file?.name) && (
|
|
<div className="p-4 bg-brand-muted dark:bg-white/5 rounded-2xl mb-8 flex items-center gap-4 border border-black/5 dark:border-white/5">
|
|
<div className="w-10 h-10 bg-white dark:bg-[#1a1a1a] rounded-xl flex items-center justify-center text-brand-goldink dark:text-brand-accent shadow-sm">
|
|
{(() => {
|
|
const name = submit.fileName || upload.file?.name || '';
|
|
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
|
const FileIcon = FILE_ICONS[ext] ?? FileText;
|
|
return <FileIcon size={20} className={FILE_COLORS[ext]} />;
|
|
})()}
|
|
</div>
|
|
<div className="overflow-hidden text-left">
|
|
<p className="text-xs font-bold truncate text-brand-dark dark:text-white">
|
|
{submit.fileName || upload.file?.name}
|
|
</p>
|
|
<p className="text-[10px] text-brand-dark/50 dark:text-white/50 font-semibold uppercase tracking-wider mt-1">
|
|
{upload.file ? `${fmt(upload.file.size)} ` : ''}{(submit.fileName || upload.file?.name || '').split('.').pop()?.toUpperCase()}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Config summary */}
|
|
<div className="space-y-6 mb-8 px-2 text-left">
|
|
<div className="flex justify-between items-center text-[11px] font-semibold uppercase tracking-[0.15em] text-brand-dark/50 dark:text-white/50">
|
|
<span>{t('translate.monitor.source')}</span>
|
|
<span className="text-brand-dark dark:text-white normal-case">{srcLangName}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center text-[11px] font-semibold uppercase tracking-[0.15em] text-brand-dark/50 dark:text-white/50">
|
|
<span>{t('translate.monitor.target')}</span>
|
|
<span className="text-brand-goldink dark:text-brand-accent normal-case">{tgtLangName}</span>
|
|
</div>
|
|
{currentProvider && (
|
|
<div className="flex justify-between items-center text-[11px] font-semibold uppercase tracking-[0.15em] text-brand-dark/50 dark:text-white/50">
|
|
<span>{t('translate.monitor.engine')}</span>
|
|
<span className="text-brand-dark dark:text-white normal-case">{currentProvider.label}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleCancel}
|
|
className="w-full mt-8 py-3.5 border border-red-200 dark:border-red-900/40 text-red-500 rounded-2xl text-[10px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-2 hover:bg-red-50 dark:hover:bg-red-950/30 transition-all cursor-pointer"
|
|
>
|
|
<X size={13} />
|
|
{t('translate.cancelAction')}
|
|
</button>
|
|
<button
|
|
onClick={handleNewTranslation}
|
|
title={t('translate.leaveScreenHint')}
|
|
className="w-full mt-2 py-2.5 text-[10px] font-bold uppercase tracking-[0.2em] text-brand-dark/50 dark:text-white/50 hover:text-brand-dark dark:hover:text-white transition-colors cursor-pointer"
|
|
>
|
|
{t('translate.leaveScreen')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── SUMMARY (complete) ──────────────────────────────── */}
|
|
{showComplete && (
|
|
<div className="editorial-card p-6 bg-white dark:bg-[#141414] border-none shadow-editorial h-full">
|
|
<h4 className="text-[11px] font-bold uppercase tracking-[0.18em] mb-8 flex items-center gap-3 text-brand-dark/50 dark:text-white/50 pb-3 border-b border-black/[0.03] dark:border-white/[0.03]">
|
|
<CheckCircle2 size={14} className="text-emerald-500" />
|
|
{t('translate.summary')}
|
|
</h4>
|
|
|
|
<div className="space-y-6 mb-8 px-2 text-left">
|
|
<div className="flex justify-between items-center text-[11px] font-semibold uppercase tracking-[0.15em] text-brand-dark/50 dark:text-white/50">
|
|
<span>{t('translate.monitor.source')}</span>
|
|
<span className="text-brand-dark dark:text-white normal-case">{srcLangName}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center text-[11px] font-semibold uppercase tracking-[0.15em] text-brand-dark/50 dark:text-white/50">
|
|
<span>{t('translate.monitor.target')}</span>
|
|
<span className="text-brand-goldink dark:text-brand-accent normal-case">{tgtLangName}</span>
|
|
</div>
|
|
{currentProvider && (
|
|
<div className="flex justify-between items-center text-[11px] font-semibold uppercase tracking-[0.15em] text-brand-dark/50 dark:text-white/50">
|
|
<span>{t('translate.monitor.engine')}</span>
|
|
<span className="text-brand-dark dark:text-white normal-case">{currentProvider.label}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="pt-6 border-t border-black/5 dark:border-white/5 text-left">
|
|
<div className="flex justify-between items-center text-[11px] font-semibold text-brand-dark/50 dark:text-white/50">
|
|
<span className="flex items-center gap-1.5">
|
|
<ShieldCheck size={13} className="text-brand-goldink dark:text-brand-accent" /> {t('landing.translate.zeroRetention') || 'Rétention Zéro'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile Sticky Action Bar (visible on mobile, hidden on lg) */}
|
|
{(showUpload || showConfiguring) && !showBatch && (
|
|
<div className="block lg:hidden fixed bottom-0 left-0 right-0 z-40 p-4 bg-white/90 dark:bg-[#141414]/90 backdrop-blur-md border-t border-black/5 dark:border-white/5 shadow-lg">
|
|
<button
|
|
disabled={!config.isConfigValid || submit.isSubmitting || upload.files.length === 0}
|
|
onClick={handleTranslate}
|
|
className={cn(
|
|
'w-full py-4 text-xs font-bold uppercase tracking-[0.15em] flex items-center justify-center gap-2 rounded-xl transition-all active:scale-98',
|
|
config.isConfigValid && upload.files.length > 0 && !submit.isSubmitting
|
|
? 'bg-brand-dark text-white hover:bg-brand-accent dark:bg-brand-accent dark:text-brand-dark cursor-pointer'
|
|
: 'bg-brand-muted/70 text-brand-dark/40 dark:bg-white/5 dark:text-white/30 cursor-not-allowed'
|
|
)}
|
|
>
|
|
{submit.isSubmitting ? (
|
|
<><Loader2 className="size-4 animate-spin" /> {t('translate.submit')}</>
|
|
) : (
|
|
<>{t('translate.startTranslation')} <ArrowRight size={13} className={upload.file ? 'text-brand-goldink dark:text-brand-accent' : 'opacity-20'} /></>
|
|
)}
|
|
</button>
|
|
{upload.files.length > 0 && config.targetLang && config.sourceLang !== 'auto' && config.sourceLang === config.targetLang && (
|
|
<p className="text-center text-[10px] text-amber-600 dark:text-amber-400 font-semibold uppercase tracking-wider mt-2">{t('translate.sameLanguageError')}</p>
|
|
)}
|
|
</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-[10px] text-brand-dark/45 font-bold uppercase tracking-widest dark:text-white/45">{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/50 transition hover:bg-brand-muted hover:text-brand-dark dark:text-white/50 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/40 transition hover:bg-brand-muted hover:text-brand-dark dark:text-white/40 dark:hover:bg-white/10 dark:hover:text-white">
|
|
<X className="size-4" />
|
|
</button>
|
|
</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-goldink dark:text-brand-accent flex justify-center mb-4">{icon}</div>
|
|
<p className="text-sm font-black text-brand-dark mb-1 uppercase tracking-tight dark:text-white">{value}</p>
|
|
<p className="text-[10px] font-black text-brand-dark/50 uppercase tracking-[0.12em] dark:text-white/50">{label}</p>
|
|
</div>
|
|
);
|
|
}
|