'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 = { xlsx: FileSpreadsheet, docx: FileText, pptx: Presentation, pdf: FileType, }; const FILE_COLORS: Record = { 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} {accent}; } /* ── 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(null); const replaceInputRef = useRef(null); const dropzoneInputRef = useRef(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(null); const [elapsed, setElapsed] = useState(0); const [recentJobs, setRecentJobs] = useState([]); const timerRef = useRef | 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) => { setBatch((b) => (b ? { ...b, items: b.items.map((it, i) => (i === index ? { ...it, ...patch } : it)) } : b)); }; const handleBatch = async (cfg: ReturnType) => { 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 = {}; 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 = {}; 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: }, { label: t('translate.fileType.excel'), icon: }, { label: t('translate.fileType.slides'), icon: }, { label: t('translate.fileType.pdf'), icon: }, ]; return (
{/* ── HEADER (Landing Page Style) ───────────────────────── */}
{showProcessing ? ( <> {t('translate.header.processing')}

{t('translate.header.aiActiveDesc')}

) : showComplete ? ( <> {t('translate.header.completed')}

{submit.fileName}

) : ( <> {t('translate.header.workspace')}

{t('translate.header.translateDocDesc')}

)}
{/* ── GRID: 7/5 SPLIT ───────────────────────────────────── */}
{/* ═══════════════════════════════════════════════════════ */} {/* LEFT (7 cols) — content swaps based on state */} {/* ═══════════════════════════════════════════════════════ */}
{/* ── UPLOAD STATE: Editorial Dropzone ──────────────── */} {showUpload && (
dropzoneInputRef.current?.click()} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); dropzoneInputRef.current?.click(); } }} >
{t('translate.upload.nativeFormat')}

{t('landing.translate.dropHere')}

{t('landing.translate.supportedFormats')}

{/* Supported formats — informational chips */}
{formatChips.map(f => ( {f.icon} {f.label} ))}
{/* Hidden file input for click-to-upload */}
)} {/* ── BATCH QUEUE: sequential multi-file translations ── */} {showBatch && batch && (

{batch.running ? t('translate.batch.runningTitle') : t('translate.batch.doneTitle', { done: batch.items.filter((i) => i.status === 'completed').length, total: batch.items.length, })}

{srcLangName} → {tgtLangName}

{batch.running ? ( ) : (
)}
    {batch.items.map((item, i) => (
  • {item.status === 'processing' ? ( ) : item.status === 'completed' ? ( ) : item.status === 'failed' ? ( ) : item.status === 'aborted' ? ( ) : ( )}
    {item.name} {item.status === 'processing' && ( )} {item.status === 'failed' && item.error && ( {humanFriendlyError(item.error)} )}
    {item.status === 'completed' && item.jobId ? ( {t('translate.recent.review')} ) : ( {item.status === 'processing' ? `${Math.round(item.progress)}%` : item.status === 'completed' ? '100%' : ''} )}
  • ))}
)} {/* ── RECENT JOBS: client-side history with review access ── */} {showUpload && recentJobs.length > 0 && (

{t('translate.recent.title')}

    {recentJobs.slice(0, 6).map((job) => (
  • {job.fileName || job.jobId} {t('translate.recent.review')}
  • ))}
)} {/* ── CONFIGURING STATE: File indicator ──────────────── */} {showConfiguring && (

{t('landing.translate.sourceDocument') || 'Document Source'}

replaceInputRef.current?.click()} t={t} /> {upload.error &&

{t(upload.error)}

} {upload.files.length > 1 && (
    {upload.files.map((f, i) => (
  • {(() => { const ext = f.name.split('.').pop()?.toLowerCase() ?? ''; const FileIcon = FILE_ICONS[ext] ?? FileText; return ; })()}
    {f.name} {fmt(f.size)}
  • ))}
)}
)} {/* ── Desktop Submit Button & Actions (shown when not processing/complete) ── */} {(showUpload || showConfiguring) && (
{upload.files.length === 0 && (

{t('translate.pleaseLoadFile')}

)} {upload.files.length > 0 && !config.targetLang && (

{t('translate.chooseTargetLang')}

)} {upload.files.length > 0 && config.targetLang && config.sourceLang !== 'auto' && config.sourceLang === config.targetLang && (

{t('translate.sameLanguageError')}

)}
{t('landing.translate.zeroRetention') || 'Rétention Zéro'} {t('landing.translate.filesDeleted') || 'Fichiers supprimés post-traitement'}
)} {/* ── PROCESSING STATE: Rich progress ───────────────── */} {showProcessing && (

{t('translate.contextEngineActive')}

{submit.fileName || upload.file?.name}

{/* Progress Line with step icons */}
{PIPELINE_ICONS.map((Icon, i) => (
(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' )} >
))}
{activeStepIdx < 2 ? t('translate.phase1') : t('translate.phase2')} {Math.round(submit.progress)}%
} value={`${Math.round(submit.progress)}%`} label={t('translate.stat.progress')} /> } value={formatRemaining(submit.estimatedRemaining)} label={t('translate.stat.remaining')} /> } value={formatElapsed(elapsed)} label={t('translate.stat.elapsed')} />
)} {/* ── COMPLETE STATE: Success with download ─────────── */} {showComplete && (

{t('translate.header.completedTitleBase')} {t('translate.header.completedTitleAccent')}

{submit.fileName}

{t('translate.reviewCta')}
)} {/* ── FAILED STATE ───────────────────────────────────── */} {showFailed && (

{t('translate.failedTitle')}

{humanFriendlyError(submit.error)}

{(submit.fileName || upload.file?.name) && upload.file && ( replaceInputRef.current?.click()} t={t} /> )}
{upload.file && config.isConfigValid && ( )}
)}
{/* ═══════════════════════════════════════════════════════ */} {/* RIGHT (5 cols) — Config / Monitor / Summary */} {/* ═══════════════════════════════════════════════════════ */}
{/* ── CONFIG (upload / configuring / failed) ──────────── */} {(showUpload || showConfiguring || showFailed) && !showBatch && (
{/* Scrollable config content */}

{t('landing.translate.configuration') || 'Configuration'}

{/* Active mode badge */} {config.provider && (
{config.mode === 'llm' ? ( <> {t('dashboard.translate.modeAI')} ) : ( <> {t('dashboard.translate.modeClassic')} )} {config.mode === 'classic' && config.isPro && ( {t('dashboard.translate.glossaryLLMHint')} )}
)} {/* Context guidelines indicator — ties the glossaries tab to this flow */} {config.mode === 'llm' && !!systemPrompt?.trim() && ( {t('translate.contextActive')} )} {/* Glossary selector — Pro only; hidden entirely for free users */} {config.isPro && ( )} {/* Translate Images — LLM mode only; hidden for free users */} {config.isPro && (
{t('dashboard.translate.translateImages') || "Traduire les images"}
{config.mode === 'classic' ? (
{t('translate.unavailableStandard')}
) : (
{t('dashboard.translate.translateImagesDesc') || "Détecter et traduire automatiquement les textes incrustés dans vos images."}
)}
)} {/* PDF mode selector */} {isPdf && (
)}
)} {/* ── MONITOR (processing) ────────────────────────────── */} {showProcessing && (

{/* File summary */} {(submit.fileName || upload.file?.name) && (
{(() => { const name = submit.fileName || upload.file?.name || ''; const ext = name.split('.').pop()?.toLowerCase() ?? ''; const FileIcon = FILE_ICONS[ext] ?? FileText; return ; })()}

{submit.fileName || upload.file?.name}

{upload.file ? `${fmt(upload.file.size)} ` : ''}{(submit.fileName || upload.file?.name || '').split('.').pop()?.toUpperCase()}

)} {/* Config summary */}
{t('translate.monitor.source')} {srcLangName}
{t('translate.monitor.target')} {tgtLangName}
{currentProvider && (
{t('translate.monitor.engine')} {currentProvider.label}
)}
)} {/* ── SUMMARY (complete) ──────────────────────────────── */} {showComplete && (

{t('translate.summary')}

{t('translate.monitor.source')} {srcLangName}
{t('translate.monitor.target')} {tgtLangName}
{currentProvider && (
{t('translate.monitor.engine')} {currentProvider.label}
)}
{t('landing.translate.zeroRetention') || 'Rétention Zéro'}
)}
{/* Mobile Sticky Action Bar (visible on mobile, hidden on lg) */} {(showUpload || showConfiguring) && !showBatch && (
{upload.files.length > 0 && config.targetLang && config.sourceLang !== 'auto' && config.sourceLang === config.targetLang && (

{t('translate.sameLanguageError')}

)}
)}
); } /* ═══ 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 (
{file.name} {fmt(file.size)} .{ext.toUpperCase()}
); } /** Small stat box */ function StatBox({ icon, value, label }: { icon: React.ReactNode; value: string; label: string }) { return (
{icon}

{value}

{label}

); }