fix(ui): design critique overhaul — a11y, honest metrics, i18n repair (critique 2026-08-30)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m51s

P0 a11y: keyboard-accessible dropzone (role/tabIndex/Enter-Space), ARIA
combobox + listbox pattern for language selector, role=switch on glossary
toggle, role=status live region on notifications, aria-labels on password
toggles, visible-on-focus close buttons, RTL logical positioning (start-*).

Trust: third-party Memento promo removed from translate page, sidebar and
all 13 locales; fabricated stats (99.9%, Turbo, computed layout-integrity
bars) replaced with real measurements incl. API estimated remaining time;
silent download failure now surfaces an error notification.

Honesty: fake 100-byte file injections removed (format chips are now
informational); cancel-that-doesn't renamed 'Back to start' with hint;
Enterprise contact placeholder replaced with contact@wordly.art.

i18n: t() no longer returns raw keys (empty string + defaultValue support,
~30 dead || fallbacks now work); ~170 new keys EN+FR across new reviews/
teams namespaces, glossaries context tab, translate monitor, settings,
services, pricing, landing, fileUploader; split-key italic titles replace
lastIndexOf() surgery (zh/ja-safe); key-audit script added 0 missing.

Flow: active job persisted across refresh with polling resume (24h TTL);
client-side recent-jobs history with review links; review page linked from
complete state; settings/services added to dashboard nav; Business/
Enterprise regain glossary access (tier gate unified).

Typeset (sober-tool direction): 7.5-9px labels raised to 10-12px, /30
opacity to /45-/55, uppercase tracking reduced, trust footer legible,
country flags removed from language switcher, localized dates.

Cleanup: 5 orphaned translate components, dead site header/footer,
fossil tailwind.config.js, PipelineStepper, duplicate pill+H1 titles,
two-step confirm for cache clear, dead landing footer links.

Verified: next build exit 0, vitest 9/9, eslint 64 errors = HEAD
(no regression, -3 warnings), detector 4 -> 3 findings.
This commit is contained in:
2026-08-30 21:44:53 +02:00
parent 1a67241ad5
commit 50047ea8a2
79 changed files with 878 additions and 1590 deletions

View File

@@ -1,92 +0,0 @@
'use client';
import { useRef } from 'react';
import { Upload, FileSpreadsheet, FileText, Presentation } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import type { UseFileUploadReturn } from './types';
interface FileDropZoneProps {
upload: UseFileUploadReturn;
}
export function FileDropZone({ upload }: FileDropZoneProps) {
const inputRef = useRef<HTMLInputElement>(null);
const { t } = useI18n();
const handleClick = () => inputRef.current?.click();
return (
<div
role="button"
tabIndex={0}
aria-label={t('dashboard.translate.dropzone.uploadAria')}
className={cn(
'relative flex flex-col items-center justify-center gap-6 rounded-2xl border-2 border-dashed',
'min-h-[320px] cursor-pointer select-none transition-all duration-200 px-8 py-12',
upload.isDragOver
? 'border-primary bg-primary/8 scale-[1.01]'
: 'border-border bg-muted/20 hover:border-primary/50 hover:bg-muted/40'
)}
onDragOver={upload.handleDragOver}
onDragLeave={upload.handleDragLeave}
onDrop={upload.handleDrop}
onClick={handleClick}
onKeyDown={(e) => e.key === 'Enter' || e.key === ' ' ? handleClick() : undefined}
>
{/* Icon */}
<div className={cn(
'flex size-20 items-center justify-center rounded-2xl transition-colors',
upload.isDragOver ? 'bg-primary/15' : 'bg-secondary'
)}>
<Upload className={cn(
'size-9 transition-colors',
upload.isDragOver ? 'text-primary' : 'text-muted-foreground'
)} />
</div>
{/* Text */}
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-lg font-semibold text-foreground">
{t('dashboard.translate.dropzone.title')}
</p>
<p className="text-sm text-muted-foreground">
{t('dashboard.translate.dropzone.subtitle')}
</p>
</div>
{/* Format badges */}
<div className="flex flex-wrap items-center justify-center gap-3">
<span className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground">
<FileText className="size-3.5 text-blue-500" />
Word (.docx)
</span>
<span className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground">
<FileSpreadsheet className="size-3.5 text-green-500" />
Excel (.xlsx)
</span>
<span className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground">
<Presentation className="size-3.5 text-orange-500" />
PowerPoint (.pptx)
</span>
<span className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground">
<svg className="size-3.5 text-red-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
<polyline points="14 2 14 8 20 8"/>
<text x="12" y="16" textAnchor="middle" fontSize="5" fontWeight="bold" fill="currentColor" stroke="none">PDF</text>
</svg>
PDF (.pdf)
</span>
</div>
<input
ref={inputRef}
type="file"
accept=".xlsx,.docx,.pptx,.pdf"
className="hidden"
onChange={upload.handleFileSelect}
aria-label={t('dashboard.translate.dropzone.uploadAria')}
/>
</div>
);
}

View File

@@ -1,53 +0,0 @@
'use client';
import { FileSpreadsheet, FileText, Presentation, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
const FILE_ICONS: Record<string, React.ElementType> = {
xlsx: FileSpreadsheet,
docx: FileText,
pptx: Presentation,
};
interface FilePreviewProps {
file: File;
onRemove: () => void;
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function FilePreview({ file, onRemove }: FilePreviewProps) {
const ext = file.name.split('.').pop()?.toLowerCase() || '';
const FileIcon = FILE_ICONS[ext] || FileText;
return (
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg bg-secondary">
<FileIcon className="size-5 text-foreground" />
</div>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm font-medium text-foreground truncate">
{file.name}
</span>
<span className="text-xs text-muted-foreground">
{formatFileSize(file.size)} · .{ext}
</span>
</div>
<Button
variant="ghost"
size="icon-sm"
className="ms-2 text-muted-foreground hover:text-foreground shrink-0"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
<X className="size-4" />
</Button>
</div>
);
}

View File

@@ -285,6 +285,9 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
{isPro && mode === 'llm' && (
<button
type="button"
role="switch"
aria-checked={isGlossaryEnabled}
aria-label={t('translate.glossary.title') || 'Glossaire'}
disabled={disabled}
onClick={() => {
const nextVal = !isGlossaryEnabled;
@@ -307,7 +310,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
>
<div className={cn(
"w-3.5 h-3.5 bg-white rounded-full absolute top-0.5 shadow transition-all",
isGlossaryEnabled ? 'left-[13px]' : 'left-0.5'
isGlossaryEnabled ? 'start-[13px]' : 'start-0.5'
)} />
</button>
)}
@@ -359,6 +362,8 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
type="button"
disabled={disabled}
onClick={() => setIsOpen(!isOpen)}
aria-haspopup="listbox"
aria-expanded={isOpen}
className={cn(
"w-full bg-white dark:bg-[#1a1a1a] border border-black/5 dark:border-white/5 hover:border-black/10 dark:hover:border-white/10 py-2.5 px-3 rounded-lg flex items-center justify-between shadow-sm transition-all cursor-pointer",
isOpen && "border-brand-accent dark:border-brand-accent",

View File

@@ -24,6 +24,7 @@ function Combobox({
autoLabel,
placeholder,
onChange,
ariaLabel,
}: {
value: string;
options: Language[];
@@ -31,12 +32,14 @@ function Combobox({
autoLabel: string;
placeholder: string;
onChange: (code: string) => void;
ariaLabel: string;
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const ref = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (open) inputRef.current?.focus();
@@ -50,6 +53,12 @@ function Combobox({
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const closeAndRefocus = () => {
setOpen(false);
setQuery('');
triggerRef.current?.focus();
};
const allOptions = includeAuto
? [{ code: 'auto', name: autoLabel }, ...options]
: options;
@@ -62,20 +71,37 @@ function Combobox({
const label = value === 'auto' ? autoLabel : allOptions.find(l => l.code === value)?.name ?? value;
return (
<div ref={ref} className="relative text-left">
<div
ref={ref}
className="relative text-left"
onKeyDown={(e) => {
if (e.key === 'Escape' && open) {
e.stopPropagation();
closeAndRefocus();
}
}}
>
<button
ref={triggerRef}
type="button"
onClick={() => setOpen(!open)}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={ariaLabel}
className={cn(
'w-full py-2.5 px-3.5 bg-brand-muted/60 dark:bg-white/5 rounded-xl border text-xs font-bold uppercase tracking-wider text-brand-dark dark:text-white flex items-center justify-between hover:border-brand-accent/50 transition-all select-none cursor-pointer',
open ? 'border-brand-accent/50' : 'border-brand-accent/20 dark:border-white/10'
)}
>
<span className="truncate">{label || placeholder}</span>
<span className="truncate normal-case">{label || placeholder}</span>
<ChevronDown className={cn('size-3.5 shrink-0 text-brand-accent transition-transform ms-2', open && 'rotate-180')} />
</button>
{open && (
<div className="absolute top-[102%] right-0 left-0 bg-white dark:bg-[#1a1a1a] border border-black/10 dark:border-white/10 rounded-xl shadow-2xl p-2 z-50 max-h-48 overflow-y-auto animate-fade-in">
<div
role="listbox"
aria-label={ariaLabel}
className="absolute top-[102%] right-0 left-0 bg-white dark:bg-[#1a1a1a] border border-black/10 dark:border-white/10 rounded-xl shadow-2xl p-2 z-50 max-h-48 overflow-y-auto animate-fade-in"
>
<div className="border-b border-black/5 dark:border-white/5 px-2 py-1.5">
<input
ref={inputRef}
@@ -83,6 +109,7 @@ function Combobox({
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('langSelector.search')}
aria-label={t('langSelector.search')}
className="w-full bg-transparent px-1 py-1 text-xs outline-none placeholder:text-brand-dark/30 dark:placeholder:text-white/30 text-brand-dark dark:text-white"
/>
</div>
@@ -94,7 +121,9 @@ function Combobox({
<button
key={lang.code}
type="button"
onClick={() => { onChange(lang.code); setOpen(false); setQuery(''); }}
role="option"
aria-selected={value === lang.code}
onClick={() => { onChange(lang.code); closeAndRefocus(); }}
className={cn(
'flex w-full items-center justify-between rounded-lg px-2.5 py-2 text-xs font-bold uppercase tracking-wider transition-colors cursor-pointer',
value === lang.code
@@ -152,6 +181,7 @@ export default function LanguageSelector({
autoLabel={t('dashboard.translate.language.autoDetect') || 'Auto-détecté'}
placeholder={t('dashboard.translate.language.selectPlaceholder') || 'Langue...'}
onChange={onSourceChange}
ariaLabel={`${t('langSelector.source')}${t('dashboard.translate.language.autoDetect') || 'Auto-détecté'}`}
/>
</div>
@@ -161,6 +191,7 @@ export default function LanguageSelector({
type="button"
onClick={() => canSwap && (() => { const s = sourceLang; onSourceChange(targetLang); onTargetChange(s); })()}
disabled={!canSwap}
aria-label={t('langSelector.swap')}
className={cn(
'flex size-7 items-center justify-center rounded-xl transition-all cursor-pointer',
canSwap
@@ -183,6 +214,7 @@ export default function LanguageSelector({
autoLabel=""
placeholder={t('dashboard.translate.language.selectPlaceholder') || 'Langue...'}
onChange={onTargetChange}
ariaLabel={t('langSelector.target')}
/>
</div>
</div>

View File

@@ -1,187 +0,0 @@
'use client';
import Link from 'next/link';
import { useState, useEffect, useRef } from 'react';
import {
CheckCircle2, Download, Plus, Loader2, FileText,
Timer, Activity, TrendingUp, BookOpenCheck,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useNotification } from '@/components/ui/notification';
import { useI18n } from '@/lib/i18n';
import { API_BASE } from '@/lib/config';
import { cn } from '@/lib/utils';
interface TranslationCompleteProps {
jobId: string;
fileName: string | null;
onNewTranslation: () => void;
}
export function TranslationComplete({
jobId,
fileName,
onNewTranslation,
}: TranslationCompleteProps) {
const [isDownloading, setIsDownloading] = useState(false);
const { success, error } = useNotification();
const { t } = useI18n();
const blobUrlRef = useRef<string | null>(null);
const handleDownload = async () => {
setIsDownloading(true);
try {
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/${jobId}`, { headers });
if (!response.ok) {
let msg = t('dashboard.translate.complete.toastFailDesc');
try {
const body = await response.json();
msg = body.message || body.error || msg;
} catch { /* not JSON */ }
throw new Error(msg);
}
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 (fileName) {
const ext = fileName.split('.').pop() || '';
const base = fileName.replace(/\.[^.]+$/, '');
downloadFilename = `${base}_translated.${ext}`;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
blobUrlRef.current = url;
const a = document.createElement('a');
a.href = url;
a.download = downloadFilename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => {
if (blobUrlRef.current) { URL.revokeObjectURL(blobUrlRef.current); blobUrlRef.current = null; }
}, 1000);
success({
title: t('dashboard.translate.complete.toastOkTitle'),
description: t('dashboard.translate.complete.toastOkDesc', { name: downloadFilename }),
});
} catch (err) {
error({
title: t('dashboard.translate.complete.toastFailTitle'),
description: err instanceof Error ? err.message : t('dashboard.translate.complete.toastFailDesc'),
});
} finally {
setIsDownloading(false);
setTimeout(() => {
if (blobUrlRef.current) { URL.revokeObjectURL(blobUrlRef.current); blobUrlRef.current = null; }
}, 5000);
}
};
useEffect(() => {
return () => {
if (blobUrlRef.current) { URL.revokeObjectURL(blobUrlRef.current); blobUrlRef.current = null; }
};
}, []);
return (
<div className="flex w-full max-w-lg flex-col gap-0 overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
{/* ═══ Success header ═══ */}
<div className="relative overflow-hidden border-b border-emerald-200/50 bg-gradient-to-r from-emerald-500/8 via-emerald-500/5 to-transparent px-8 py-6 text-center">
<div className="mx-auto flex size-16 items-center justify-center rounded-2xl bg-emerald-500 shadow-lg shadow-emerald-500/20">
<CheckCircle2 className="size-8 text-white" />
</div>
<h3 className="mt-4 text-xl font-bold text-foreground">
{t('dashboard.translate.complete.title')}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{fileName
? t('dashboard.translate.complete.descNamed', { name: fileName })
: t('dashboard.translate.complete.descGeneric')}
</p>
<div className="mt-3 inline-flex items-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700 dark:border-emerald-800/50 dark:bg-emerald-950/30 dark:text-emerald-400">
<TrendingUp className="size-3" /> {t('translateComplete.highQuality')}
</div>
</div>
<div className="p-8 space-y-6">
{/* ═══ Result stats ═══ */}
<div className="grid grid-cols-3 gap-2.5">
<div className="flex flex-col items-center gap-1 rounded-xl border border-emerald-100 bg-emerald-50/50 p-3 dark:border-emerald-900/30 dark:bg-emerald-950/10">
<FileText className="size-4 text-emerald-600" />
<p className="text-sm font-bold text-foreground">142</p>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{t('translateComplete.segments')}</p>
</div>
<div className="flex flex-col items-center gap-1 rounded-xl border border-emerald-100 bg-emerald-50/50 p-3 dark:border-emerald-900/30 dark:bg-emerald-950/10">
<Activity className="size-4 text-emerald-600" />
<p className="text-sm font-bold text-foreground">12.8k</p>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{t('translateComplete.characters')}</p>
</div>
<div className="flex flex-col items-center gap-1 rounded-xl border border-emerald-100 bg-emerald-50/50 p-3 dark:border-emerald-900/30 dark:bg-emerald-950/10">
<Timer className="size-4 text-emerald-600" />
<p className="text-sm font-bold text-emerald-600">96%</p>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{t('translateComplete.confidence')}</p>
</div>
</div>
{/* ═══ Actions ═══ */}
<div className="flex flex-col gap-3">
<Button
size="lg"
className="h-12 w-full gap-2 text-base font-semibold"
onClick={handleDownload}
disabled={isDownloading}
>
{isDownloading ? (
<>
<Loader2 className="size-5 animate-spin" />
{t('dashboard.translate.complete.downloading')}
</>
) : (
<>
<Download className="size-5" />
{t('dashboard.translate.complete.download')}
</>
)}
</Button>
<Button
variant="outline"
size="lg"
className="h-11 w-full gap-2"
asChild
>
<Link href={`/dashboard/reviews/${jobId}`}>
<BookOpenCheck className="size-4" />
Relire et corriger la traduction
</Link>
</Button>
<Button
variant="outline"
size="lg"
className="h-11 w-full gap-2"
onClick={onNewTranslation}
>
<Plus className="size-4" />
{t('dashboard.translate.complete.newTranslation')}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,89 +0,0 @@
'use client';
import { Lock } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { TranslationMode } from './types';
import { useI18n } from '@/lib/i18n';
interface TranslationModeToggleProps {
mode: TranslationMode;
onModeChange: (mode: TranslationMode) => void;
isPro: boolean;
}
export function TranslationModeToggle({
mode,
onModeChange,
isPro,
}: TranslationModeToggleProps) {
const { t } = useI18n();
return (
<TooltipProvider>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
{t('translate.mode.label')}
</label>
<div className="flex rounded-lg border border-border bg-muted p-1">
<button
type="button"
className={cn(
'flex-1 rounded-md px-4 py-2 text-sm font-medium transition-all',
mode === 'classic'
? 'bg-card text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
onClick={() => onModeChange('classic')}
>
{t('translate.mode.classic')}
<span className="ms-1.5 text-xs text-muted-foreground">
{t('translate.mode.classicDesc')}
</span>
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
'flex-1 rounded-md px-4 py-2 text-sm font-medium transition-all relative',
mode === 'llm'
? 'bg-card text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
!isPro && 'cursor-not-allowed opacity-60'
)}
onClick={() => isPro && onModeChange('llm')}
disabled={!isPro}
>
{t('translate.mode.proLlm')}
<span className="ms-1.5 text-xs text-muted-foreground">
{t('translate.mode.proLlmDesc')}
</span>
{!isPro && (
<Lock className="absolute right-2 top-1/2 -translate-y-1/2 size-3 text-muted-foreground" />
)}
</button>
</TooltipTrigger>
{!isPro && (
<TooltipContent side="top">
<p>{t('translate.mode.tooltip')}</p>
</TooltipContent>
)}
</Tooltip>
</div>
{!isPro && (
<p className="text-xs text-muted-foreground">
<a href="/pricing" className="text-primary hover:underline">
{t('translate.mode.upgradeLink')}
</a>{' '}
{t('translate.mode.upgradeDesc')}
</p>
)}
</div>
</TooltipProvider>
);
}

View File

@@ -1,358 +0,0 @@
'use client';
import { useEffect, useRef, useState, useMemo } from 'react';
import {
AlertTriangle, Loader2, Clock, WifiOff,
Upload, Search, Languages, Wrench, CheckCircle2,
FileText, Timer, Gauge, Activity,
RotateCcw,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
/* ── Types ──────────────────────────────────────────────────────── */
interface TranslationProgressProps {
progress: number;
currentStep: string;
estimatedRemaining: number | null;
error: string | null;
isPolling?: boolean;
isUploading?: boolean;
isCompleted?: boolean;
/** Extra info for the monitor panel */
fileName?: string | null;
sourceLang?: string;
targetLang?: string;
providerLabel?: string | null;
onCancel?: () => void;
}
/* ── Pipeline step definition ───────────────────────────────────── */
interface PipelineStep {
id: string;
label: string;
icon: React.ElementType;
/** Progress range where this step is active */
startsAt: number;
}
const PIPELINE_STEPS: PipelineStep[] = [
{ id: 'upload', label: 'Upload', icon: Upload, startsAt: 0 },
{ id: 'analyze', label: 'Analyse', icon: Search, startsAt: 10 },
{ id: 'translate', label: 'Traduction', icon: Languages, startsAt: 25 },
{ id: 'rebuild', label: 'Reconstruction', icon: Wrench, startsAt: 75 },
{ id: 'finalize', label: 'Finalisation', icon: CheckCircle2, startsAt: 92 },
];
/* ── Simulated activity messages ────────────────────────────────── */
const ACTIVITY_MESSAGES: Record<string, string[]> = {
upload: [
'Fichier reçu avec succès',
'Vérification du format...',
],
analyze: [
'Analyse de la structure du document',
'Extraction des segments de texte',
'Détection de la langue source',
],
translate: [
'Connexion au moteur de traduction',
'Traduction des segments en cours...',
'Traitement des tableaux et graphiques',
'Conservation de la mise en forme',
'Validation des traductions',
],
rebuild: [
'Reconstruction du document traduit',
'Application de la mise en forme originale',
'Vérification de l\'intégrité',
],
finalize: [
'Contrôle qualité final',
'Préparation du téléchargement',
],
};
/* ── Helpers ────────────────────────────────────────────────────── */
function getActiveStepIndex(progress: number): number {
let idx = 0;
for (let i = PIPELINE_STEPS.length - 1; i >= 0; i--) {
if (progress >= PIPELINE_STEPS[i].startsAt) { idx = i; break; }
}
return idx;
}
function formatTime(seconds: number | null): string {
if (seconds === null || seconds <= 0) return '';
if (seconds < 60) return `~${seconds}s`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `~${m}m${s > 0 ? ` ${s}s` : ''}`;
}
function formatElapsed(totalSeconds: number): string {
const m = Math.floor(totalSeconds / 60);
const s = totalSeconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
/* ── Component ──────────────────────────────────────────────────── */
export function TranslationProgress({
progress,
currentStep,
estimatedRemaining,
error,
isPolling = true,
isUploading = false,
isCompleted = false,
fileName,
sourceLang,
targetLang,
providerLabel,
onCancel,
}: TranslationProgressProps) {
const { t } = useI18n();
const [animate, setAnimate] = useState(false);
const [elapsed, setElapsed] = useState(0);
const [activities, setActivities] = useState<{ text: string; time: string }[]>([]);
const prevStepIdx = useRef(-1);
const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Animate progress bar on first progress
useEffect(() => {
if (progress > 0) setAnimate(true);
else {
setAnimate(false);
const tid = setTimeout(() => setAnimate(true), 50);
return () => clearTimeout(tid);
}
}, [progress]);
// Elapsed timer
useEffect(() => {
if (isCompleted || error) {
if (elapsedRef.current) clearInterval(elapsedRef.current);
return;
}
elapsedRef.current = setInterval(() => setElapsed(e => e + 1), 1000);
return () => { if (elapsedRef.current) clearInterval(elapsedRef.current); };
}, [isCompleted, error]);
// Activity log — push messages when step changes
const activeIdx = getActiveStepIndex(progress);
useEffect(() => {
if (activeIdx !== prevStepIdx.current) {
prevStepIdx.current = activeIdx;
const step = PIPELINE_STEPS[activeIdx];
if (!step) return;
const msgs = ACTIVITY_MESSAGES[step.id] || [];
msgs.forEach((msg, i) => {
setTimeout(() => {
setActivities(prev => {
const next = [{ text: msg, time: formatElapsed(elapsed) }, ...prev];
return next.slice(0, 10);
});
}, i * 600);
});
}
}, [activeIdx, elapsed]);
// Simulated stats
const totalSegments = 142;
const totalChars = 12847;
const doneSeg = Math.round(totalSegments * progress / 100);
const doneChars = Math.round(totalChars * progress / 100);
const speed = elapsed > 3 ? (doneSeg / (elapsed / 60)).toFixed(1) : '—';
/* ── Error state ──────────────────────────────────────────────── */
if (error) {
return (
<div className="flex flex-col gap-6">
<div className="rounded-xl bg-destructive/10 border border-destructive/20 p-5" role="alert" aria-live="assertive">
<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">{error}</p>
</div>
</div>
</div>
{onCancel && (
<button onClick={onCancel} className="mx-auto flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground transition hover:bg-muted hover:text-foreground">
<RotateCcw className="size-4" />{t('dashboard.translate.actions.tryAgain')}
</button>
)}
</div>
);
}
const showConnectionLost = !isPolling && !isCompleted && !isUploading;
return (
<div className="flex flex-col gap-0 overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
{/* ═══ Header band ═══ */}
<div className="relative overflow-hidden border-b border-border bg-gradient-to-r from-primary/5 via-primary/8 to-primary/3 px-6 py-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10">
<Loader2 className="size-5 animate-spin text-primary" />
</div>
<div>
<h2 className="text-base font-bold text-foreground leading-tight">Traduction en cours</h2>
{fileName && (
<p className="text-xs text-muted-foreground mt-0.5 truncate max-w-[260px]">{fileName}</p>
)}
</div>
</div>
{estimatedRemaining != null && estimatedRemaining > 0 && (
<div className="flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1.5 text-xs font-semibold text-primary">
<Clock className="size-3.5" />
{formatTime(estimatedRemaining)}
</div>
)}
</div>
</div>
<div className="flex flex-col gap-6 p-6">
{/* ═══ Pipeline Stepper ═══ */}
<div className="flex items-start justify-between">
{PIPELINE_STEPS.map((step, i) => {
const isActive = i === activeIdx;
const isDone = i < activeIdx;
const Icon = step.icon;
return (
<div key={step.id} className="flex flex-col items-center gap-1.5 relative" style={{ flex: i < PIPELINE_STEPS.length - 1 ? 1 : 'none' }}>
{/* Step circle + connector */}
<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_STEPS.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 duration-300',
isDone || isActive ? 'text-primary' : 'text-muted-foreground',
)}>
{step.label}
</span>
</div>
);
})}
</div>
{/* ═══ Progress bar ═══ */}
<div className="space-y-2">
<div className="flex items-center justify-between gap-4">
<p className="text-sm font-medium text-foreground animate-pulse truncate">
{currentStep || t('dashboard.translate.progress.processingFallback')}
</p>
<p className="text-2xl font-black tabular-nums tracking-tight text-primary shrink-0">
{Math.round(progress)}%
</p>
</div>
<div className="h-3 w-full overflow-hidden rounded-full bg-primary/10">
<div
className={cn(
'h-full rounded-full transition-all duration-700 ease-out',
animate && 'bg-gradient-to-r from-primary via-primary/80 to-primary',
)}
style={{ width: `${Math.max(0, progress)}%` }}
/>
</div>
</div>
{/* ═══ Live Stats Grid ═══ */}
<div className="grid grid-cols-4 gap-2.5">
<StatCard
icon={<FileText className="size-4" />}
value={`${doneSeg}/${totalSegments}`}
label="Segments"
/>
<StatCard
icon={<Activity className="size-4" />}
value={doneChars.toLocaleString()}
label="Caractères"
/>
<StatCard
icon={<Gauge className="size-4" />}
value={speed}
label="Seg/min"
/>
<StatCard
icon={<Timer className="size-4" />}
value={formatElapsed(elapsed)}
label="Écoulé"
/>
</div>
{/* ═══ Activity Feed ═══ */}
{activities.length > 0 && (
<div>
<p className="mb-2 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<Activity className="size-3" /> Journal d&apos;activité
</p>
<div className="max-h-[100px] overflow-y-auto rounded-xl border border-border bg-muted/30">
{activities.map((act, i) => (
<div key={i} className="flex items-center gap-2.5 border-b border-border/50 px-3 py-1.5 last:border-0">
<div className="size-1.5 shrink-0 rounded-full bg-primary" />
<span className="flex-1 text-xs text-foreground">{act.text}</span>
<span className="text-[10px] tabular-nums text-muted-foreground">{act.time}</span>
</div>
))}
</div>
</div>
)}
{/* ═══ Connection lost ═══ */}
{showConnectionLost && (
<div className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-400">
<WifiOff className="size-3.5" />
<span>{t('dashboard.translate.progress.connectionLost')}</span>
</div>
)}
{/* ═══ Cancel button ═══ */}
{onCancel && (
<div className="flex justify-center pt-1">
<button
onClick={onCancel}
className="flex items-center gap-2 rounded-lg border border-destructive/20 px-4 py-2 text-sm font-medium text-destructive transition hover:bg-destructive hover:text-white hover:border-destructive"
>
<RotateCcw className="size-4" />Annuler
</button>
</div>
)}
</div>
</div>
);
}
/* ── Stat card sub-component ────────────────────────────────────── */
function StatCard({ icon, value, label }: { icon: React.ReactNode; value: string; label: string }) {
return (
<div className="flex flex-col items-center gap-1 rounded-xl border border-border bg-muted/20 p-2.5 text-center">
<div className="text-primary">{icon}</div>
<p className="text-sm font-bold tabular-nums text-foreground leading-none">{value}</p>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{label}</p>
</div>
);
}

View File

@@ -1,17 +1,18 @@
'use client';
import { useEffect, useRef, useState, useMemo } from 'react';
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, Timer,
Search, Languages, Wrench, Activity,
Download, AlertTriangle, FileType,
Image as ImageIcon,
} from 'lucide-react';
import { useFileUpload } from './useFileUpload';
import { useTranslationConfig } from './useTranslationConfig';
import { useTranslationSubmit } from './useTranslationSubmit';
import { useTranslationSubmit, getRecentJobs, type RecentJob } from './useTranslationSubmit';
import LanguageSelector from './LanguageSelector';
import { ProviderSelector } from './ProviderSelector';
import { GlossarySelector } from './GlossarySelector';
@@ -20,7 +21,6 @@ import { useNotification } from '@/components/ui/notification';
import { useI18n } from '@/lib/i18n';
import { API_BASE } from '@/lib/config';
import { cn } from '@/lib/utils';
import { useUser } from '../useUser';
/* ── helpers ─────────────────────────────────────────────────────── */
const FILE_ICONS: Record<string, React.ElementType> = {
@@ -35,23 +35,6 @@ function fmt(bytes: number) {
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];
@@ -68,6 +51,18 @@ function formatElapsed(totalSeconds: number) {
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();
@@ -75,13 +70,12 @@ export default function TranslatePage() {
const submit = useTranslationSubmit();
const { error: showError } = useNotification();
const { t } = useI18n();
const { data: currentUser } = useUser();
const isPaid = !!currentUser?.tier && currentUser.tier !== 'free';
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 [recentJobs, setRecentJobs] = useState<RecentJob[]>([]);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isPdf = upload.file?.name.toLowerCase().endsWith('.pdf') ?? false;
@@ -128,6 +122,11 @@ export default function TranslatePage() {
return () => { if (timerRef.current) clearInterval(timerRef.current); };
}, [submit.status, submit.isSubmitting]);
// Recent jobs (client-side history) — loaded on mount, refreshed when a job completes
useEffect(() => {
setRecentJobs(getRecentJobs());
}, [submit.status]);
const handleTranslate = async () => {
if (!upload.file || !config.isConfigValid) return;
const cfg = config.getConfig();
@@ -142,30 +141,37 @@ export default function TranslatePage() {
await handleTranslate();
};
const handleNewTranslation = () => { submit.reset(); upload.removeFile(); setElapsed(0); };
const handleNewTranslation = () => { submit.reset(); upload.removeFile(); setElapsed(0); setRecentJobs(getRecentJobs()); };
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}`;
try {
const response = await fetch(`${API_BASE}/api/v1/download/${submit.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') });
}
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 ──────────────────────────────────────────── */
@@ -184,12 +190,12 @@ export default function TranslatePage() {
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]);
const fileTypeButtons = [
{ label: t('translate.fileType.word'), type: 'word' as const, icon: <FileText size={11} className="text-blue-500" /> },
{ label: t('translate.fileType.excel'), type: 'excel' as const, icon: <FileSpreadsheet size={11} className="text-green-500" /> },
{ label: t('translate.fileType.slides'), type: 'slides' as const, icon: <Presentation size={11} className="text-orange-500" /> },
{ label: t('translate.fileType.pdf'), type: 'pdf' as const, icon: <FileType size={11} className="text-red-500" /> },
// 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 (
@@ -202,11 +208,7 @@ export default function TranslatePage() {
<>
<span className="accent-pill mb-4 block w-fit italic">{t('translate.header.processing')}</span>
<h1 className="text-4xl md:text-5xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
{(() => {
const full = t('translate.header.aiActive');
const i = full.lastIndexOf(' ');
return <>{i === -1 ? full : <>{full.slice(0, i)} <span className="italic">{full.slice(i + 1)}</span></>}</>;
})()}
<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')}
@@ -216,11 +218,7 @@ export default function TranslatePage() {
<>
<span className="accent-pill mb-4 block w-fit italic">{t('translate.header.completed')}</span>
<h1 className="text-4xl md:text-5xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
{(() => {
const full = t('translate.header.completedTitle');
const i = full.lastIndexOf(' ');
return <>{i === -1 ? full : <>{full.slice(0, i)} <span className="italic">{full.slice(i + 1)}</span></>}</>;
})()}
<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}
@@ -228,13 +226,9 @@ export default function TranslatePage() {
</>
) : (
<>
<span className="accent-pill mb-4 block w-fit">{t('translate.header.proSpace')}</span>
<span className="accent-pill mb-4 block w-fit">{t('translate.header.workspace')}</span>
<h1 className="text-4xl md:text-5xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
{(() => {
const full = t('translate.header.translateDoc');
const i = full.lastIndexOf(' ');
return <>{i === -1 ? full : <>{full.slice(0, i)} <span className="italic">{full.slice(i + 1)}</span></>}</>;
})()}
<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')}
@@ -254,13 +248,23 @@ export default function TranslatePage() {
{/* ── UPLOAD STATE: Editorial Dropzone ──────────────── */}
{showUpload && (
<div
className="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]"
className="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"
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-[8px] font-bold uppercase tracking-widest text-brand-dark/30 dark:text-white/30 bg-brand-muted dark:bg-white/5 px-3 py-1 rounded-full border border-black/[0.03] dark:border-white/[0.03]">
<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>
@@ -271,21 +275,19 @@ export default function TranslatePage() {
<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/40 dark:text-white/40 mb-8 font-medium">
<p className="text-xs text-brand-dark/50 dark:text-white/50 mb-8 font-medium">
{t('landing.translate.supportedFormats')}
</p>
{/* Simulated file triggers */}
<div className="flex flex-wrap justify-center gap-2.5" onClick={(e) => e.stopPropagation()}>
{fileTypeButtons.map(f => (
<button
key={f.type}
type="button"
onClick={() => upload.setMockFile(f.type)}
className="flex items-center gap-2 px-3.5 py-2 bg-brand-muted dark:bg-white/10 rounded-xl text-[9px] font-bold uppercase tracking-widest text-brand-dark/60 dark:text-white/60 border border-transparent hover:border-brand-accent/20 dark:hover:border-brand-accent/20 transition-all hover:scale-[1.02] active:scale-[0.98]"
{/* 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}
</button>
</span>
))}
</div>
@@ -296,10 +298,37 @@ export default function TranslatePage() {
accept=".xlsx,.docx,.pptx,.pdf"
className="hidden"
onChange={upload.handleFileSelect}
aria-hidden="true"
tabIndex={-1}
/>
</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, 4).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-accent" />
<span className="truncate">{job.fileName || job.jobId}</span>
</span>
<Link
href={`/dashboard/reviews/${job.jobId}`}
className="shrink-0 rounded-lg px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-brand-accent border border-brand-accent/25 hover:bg-brand-accent/10 transition-colors"
>
{t('translate.recent.review')}
</Link>
</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">
@@ -308,7 +337,7 @@ export default function TranslatePage() {
</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-xs text-destructive">{upload.error}</p>}
{upload.error && <p className="mt-2 text-xs text-destructive">{t(upload.error)}</p>}
</div>
)}
@@ -319,10 +348,10 @@ export default function TranslatePage() {
disabled={!config.isConfigValid || submit.isSubmitting || !upload.file}
onClick={handleTranslate}
className={cn(
'w-full py-4 text-[10px] font-bold uppercase tracking-[0.25em] flex items-center justify-center gap-2 rounded-2xl transition-all shadow-sm active:scale-98',
'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.file && !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/25 dark:bg-white/5 dark:text-white/20 cursor-not-allowed border border-black/[0.03] dark:border-white/[0.03]'
: '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 ? (
@@ -333,18 +362,18 @@ export default function TranslatePage() {
</button>
{!upload.file && (
<p className="text-center text-[8px] text-brand-dark/30 dark:text-white/30 font-bold uppercase tracking-widest">{t('translate.pleaseLoadFile')}</p>
<p className="text-center text-[10px] text-brand-dark/50 dark:text-white/50 font-semibold uppercase tracking-wider">{t('translate.pleaseLoadFile')}</p>
)}
{upload.file && !config.targetLang && (
<p className="text-center text-[8px] text-brand-dark/30 dark:text-white/30 font-bold uppercase tracking-widest">{t('translate.chooseTargetLang')}</p>
<p className="text-center text-[10px] text-brand-dark/50 dark:text-white/50 font-semibold uppercase tracking-wider">{t('translate.chooseTargetLang')}</p>
)}
<div className="flex justify-between text-[7.5px] font-bold uppercase tracking-[0.15em] text-brand-dark/30 dark:text-white/30 border-t border-black/5 dark:border-white/5 pt-4">
<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={12} /> {t('landing.translate.zeroRetention') || 'Rétention Zéro'}
<ShieldCheck size={13} className="text-brand-accent" /> {t('landing.translate.zeroRetention') || 'Rétention Zéro'}
</span>
<span className="flex items-center gap-1.5">
<Clock size={12} /> {t('landing.translate.filesDeleted') || 'Fichiers supprimés post-traitement'}
<Clock size={13} className="text-brand-accent" /> {t('landing.translate.filesDeleted') || 'Fichiers supprimés post-traitement'}
</span>
</div>
</div>
@@ -361,7 +390,7 @@ export default function TranslatePage() {
<h3 className="text-2xl font-serif font-medium text-brand-dark dark:text-white tracking-tight">
{t('translate.contextEngineActive')}
</h3>
<p className="text-[10px] text-brand-dark/30 dark:text-white/30 font-bold uppercase tracking-widest mt-1">
<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>
@@ -391,19 +420,18 @@ export default function TranslatePage() {
</div>
<div className="flex justify-between items-end mt-12 pt-6">
<span className="text-[10px] font-bold text-brand-dark/30 dark:text-white/30 uppercase tracking-[0.3em]">
<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">
<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-4 gap-4 pt-12 border-t border-black/5 dark:border-white/5">
<StatBox icon={<FileText size={18} />} value={`${Math.round(submit.progress)}%`} label={t('translate.stat.segments')} />
<StatBox icon={<Zap size={18} />} value="99.9%" label={t('translate.stat.precision')} />
<StatBox icon={<Clock size={18} />} value="Turbo" label={t('translate.stat.speedLabel')} />
<StatBox icon={<Activity size={18} />} value={formatElapsed(elapsed)} label={t('translate.stat.time')} />
<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>
)}
@@ -420,12 +448,12 @@ export default function TranslatePage() {
<p className="text-[13px] font-bold uppercase tracking-[0.1em] text-brand-dark dark:text-white">
{t('translate.header.completedTitle')}
</p>
<p className="text-[10px] text-brand-dark/40 dark:text-white/40 font-bold uppercase mt-1 tracking-widest max-w-[300px] truncate">
<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>
<span className="px-5 py-2 bg-white dark:bg-[#1a1a1a] rounded-full text-[9px] font-bold uppercase tracking-widest text-brand-accent border border-brand-accent/20 shadow-sm shrink-0">
<span className="px-5 py-2 bg-white dark:bg-[#1a1a1a] rounded-full text-[10px] font-bold uppercase tracking-wider text-brand-accent border border-brand-accent/20 shadow-sm shrink-0">
{t('translate.complete.masterQuality')}
</span>
</div>
@@ -438,9 +466,16 @@ export default function TranslatePage() {
<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-accent transition-colors hover:bg-brand-accent/10"
>
<Search size={14} />
{t('translate.reviewCta')}
</Link>
<button
onClick={handleNewTranslation}
className="text-[10px] font-bold uppercase tracking-[0.3em] text-brand-dark/30 hover:text-brand-dark dark:text-white/30 dark:hover:text-white transition-colors"
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>
@@ -480,7 +515,8 @@ export default function TranslatePage() {
)}
<button
onClick={handleNewTranslation}
className="w-full py-4 border border-black/10 dark:border-white/10 rounded-2xl text-[10px] font-bold uppercase tracking-[0.25em] 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"
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')}
@@ -500,7 +536,7 @@ export default function TranslatePage() {
<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-[10px] font-bold uppercase tracking-[0.2em] text-brand-dark/30 dark:text-white/30 pb-3 border-b border-black/[0.03] dark:border-white/[0.03]">
<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>
@@ -522,10 +558,10 @@ export default function TranslatePage() {
{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-[9px] font-bold uppercase tracking-[0.1em]",
"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-accent border border-brand-accent/20"
: "bg-brand-muted/50 text-brand-dark/40 dark:bg-white/5 dark:text-white/40 border border-transparent"
: "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')}</>
@@ -534,7 +570,7 @@ export default function TranslatePage() {
)}
</span>
{config.mode === 'classic' && config.isPro && (
<span className="text-[9px] text-brand-dark/40 dark:text-white/40 italic font-medium leading-none">
<span className="text-[10px] text-brand-dark/50 dark:text-white/50 italic font-medium leading-none">
{t('dashboard.translate.glossaryLLMHint')}
</span>
)}
@@ -557,7 +593,7 @@ export default function TranslatePage() {
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<ImageIcon className="size-3.5 text-brand-accent shrink-0" />
<span className="text-[9px] font-black uppercase tracking-[0.1em] text-brand-dark dark:text-white">
<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>
@@ -568,16 +604,16 @@ export default function TranslatePage() {
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-[7.5px] font-black uppercase opacity-45 block">
<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-[7.5px] font-medium text-brand-dark/50 dark:text-white/50 block leading-normal">
<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>
@@ -587,13 +623,14 @@ export default function TranslatePage() {
{/* PDF mode selector */}
{isPdf && (
<div className="space-y-2 text-left">
<label className="text-[9px] font-bold text-brand-dark/40 dark:text-white/40 uppercase tracking-[0.15em] block mb-2">
<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'
@@ -605,13 +642,14 @@ export default function TranslatePage() {
<FileText className="size-3.5 text-brand-accent" />
{t('dashboard.translate.pdfMode.preserveLayout') || 'Mise en page'}
</div>
<p className="mt-1.5 text-[8px] text-brand-dark/40 dark:text-white/40 font-bold uppercase tracking-widest leading-relaxed">
{t('translate.preserveLayoutDesc')}
<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'
@@ -623,8 +661,8 @@ export default function TranslatePage() {
<Languages className="size-3.5 text-brand-accent" />
{t('translate.textOnly')}
</div>
<p className="mt-1.5 text-[8px] text-brand-dark/40 dark:text-white/40 font-bold uppercase tracking-widest leading-relaxed">
{t('translate.textOnlyDesc')}
<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>
@@ -638,8 +676,8 @@ export default function TranslatePage() {
{/* ── MONITOR (processing) ────────────────────────────── */}
{showProcessing && (
<div className="editorial-card p-6 bg-white dark:bg-[#141414] border-none shadow-editorial h-full">
<h4 className="text-[10px] font-bold uppercase tracking-[0.2em] mb-8 flex items-center gap-3 text-brand-dark/45 dark:text-white/45 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" />
<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>
@@ -655,10 +693,10 @@ export default function TranslatePage() {
})()}
</div>
<div className="overflow-hidden text-left">
<p className="text-[11px] font-bold truncate text-brand-dark dark:text-white">
<p className="text-xs font-bold truncate text-brand-dark dark:text-white">
{submit.fileName || upload.file?.name}
</p>
<p className="text-[9px] text-brand-dark/45 dark:text-white/45 font-bold uppercase tracking-widest mt-1">
<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>
@@ -667,41 +705,29 @@ export default function TranslatePage() {
{/* Config summary */}
<div className="space-y-6 mb-8 px-2 text-left">
<div className="flex justify-between items-center text-[9px] font-bold uppercase tracking-[0.2em] text-brand-dark/40 dark:text-white/40">
<span>Source</span>
<span className="text-brand-dark dark:text-white">{srcLangName.toUpperCase()}</span>
<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-[9px] font-bold uppercase tracking-[0.2em] text-brand-dark/40 dark:text-white/40">
<span>Cible</span>
<span className="text-brand-accent">{tgtLangName.toUpperCase()}</span>
<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-accent normal-case">{tgtLangName}</span>
</div>
{currentProvider && (
<div className="flex justify-between items-center text-[9px] font-bold uppercase tracking-[0.2em] text-brand-dark/40 dark:text-white/40">
<span>Moteur</span>
<span className="text-brand-dark dark:text-white">{currentProvider.label.toUpperCase()}</span>
<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>
{/* Quality progress */}
<div className="pt-6 border-t border-black/5 dark:border-white/5 text-left">
<div className="flex justify-between text-[9px] font-bold uppercase tracking-[0.2em] mb-3">
<span className="text-brand-dark/40 dark:text-white/40">{t('translate.layoutIntegrity')}</span>
<span className="text-brand-accent">{t('translate.secureHundred')}</span>
</div>
<div className="h-2 bg-brand-muted dark:bg-white/5 rounded-full overflow-hidden p-0.5">
<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-12 py-4 border border-red-100 text-red-500 rounded-2xl text-[9px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-2 hover:bg-red-50 dark:border-red-950/20 dark:hover:bg-red-950/30 transition-all cursor-pointer"
title={t('translate.leaveScreenHint')}
className="w-full mt-8 py-3.5 border border-black/10 dark:border-white/10 text-brand-dark/50 dark:text-white/50 rounded-2xl text-[10px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-2 hover:bg-brand-muted/40 dark:hover:bg-white/5 hover:text-brand-dark dark:hover:text-white transition-all cursor-pointer"
>
{t('translate.cancelProcess')}
<X size={13} />
{t('translate.leaveScreen')}
</button>
</div>
)}
@@ -709,35 +735,33 @@ export default function TranslatePage() {
{/* ── SUMMARY (complete) ──────────────────────────────── */}
{showComplete && (
<div className="editorial-card p-6 bg-white dark:bg-[#141414] border-none shadow-editorial h-full">
<h4 className="text-[10px] font-bold uppercase tracking-[0.2em] mb-8 flex items-center gap-3 text-brand-dark/45 dark:text-white/45 pb-3 border-b border-black/[0.03] dark:border-white/[0.03]">
<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-[9px] font-bold uppercase tracking-[0.2em] text-brand-dark/40 dark:text-white/40">
<span>Source</span>
<span className="text-brand-dark dark:text-white">{srcLangName.toUpperCase()}</span>
<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-[9px] font-bold uppercase tracking-[0.2em] text-brand-dark/40 dark:text-white/40">
<span>Cible</span>
<span className="text-brand-accent">{tgtLangName.toUpperCase()}</span>
<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-accent normal-case">{tgtLangName}</span>
</div>
{currentProvider && (
<div className="flex justify-between items-center text-[9px] font-bold uppercase tracking-[0.2em] text-brand-dark/40 dark:text-white/40">
<span>Moteur</span>
<span className="text-brand-dark dark:text-white">{currentProvider.label.toUpperCase()}</span>
<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 text-[9px] font-bold uppercase tracking-[0.2em] mb-3">
<span className="text-brand-dark/40 dark:text-white/40">{t('translate.layoutIntegrity')}</span>
<span className="text-brand-accent">{t('translate.okHundred')}</span>
</div>
<div className="h-2 bg-brand-muted dark:bg-white/5 rounded-full overflow-hidden p-0.5">
<div className="h-full bg-brand-accent rounded-full" style={{ width: '100%' }} />
<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-accent" /> {t('landing.translate.zeroRetention') || 'Rétention Zéro'}
</span>
</div>
</div>
</div>
@@ -745,41 +769,6 @@ export default function TranslatePage() {
</div>
</div>
{/* ── MEMENTO PROMO BANNER — hidden for paying users ────── */}
{!isPaid && (showUpload || showConfiguring || showFailed) && (
<a
href={t('memento.url', { defaultValue: 'https://memento-note.com/' })}
target="_blank"
rel="noopener noreferrer"
className="block mt-12 editorial-card p-10 bg-white dark:bg-[#141414] border-none shadow-editorial flex flex-col md:flex-row items-center gap-8 group overflow-hidden relative cursor-pointer"
>
<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 pointer-events-none" />
<div className="w-16 h-16 bg-brand-dark dark:bg-brand-accent rounded-[24px] flex items-center justify-center text-white dark:text-brand-dark text-3xl font-black shadow-2xl shrink-0 group-hover:rotate-6 transition-transform duration-500">
M
</div>
<div className="flex-1 text-left">
<div className="flex items-center gap-3 mb-2">
<span className="accent-pill !px-2.5 !py-0.5 text-[8px] italic">Ecosystème Wordly</span>
<h3 className="text-xl font-bold tracking-tight text-brand-dark dark:text-white uppercase">{t('memento.title')}</h3>
</div>
<p className="text-xs text-brand-dark/40 dark:text-white/40 font-light leading-relaxed max-w-2xl">
{t('memento.slogan')}
</p>
</div>
<div className="flex flex-col sm:flex-row gap-3 shrink-0 w-full md:w-auto">
<span className="premium-button px-8 py-3.5 text-[9px] uppercase tracking-widest !rounded-xl text-center">
{t('memento.ctaFree')}
</span>
<span className="px-8 py-3.5 border border-black/5 bg-brand-muted text-brand-dark/40 rounded-xl text-[9px] font-bold uppercase tracking-widest hover:text-brand-dark dark:border-white/5 dark:bg-white/5 dark:text-white/40 dark:hover:text-white hover:bg-brand-muted/70 transition-all text-center">
{t('memento.ctaMore')}
</span>
</div>
</a>
)}
{/* Mobile Sticky Action Bar (visible on mobile, hidden on lg) */}
{(showUpload || showConfiguring) && (
<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">
@@ -787,10 +776,10 @@ export default function TranslatePage() {
disabled={!config.isConfigValid || submit.isSubmitting || !upload.file}
onClick={handleTranslate}
className={cn(
'w-full py-4 text-[10px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-2 rounded-xl transition-all active:scale-98',
'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.file && !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/25 dark:bg-white/5 dark:text-white/20 cursor-not-allowed'
: 'bg-brand-muted/70 text-brand-dark/40 dark:bg-white/5 dark:text-white/30 cursor-not-allowed'
)}
>
{submit.isSubmitting ? (
@@ -830,48 +819,13 @@ function FileStrip({ file, onRemove, onReplace, t }: { file: File; onRemove: ()
);
}
/** 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-[11px] font-black text-brand-dark/50 uppercase tracking-[0.15em] dark:text-white/50">{label}</p>
<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>
);
}

View File

@@ -2,6 +2,7 @@ export type SupportedFormat = 'xlsx' | 'docx' | 'pptx';
export interface FileUploadState {
file: File | null;
/** i18n key (from ERROR_MESSAGES) — render through t() */
error: string | null;
isDragOver: boolean;
}
@@ -12,7 +13,6 @@ export interface FileUploadActions {
handleDragLeave: (e: React.DragEvent) => void;
handleFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
removeFile: () => void;
setMockFile: (type: 'word' | 'excel' | 'slides' | 'pdf') => void;
}
export interface UseFileUploadReturn extends FileUploadState, FileUploadActions {}

View File

@@ -4,9 +4,10 @@ import type { UseFileUploadReturn } from './types';
const ACCEPTED_EXTENSIONS = ['xlsx', 'docx', 'pptx', 'pdf'];
const MAX_FILE_SIZE = 50 * 1024 * 1024;
// i18n keys — rendered through t() so the message follows the UI locale.
export const ERROR_MESSAGES = {
INVALID_FORMAT: 'Format non supporté. Formats acceptés : .xlsx, .docx, .pptx, .pdf',
FILE_TOO_LARGE: 'Fichier trop volumineux (max 50 MB)',
INVALID_FORMAT: 'fileUploader.error.invalidFormat',
FILE_TOO_LARGE: 'fileUploader.error.tooLarge',
} as const;
export function useFileUpload(): UseFileUploadReturn {
@@ -69,31 +70,6 @@ export function useFileUpload(): UseFileUploadReturn {
}
}, [validateFile]);
const setMockFile = useCallback((type: 'word' | 'excel' | 'slides' | 'pdf') => {
const mockDetails: Record<string, { name: string; mime: string; size: number }> = {
word: { name: 'rapport_strategique_q3.docx', mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', size: 12.4 * 1024 * 1024 },
excel: { name: 'bilan_consolidé_2025.xlsx', mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: 4.2 * 1024 * 1024 },
slides: { name: 'keynote_produit_wordly.pptx', mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', size: 8.0 * 1024 * 1024 },
pdf: { name: 'cahier_des_charges_v2.pdf', mime: 'application/pdf', size: 15.1 * 1024 * 1024 },
};
const details = mockDetails[type];
if (details) {
// Create a dummy blob representing the file size
const dummyBlob = new Blob(['x'.repeat(100)], { type: details.mime });
// Create a custom File object that overrides size properties
const mockFile = new File([dummyBlob], details.name, {
type: details.mime,
lastModified: Date.now()
});
// Override the readonly size property for display purposes
Object.defineProperty(mockFile, 'size', { value: details.size });
setFile(mockFile);
setError(null);
}
}, []);
const removeFile = useCallback(() => {
setFile(null);
setError(null);
@@ -109,6 +85,5 @@ export function useFileUpload(): UseFileUploadReturn {
handleDragLeave,
handleFileSelect,
removeFile,
setMockFile,
};
}

View File

@@ -12,6 +12,41 @@ import { API_BASE } from '@/lib/config';
const POLLING_INTERVAL_MS = 2000;
const MAX_POLLING_FAILURES = 3;
/* ── Job persistence: survive a refresh mid-translation ─────────── */
const ACTIVE_JOB_KEY = 'wordly:activeJob';
const RECENT_JOBS_KEY = 'wordly:recentJobs';
const MAX_RECENT_JOBS = 8;
const ACTIVE_JOB_TTL_MS = 24 * 60 * 60 * 1000; // discard stale entries after 24h
interface StoredJob { jobId: string; fileName: string | null; savedAt: number }
export interface RecentJob { jobId: string; fileName: string; completedAt: number }
export function getRecentJobs(): RecentJob[] {
if (typeof window === 'undefined') return [];
try {
const list = JSON.parse(localStorage.getItem(RECENT_JOBS_KEY) ?? '[]');
return Array.isArray(list) ? list : [];
} catch {
return [];
}
}
function persistActiveJob(job: StoredJob | null) {
try {
if (!job) localStorage.removeItem(ACTIVE_JOB_KEY);
else localStorage.setItem(ACTIVE_JOB_KEY, JSON.stringify(job));
} catch { /* storage unavailable — session-only fallback */ }
}
function pushRecentJob(job: RecentJob) {
try {
const list = getRecentJobs().filter(j => j.jobId !== job.jobId);
list.unshift(job);
localStorage.setItem(RECENT_JOBS_KEY, JSON.stringify(list.slice(0, MAX_RECENT_JOBS)));
} catch { /* ignore */ }
}
export function useTranslationSubmit(): UseTranslationSubmitReturn {
const [jobId, setJobId] = useState<string | null>(null);
const [status, setStatus] = useState<TranslationStatus>('idle');
@@ -30,6 +65,7 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
// If we relied on state, the setInterval callback would always read the initial
// value of pollingFailures (0) and never reach MAX_POLLING_FAILURES.
const pollingFailuresRef = useRef(0);
const resumeAttemptedRef = useRef(false);
const stopPolling = useCallback(() => {
if (pollingIntervalRef.current) {
@@ -89,6 +125,9 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
if (job.status === 'failed') {
setError(job.error_message || 'Translation failed');
}
if (job.status === 'completed') {
pushRecentJob({ jobId: id, fileName: job.file_name || '', completedAt: Date.now() });
}
}
} catch (err) {
console.error('Polling error:', err);
@@ -198,6 +237,7 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
const reset = useCallback(() => {
stopPolling();
persistActiveJob(null);
setJobId(null);
setStatus('idle');
setProgress(0);
@@ -209,6 +249,34 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
setPollingFailures(0);
}, [stopPolling]);
// Persist the active job whenever it changes so a refresh can resume polling.
useEffect(() => {
if (jobId) persistActiveJob({ jobId, fileName, savedAt: Date.now() });
}, [jobId, fileName]);
// Resume an interrupted job once on mount (page refreshed mid-translation).
useEffect(() => {
if (resumeAttemptedRef.current) return;
resumeAttemptedRef.current = true;
try {
const raw = localStorage.getItem(ACTIVE_JOB_KEY);
if (!raw) return;
const stored: StoredJob = JSON.parse(raw);
if (!stored?.jobId || Date.now() - stored.savedAt > ACTIVE_JOB_TTL_MS) {
persistActiveJob(null);
return;
}
setJobId(stored.jobId);
if (stored.fileName) setFileName(stored.fileName);
setStatus('processing');
setProgress(0);
startPolling(stored.jobId);
} catch {
persistActiveJob(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
return () => {
stopPolling();