feat: homelab deployment - NPM + IONOS DNS + monitoring + NAS backup

- Restructured docker-compose for Nginx Proxy Manager (no custom nginx)
- Added domain wordly.art configuration
- Added Prometheus + Grafana monitoring stack with pre-configured dashboards
- Added PostgreSQL backup script to NAS (daily/weekly/monthly rotation)
- Added alert rules for backend, system, and Docker metrics
- Updated deployment guide for NPM + IONOS DNS homelab setup
- Added marketing plan document
- PDF translator and watermark support
- Enhanced middleware, routes, and translator modules

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 11:43:28 +02:00
parent 16ac7ca2b9
commit ce8e150a61
110 changed files with 6935 additions and 4301 deletions

View File

@@ -57,24 +57,32 @@ export function FileDropZone({ upload }: FileDropZoneProps) {
{/* 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">
<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">
<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"
accept=".xlsx,.docx,.pptx,.pdf"
className="hidden"
onChange={upload.handleFileSelect}
aria-label={t('dashboard.translate.dropzone.uploadAria')}

View File

@@ -40,7 +40,7 @@ export function FilePreview({ file, onRemove }: FilePreviewProps) {
<Button
variant="ghost"
size="icon-sm"
className="ml-2 text-muted-foreground hover:text-foreground shrink-0"
className="ms-2 text-muted-foreground hover:text-foreground shrink-0"
onClick={(e) => {
e.stopPropagation();
onRemove();

View File

@@ -1,14 +1,9 @@
'use client';
import { ArrowRight, Loader2, AlertCircle } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useState, useRef, useEffect, useCallback } from 'react';
import { Loader2, AlertCircle, ChevronDown, Check } from 'lucide-react';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import type { Language } from './types';
interface LanguageSelectorProps {
@@ -21,84 +16,179 @@ interface LanguageSelectorProps {
onTargetChange: (value: string) => void;
}
export function LanguageSelector({
sourceLang,
targetLang,
languages,
isLoading,
error,
onSourceChange,
onTargetChange,
/* ── Combobox dropdown with search ──────────────────────────────── */
function Combobox({
value,
options,
includeAuto,
autoLabel,
placeholder,
onChange,
}: {
value: string;
options: Language[];
includeAuto: boolean;
autoLabel: string;
placeholder: string;
onChange: (code: string) => void;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const ref = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
if (open) document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const allOptions = includeAuto
? [{ code: 'auto', name: autoLabel }, ...options]
: options;
const q = query.toLowerCase().trim();
const filtered = q
? allOptions.filter(l => l.name.toLowerCase().includes(q) || l.code.toLowerCase().includes(q))
: allOptions;
const label = value === 'auto' ? autoLabel : allOptions.find(l => l.code === value)?.name ?? value;
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className={cn(
'flex w-full items-center justify-between rounded-lg border px-3 py-2 text-sm transition-colors',
open
? 'border-primary ring-2 ring-primary/15 outline-none'
: 'border-border bg-background hover:border-muted-foreground/40'
)}
>
<span className="truncate text-foreground">{label || placeholder}</span>
<ChevronDown className={cn('size-4 shrink-0 text-muted-foreground transition-transform ms-2', open && 'rotate-180')} />
</button>
{open && (
<div className="absolute top-full left-0 right-0 z-50 mt-1 overflow-hidden rounded-lg border border-border bg-popover shadow-md">
<div className="border-b border-border px-2 py-1.5">
<input
ref={inputRef}
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
className="w-full bg-transparent px-1 py-1 text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<div className="max-h-[200px] overflow-y-auto p-1">
{filtered.length === 0 && (
<div className="px-3 py-3 text-center text-xs text-muted-foreground">No results</div>
)}
{filtered.map(lang => (
<button
key={lang.code}
type="button"
onClick={() => { onChange(lang.code); setOpen(false); setQuery(''); }}
className={cn(
'flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-sm transition-colors',
value === lang.code
? 'bg-primary/10 text-primary font-medium'
: 'text-foreground hover:bg-muted'
)}
>
<span className="flex-1 text-start">{lang.name}</span>
{value === lang.code && <Check className="size-3.5 shrink-0" />}
</button>
))}
</div>
</div>
)}
</div>
);
}
/* ── Main component ─────────────────────────────────────────────── */
export default function LanguageSelector({
sourceLang, targetLang, languages, isLoading, error,
onSourceChange, onTargetChange,
}: LanguageSelectorProps) {
const { t } = useI18n();
if (error) {
return (
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertCircle className="size-3.5 shrink-0" />
<span>{t('dashboard.translate.language.loadErrorPrefix')} {error}</span>
</div>
);
}
if (isLoading) {
return (
<div className="flex items-center justify-center gap-2 py-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span className="text-xs">{t('dashboard.translate.language.loading')}</span>
</div>
);
}
const canSwap = sourceLang !== 'auto';
return (
<div className="flex flex-col gap-3">
{error && (
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertCircle className="size-3.5 shrink-0" />
<span>{t('dashboard.translate.language.loadErrorPrefix')} {error}</span>
</div>
)}
<div className="space-y-3">
{/* Source */}
<div>
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t('dashboard.translate.language.source')}
</label>
<Combobox
value={sourceLang}
options={languages}
includeAuto
autoLabel={t('dashboard.translate.language.autoDetect')}
placeholder={t('dashboard.translate.language.selectPlaceholder')}
onChange={onSourceChange}
/>
</div>
<div className="flex items-end gap-3">
{/* Source language */}
<div className="flex flex-1 flex-col gap-1.5">
<label className="text-sm font-medium text-foreground">
{t('dashboard.translate.language.source')}
</label>
<Select value={sourceLang} onValueChange={onSourceChange} disabled={isLoading}>
<SelectTrigger className="h-11 w-full">
{isLoading ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
<span>{t('dashboard.translate.language.loading')}</span>
</div>
) : (
<SelectValue placeholder={t('dashboard.translate.language.autoDetect')} />
)}
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t('dashboard.translate.language.autoDetect')}</SelectItem>
{languages.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Swap */}
<div className="flex justify-center -my-0.5">
<button
type="button"
onClick={() => canSwap && (() => { const s = sourceLang; onSourceChange(targetLang); onTargetChange(s); })()}
disabled={!canSwap}
className={cn(
'flex size-8 items-center justify-center rounded-full border shadow-sm transition-colors',
canSwap
? 'border-border bg-background text-muted-foreground hover:border-primary hover:text-primary'
: 'cursor-not-allowed border-border/50 bg-muted text-muted-foreground/40'
)}
title="Inverser"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
</button>
</div>
{/* Arrow — flips in RTL via rtl: variant */}
<div className="mb-2 flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<ArrowRight className="size-4 rtl:rotate-180" />
</div>
{/* Target language */}
<div className="flex flex-1 flex-col gap-1.5">
<label className="text-sm font-medium text-foreground">
{t('dashboard.translate.language.target')}
</label>
<Select value={targetLang} onValueChange={onTargetChange} disabled={isLoading}>
<SelectTrigger className="h-11 w-full">
{isLoading ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
<span>{t('dashboard.translate.language.loading')}</span>
</div>
) : (
<SelectValue placeholder={t('dashboard.translate.language.selectPlaceholder')} />
)}
</SelectTrigger>
<SelectContent>
{languages.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Target */}
<div>
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t('dashboard.translate.language.target')}
</label>
<Combobox
value={targetLang}
options={languages}
includeAuto={false}
autoLabel=""
placeholder={t('dashboard.translate.language.selectPlaceholder')}
onChange={onTargetChange}
/>
</div>
</div>
);

View File

@@ -1,11 +1,15 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { CheckCircle2, Download, Plus, Loader2 } from 'lucide-react';
import {
CheckCircle2, Download, Plus, Loader2, FileText,
Timer, Activity, TrendingUp,
} 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;
@@ -91,54 +95,78 @@ export function TranslationComplete({
}, []);
return (
<div className="flex w-full max-w-md flex-col items-center gap-8 rounded-2xl border border-border bg-card p-8 text-center shadow-sm">
{/* Success icon */}
<div className="flex size-20 items-center justify-center rounded-full bg-green-500/15">
<CheckCircle2 className="size-10 text-green-500" />
</div>
<div className="flex w-full max-w-lg flex-col gap-0 overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
{/* Text */}
<div className="flex flex-col gap-2">
<h3 className="text-xl font-bold text-foreground">
{/* ═══ 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="text-sm text-muted-foreground">
<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" /> Haute qualité
</div>
</div>
{/* Actions — stacked column so text is never cut */}
<div className="flex w-full 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>
<div className="p-8 space-y-6">
<Button
variant="outline"
size="lg"
className="h-11 w-full gap-2"
onClick={onNewTranslation}
>
<Plus className="size-4" />
{t('dashboard.translate.complete.newTranslation')}
</Button>
{/* ═══ 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">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">Caractères</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">Confiance</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"
onClick={onNewTranslation}
>
<Plus className="size-4" />
{t('dashboard.translate.complete.newTranslation')}
</Button>
</div>
</div>
</div>
);

View File

@@ -39,7 +39,7 @@ export function TranslationModeToggle({
onClick={() => onModeChange('classic')}
>
Classic
<span className="ml-1.5 text-xs text-muted-foreground">
<span className="ms-1.5 text-xs text-muted-foreground">
Fast
</span>
</button>
@@ -58,7 +58,7 @@ export function TranslationModeToggle({
disabled={!isPro}
>
Pro LLM
<span className="ml-1.5 text-xs text-muted-foreground">
<span className="ms-1.5 text-xs text-muted-foreground">
Context-Aware
</span>
{!isPro && (

View File

@@ -1,10 +1,16 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { AlertTriangle, Loader2, Clock, WifiOff } from 'lucide-react';
import { Progress } from '@/components/ui/progress';
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;
@@ -13,8 +19,84 @@ interface TranslationProgressProps {
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,
@@ -23,101 +105,254 @@ export function TranslationProgress({
isPolling = true,
isUploading = false,
isCompleted = false,
fileName,
sourceLang,
targetLang,
providerLabel,
onCancel,
}: TranslationProgressProps) {
const { t } = useI18n();
const [animate, setAnimate] = useState(false);
const prevProgressRef = useRef(progress);
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 if (progress === 0) {
if (progress > 0) setAnimate(true);
else {
setAnimate(false);
const tid = setTimeout(() => setAnimate(true), 50);
return () => clearTimeout(tid);
}
prevProgressRef.current = progress;
}, [progress]);
function formatTimeRemaining(seconds: number | null): string {
if (seconds === null || seconds <= 0) return '';
if (seconds < 60)
return t('dashboard.translate.progress.timeSeconds', { seconds: String(seconds) });
const minutes = Math.floor(seconds / 60);
const remaining = seconds % 60;
if (remaining === 0)
return t('dashboard.translate.progress.timeMinutes', { minutes: String(minutes) });
return t('dashboard.translate.progress.timeMixed', {
minutes: String(minutes),
seconds: String(remaining),
});
}
// 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="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" aria-hidden />
<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 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 timeRemaining = formatTimeRemaining(estimatedRemaining);
const showConnectionLost = !isPolling && !isCompleted && !isUploading;
return (
<div className="flex flex-col items-center gap-6 py-4">
{/* Animated spinner */}
<div className="flex size-16 items-center justify-center rounded-full bg-primary/10">
<Loader2 className="size-8 animate-spin text-primary" aria-hidden />
<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>
{/* Status text */}
<div className="flex flex-col items-center gap-1 text-center">
<p className="text-base font-medium text-foreground">
{currentStep || t('dashboard.translate.progress.processingFallback')}
</p>
{timeRemaining && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock className="size-3.5" aria-hidden />
{timeRemaining}
</p>
<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>
{/* Progress bar + percentage */}
<div className="w-full max-w-md space-y-2">
<Progress
value={progress}
animate={animate}
className="h-3 rounded-full"
aria-label={t('dashboard.translate.progress.ariaProgress')}
aria-valuenow={Math.round(progress)}
aria-valuemin={0}
aria-valuemax={100}
/>
<p className="text-end text-sm font-semibold tabular-nums text-primary" aria-live="polite">
{Math.round(progress)} %
</p>
</div>
{showConnectionLost && (
<div className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-400">
<WifiOff className="size-3.5" aria-hidden />
<span>{t('dashboard.translate.progress.connectionLost')}</span>
</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,106 +1,70 @@
'use client';
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState, useMemo } from 'react';
import {
ShieldCheck,
Clock,
ArrowRight,
RotateCcw,
Loader2,
FileSpreadsheet,
FileText,
Presentation,
Upload,
X,
ShieldCheck, Clock, ArrowRight, RotateCcw, Loader2,
FileSpreadsheet, FileText, Presentation, Upload, X,
Zap, Brain, CheckCircle2, ArrowLeftRight,
Search, Languages, Wrench, Activity, Gauge, Timer,
Download, Plus, TrendingUp, AlertTriangle, FileType,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { FileDropZone } from './FileDropZone';
import { useFileUpload } from './useFileUpload';
import { useTranslationConfig } from './useTranslationConfig';
import { useTranslationSubmit } from './useTranslationSubmit';
import { LanguageSelector } from './LanguageSelector';
import LanguageSelector from './LanguageSelector';
import { ProviderSelector } from './ProviderSelector';
import { TranslationProgress } from './TranslationProgress';
import { TranslationComplete } from './TranslationComplete';
import { useNotification } from '@/components/ui/notification';
import { useI18n } from '@/lib/i18n';
import { API_BASE } from '@/lib/config';
import { cn } from '@/lib/utils';
/* ── helpers ─────────────────────────────────────────────────────── */
const FILE_ICONS: Record<string, React.ElementType> = {
xlsx: FileSpreadsheet,
docx: FileText,
pptx: Presentation,
xlsx: FileSpreadsheet, docx: FileText, pptx: Presentation, pdf: FileType,
};
const FILE_COLORS: Record<string, string> = {
xlsx: 'text-green-500',
docx: 'text-blue-500',
pptx: 'text-orange-500',
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`;
}
/* ── Compact file strip ──────────────────────────────────────────── */
function FileStrip({
file,
onRemove,
onReplace,
}: {
file: File;
onRemove: () => void;
onReplace: () => void;
}) {
const { t } = useI18n();
const ext = file.name.split('.').pop()?.toLowerCase() ?? '';
const FileIcon = FILE_ICONS[ext] ?? FileText;
const color = FILE_COLORS[ext] ?? 'text-muted-foreground';
return (
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
<FileIcon className={`size-5 shrink-0 ${color}`} />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-semibold text-foreground">{file.name}</span>
<span className="text-xs text-muted-foreground">{fmt(file.size)} · .{ext.toUpperCase()}</span>
</div>
<button
type="button"
onClick={onReplace}
className="flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground transition hover:bg-secondary hover:text-foreground"
>
<Upload className="size-3.5" />
{t('dashboard.translate.dropzone.replaceFile')}
</button>
<button
type="button"
aria-label="Remove"
onClick={onRemove}
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-secondary hover:text-foreground"
>
<X className="size-4" />
</button>
</div>
);
/* ── 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');
}
/* ── Trust row ───────────────────────────────────────────────────── */
function TrustRow() {
const { t } = useI18n();
return (
<div className="flex flex-wrap items-center justify-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<ShieldCheck className="size-3.5" />
{t('dashboard.translate.trust.zeroRetention')}
</span>
<span className="h-3 w-px bg-border" aria-hidden />
<span className="flex items-center gap-1.5">
<Clock className="size-3.5" />
{t('dashboard.translate.trust.deletedAfter')}
</span>
</div>
);
/* ── Pipeline step keys ─────────────────────────────────────────── */
const PIPELINE_STEP_KEYS = [
'dashboard.translate.pipeline.upload',
'dashboard.translate.pipeline.analyze',
'dashboard.translate.pipeline.translate',
'dashboard.translate.pipeline.rebuild',
'dashboard.translate.pipeline.finalize',
] as const;
const PIPELINE_ICONS = [Upload, Search, Languages, Wrench, CheckCircle2] as const;
const PIPELINE_STARTS = [0, 10, 25, 75, 92];
function getActiveStepIdx(progress: number) {
for (let i = PIPELINE_STARTS.length - 1; i >= 0; i--) {
if (progress >= PIPELINE_STARTS[i]) return i;
}
return 0;
}
function formatElapsed(totalSeconds: number) {
const m = Math.floor(totalSeconds / 60);
const s = totalSeconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
/* ── Page ────────────────────────────────────────────────────────── */
@@ -112,201 +76,528 @@ export default function TranslatePage() {
const { t } = useI18n();
const lastErrorRef = useRef<string | null>(null);
const replaceInputRef = useRef<HTMLInputElement>(null);
const [pdfMode, setPdfMode] = useState<'layout' | 'text_only'>('layout');
const [elapsed, setElapsed] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isPdf = upload.file?.name.toLowerCase().endsWith('.pdf') ?? false;
useEffect(() => {
if (submit.error && submit.error !== lastErrorRef.current) {
lastErrorRef.current = submit.error;
showError({
title: t('dashboard.translate.errorNotificationTitle'),
description: submit.error,
});
showError({ title: t('dashboard.translate.errorNotificationTitle'), description: submit.error });
}
}, [submit.error, showError, t]);
// Elapsed timer
useEffect(() => {
if ((submit.status === 'processing' || submit.isSubmitting) && submit.status !== 'completed') {
setElapsed(0);
timerRef.current = setInterval(() => setElapsed((s) => s + 1), 1000);
} else {
if (timerRef.current) clearInterval(timerRef.current);
}
return () => { if (timerRef.current) clearInterval(timerRef.current); };
}, [submit.status, submit.isSubmitting]);
const handleTranslate = async () => {
if (!upload.file || !config.isConfigValid) return;
await submit.submitTranslation(upload.file, config.getConfig());
const cfg = config.getConfig();
if (isPdf) cfg.pdfMode = pdfMode;
await submit.submitTranslation(upload.file, cfg);
};
const handleNewTranslation = () => {
submit.reset();
upload.removeFile();
const handleNewTranslation = () => { submit.reset(); upload.removeFile(); setElapsed(0); };
const handleDownload = async () => {
if (!submit.jobId) return;
const token = localStorage.getItem('token');
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const response = await fetch(`${API_BASE}/api/v1/download/${submit.jobId}`, { headers });
if (!response.ok) return;
const contentDisposition = response.headers.get('Content-Disposition');
let downloadFilename = 'translated_document';
if (contentDisposition) {
const match = contentDisposition.match(/filename\*?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']+)/i);
if (match?.[1]) downloadFilename = match[1];
} else if (submit.fileName) {
const ext = submit.fileName.split('.').pop() || '';
const base = submit.fileName.replace(/\.[^.]+$/, '');
downloadFilename = `${base}_translated.${ext}`;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = downloadFilename;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
/* ── Derived states ──────────────────────────────────────────── */
const isConfiguring = !!upload.file && submit.status === 'idle' && !submit.isSubmitting;
const isProcessing =
(submit.status === 'processing' || submit.isSubmitting) && submit.status !== 'completed';
const isProcessing = (submit.status === 'processing' || submit.isSubmitting) && submit.status !== 'completed';
const isCompleted = submit.status === 'completed';
const isFailed = submit.status === 'failed';
/* ── STATE: No file ─────────────────────────────────────────────── */
if (!upload.file && !isProcessing && !isCompleted && !isFailed) {
return (
<div className="flex h-full flex-col gap-6 overflow-y-auto p-6 lg:p-8">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
{t('dashboard.translate.pageTitle')}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{t('dashboard.translate.pageSubtitle')}
</p>
</div>
<div className="flex flex-1 flex-col">
<FileDropZone upload={upload} />
{upload.error && <p className="mt-2 text-sm text-destructive">{upload.error}</p>}
</div>
<TrustRow />
const showUpload = !upload.file && !isProcessing && !isCompleted && !isFailed;
const showConfiguring = isConfiguring;
const showProcessing = isProcessing;
const showComplete = isCompleted && !!submit.jobId;
const showFailed = isFailed;
const currentProvider = config.availableProviders.find(p => p.id === config.provider);
const srcLangName = config.languages.find(l => l.code === config.sourceLang)?.name || config.sourceLang;
const tgtLangName = config.languages.find(l => l.code === config.targetLang)?.name || config.targetLang;
const activeStepIdx = getActiveStepIdx(submit.progress);
const qualityLabel = useMemo(() => getQualityLabel(t, config.provider), [t, config.provider]);
/* ═══════════════════════════════════════════════════════════════ */
/* UNIFIED LAYOUT — always the same grid */
/* ═══════════════════════════════════════════════════════════════ */
return (
<div className="flex h-full flex-col gap-6 overflow-y-auto p-6 lg:p-8">
{/* Header */}
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
{t('dashboard.translate.pageTitle')}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{t('dashboard.translate.pageSubtitle')}
</p>
</div>
);
}
/* ── STATE: Configure — single column, full width ───────────────── */
if (isConfiguring) {
return (
<div className="flex h-full flex-col overflow-y-auto">
{/* Scrollable content */}
<div className="flex flex-1 flex-col gap-6 p-6 lg:p-8">
{/* Page title */}
<div>
<h1 className="text-xl font-bold tracking-tight text-foreground">
{t('dashboard.translate.pageTitle')}
</h1>
</div>
{/* Grid: LEFT (2/3) + RIGHT (1/3) — always present */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* File strip */}
<FileStrip
file={upload.file!}
onRemove={upload.removeFile}
onReplace={() => replaceInputRef.current?.click()}
/>
<input
ref={replaceInputRef}
type="file"
accept=".xlsx,.docx,.pptx"
className="hidden"
onChange={upload.handleFileSelect}
/>
{upload.error && <p className="text-sm text-destructive">{upload.error}</p>}
{/* ═══════════════════════════════════════════════════════ */}
{/* LEFT CARD (2/3) — content swaps based on state */}
{/* ═══════════════════════════════════════════════════════ */}
<div className="lg:col-span-2 flex flex-col gap-0 rounded-2xl border border-border bg-card shadow-sm overflow-hidden">
{/* Language selector — full width */}
<LanguageSelector
sourceLang={config.sourceLang}
targetLang={config.targetLang}
languages={config.languages}
isLoading={config.isLoadingLanguages}
error={config.languagesError}
onSourceChange={config.setSourceLang}
onTargetChange={config.setTargetLang}
/>
{/* ── UPLOAD STATE: Dropzone ─────────────────────────── */}
{showUpload && (
<>
<div className="px-6 pt-6 pb-4">
<h2 className="text-lg font-semibold text-foreground">{t('dashboard.translate.sourceDocument')}</h2>
</div>
<div className="flex-1 px-6 pb-6">
<FileDropZone upload={upload} />
{upload.error && <p className="mt-2 text-sm text-destructive">{upload.error}</p>}
</div>
</>
)}
{/* Provider selector — full width */}
<ProviderSelector
provider={config.provider}
onProviderChange={config.setProvider}
availableProviders={config.availableProviders}
isLoadingProviders={config.isLoadingProviders}
isPro={config.isPro}
/>
{/* ── CONFIGURING STATE: File strip ──────────────────── */}
{showConfiguring && (
<>
<div className="px-6 pt-6 pb-4">
<h2 className="text-lg font-semibold text-foreground">{t('dashboard.translate.sourceDocument')}</h2>
</div>
<div className="px-6 pb-6">
<FileStrip file={upload.file!} onRemove={upload.removeFile} onReplace={() => replaceInputRef.current?.click()} t={t} />
<input ref={replaceInputRef} type="file" accept=".xlsx,.docx,.pptx,.pdf" className="hidden" onChange={upload.handleFileSelect} />
{upload.error && <p className="mt-2 text-sm text-destructive">{upload.error}</p>}
</div>
</>
)}
{/* ── PROCESSING STATE: Rich progress ────────────────── */}
{showProcessing && (
<div className="flex flex-col">
{/* Header band */}
<div className="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">{t('dashboard.translate.translating')}</h2>
{(submit.fileName || upload.file?.name) && (
<p className="text-xs text-muted-foreground mt-0.5 truncate max-w-[300px]">
{submit.fileName || upload.file?.name}
</p>
)}
</div>
</div>
{submit.estimatedRemaining != null && submit.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" />
~{submit.estimatedRemaining}s
</div>
)}
</div>
</div>
<div className="flex flex-col gap-5 p-6">
{/* Pipeline stepper */}
<PipelineStepper activeIdx={activeStepIdx} t={t} />
{/* 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">
{submit.currentStep || (submit.isSubmitting ? t('dashboard.translate.steps.uploading') : t('dashboard.translate.steps.starting'))}
</p>
<p className="text-2xl font-black tabular-nums tracking-tight text-primary shrink-0">
{Math.round(submit.progress)}%
</p>
</div>
<div className="h-3 w-full overflow-hidden rounded-full bg-primary/10">
<div
className="h-full rounded-full bg-gradient-to-r from-primary via-primary/80 to-primary transition-all duration-700 ease-out"
style={{ width: `${Math.max(0, submit.progress)}%` }}
/>
</div>
</div>
{/* Live stats */}
<div className="grid grid-cols-4 gap-2.5">
<StatBox icon={<FileText className="size-4" />} value={`${Math.round(submit.progress)}%`} label={t('dashboard.translate.segments')} />
<StatBox icon={<Activity className="size-4" />} value={t('dashboard.translate.translating')} label={t('dashboard.translate.characters')} />
<StatBox icon={<Gauge className="size-4" />} value="—" label={t('dashboard.translate.segPerMin')} />
<StatBox icon={<Timer className="size-4" />} value={formatElapsed(elapsed)} label={t('dashboard.translate.elapsed')} />
</div>
</div>
</div>
)}
{/* ── COMPLETE STATE: Success ────────────────────────── */}
{showComplete && (
<div className="flex flex-col">
{/* Success header */}
<div className="border-b border-emerald-200/50 bg-gradient-to-r from-emerald-500/8 via-emerald-500/5 to-transparent px-6 py-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-xl bg-emerald-500 shadow-lg shadow-emerald-500/20">
<CheckCircle2 className="size-5 text-white" />
</div>
<div>
<h2 className="text-base font-bold text-foreground leading-tight">{t('dashboard.translate.completed')}</h2>
{submit.fileName && (
<p className="text-xs text-muted-foreground mt-0.5 truncate max-w-[260px]">{submit.fileName}</p>
)}
</div>
</div>
<div className="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" />{qualityLabel}
</div>
</div>
</div>
<div className="flex flex-col gap-5 p-6">
{/* Actions */}
<div className="flex items-center gap-3">
<Button size="lg" className="h-11 gap-2 flex-1 font-semibold" onClick={handleDownload}>
<Download className="size-4" />{t('dashboard.translate.complete.download')}
</Button>
<Button variant="outline" size="lg" className="h-11 gap-2 flex-1" onClick={handleNewTranslation}>
<Plus className="size-4" />{t('dashboard.translate.complete.newTranslation')}
</Button>
</div>
</div>
</div>
)}
{/* ── FAILED STATE ───────────────────────────────────── */}
{showFailed && (
<div className="flex flex-col gap-4 p-6">
<div className="rounded-xl bg-destructive/10 border border-destructive/20 p-4" role="alert">
<div className="flex items-start gap-3">
<AlertTriangle className="size-5 text-destructive shrink-0 mt-0.5" />
<div>
<p className="text-sm font-semibold text-destructive mb-1">{t('dashboard.translate.progress.failedTitle')}</p>
<p className="text-sm text-destructive/80">{submit.error}</p>
</div>
</div>
</div>
{(submit.fileName || upload.file?.name) && (
<FileStrip file={upload.file!} onRemove={upload.removeFile} onReplace={() => replaceInputRef.current?.click()} t={t} />
)}
</div>
)}
</div>
{/* Sticky bottom bar: button + trust */}
<div className="shrink-0 border-t border-border/50 bg-background px-6 py-4 lg:px-8">
<Button
size="lg"
className="h-13 w-full gap-2 text-base font-semibold"
disabled={!config.isConfigValid || submit.isSubmitting}
onClick={handleTranslate}
>
{submit.isSubmitting ? (
<>
<Loader2 className="size-5 animate-spin" />
{t('dashboard.translate.actions.uploading')}
</>
) : (
<>
{t('dashboard.translate.actions.translate')}
<ArrowRight className="size-5 rtl:rotate-180" />
</>
)}
</Button>
<div className="mt-3">
<TrustRow />
</div>
{/* ═══════════════════════════════════════════════════════ */}
{/* RIGHT CARD (1/3) — Config / Monitor / Summary */}
{/* ═══════════════════════════════════════════════════════ */}
<div className="flex flex-col gap-0 rounded-2xl border border-border bg-card shadow-sm">
{/* ── CONFIG (upload / configuring / failed) ──────────── */}
{(showUpload || showConfiguring || showFailed) && (
<>
<div className="px-6 pt-6 pb-4">
<h2 className="text-lg font-semibold text-foreground">{t('dashboard.translate.configuration')}</h2>
</div>
<div className="flex flex-1 flex-col gap-5 px-6">
<LanguageSelector
sourceLang={config.sourceLang} targetLang={config.targetLang}
languages={config.languages} isLoading={config.isLoadingLanguages}
error={config.languagesError} onSourceChange={config.setSourceLang}
onTargetChange={config.setTargetLang}
/>
<ProviderSelector
provider={config.provider} onProviderChange={config.setProvider}
availableProviders={config.availableProviders} isLoadingProviders={config.isLoadingProviders}
isPro={config.isPro}
/>
{/* PDF mode selector — only shown for PDF files */}
{isPdf && (
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{t('dashboard.translate.pdfMode.title')}
</label>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setPdfMode('layout')}
className={cn(
'flex flex-col items-start rounded-lg border-2 p-3 text-start transition-all',
pdfMode === 'layout'
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
: 'border-border hover:border-primary/40'
)}
>
<div className="flex items-center gap-2 text-sm font-semibold">
<FileText className="size-4 text-primary" />
{t('dashboard.translate.pdfMode.preserveLayout')}
</div>
<p className="mt-1 text-xs text-muted-foreground leading-relaxed">
{t('dashboard.translate.pdfMode.preserveLayoutDesc')}
</p>
</button>
<button
type="button"
onClick={() => setPdfMode('text_only')}
className={cn(
'flex flex-col items-start rounded-lg border-2 p-3 text-start transition-all',
pdfMode === 'text_only'
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
: 'border-border hover:border-primary/40'
)}
>
<div className="flex items-center gap-2 text-sm font-semibold">
<Languages className="size-4 text-primary" />
{t('dashboard.translate.pdfMode.textOnly')}
</div>
<p className="mt-1 text-xs text-muted-foreground leading-relaxed">
{t('dashboard.translate.pdfMode.textOnlyDesc')}
</p>
</button>
</div>
</div>
)}
</div>
{/* Footer with button */}
<div className="px-6 py-4 border-t border-border/50">
<Button
size="lg" className="h-12 w-full gap-2 text-base font-semibold"
disabled={!config.isConfigValid || submit.isSubmitting || !upload.file}
onClick={handleTranslate}
>
{submit.isSubmitting ? (
<><Loader2 className="size-5 animate-spin" />{t('dashboard.translate.actions.uploading')}</>
) : (
<>{t('dashboard.translate.actions.translate')}<ArrowRight className="size-5 rtl:rotate-180" /></>
)}
</Button>
<div className="mt-3 flex items-center justify-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5"><ShieldCheck className="size-3.5" />{t('dashboard.translate.trust.zeroRetention')}</span>
<span className="h-3 w-px bg-border" aria-hidden />
<span className="flex items-center gap-1.5"><Clock className="size-3.5" />{t('dashboard.translate.trust.deletedAfter')}</span>
</div>
</div>
</>
)}
{/* ── MONITOR (processing) ────────────────────────────── */}
{showProcessing && (
<>
<div className="flex items-center gap-2 border-b border-border px-6 py-4">
<div className="size-2 animate-pulse rounded-full bg-primary" />
<h3 className="text-sm font-semibold text-foreground">{t('dashboard.translate.liveMonitor')}</h3>
</div>
<div className="flex flex-1 flex-col gap-4 p-6">
{/* File summary */}
{(submit.fileName || upload.file?.name) && (
<div className="flex items-center gap-3 rounded-xl bg-primary/5 border border-primary/10 p-3">
{(() => {
const name = submit.fileName || upload.file?.name || '';
const ext = name.split('.').pop()?.toLowerCase() ?? '';
const FileIcon = FILE_ICONS[ext] ?? FileText;
const color = FILE_COLORS[ext] ?? 'text-muted-foreground';
return <FileIcon className={`size-6 shrink-0 ${color}`} />;
})()}
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-semibold text-foreground">{submit.fileName || upload.file?.name}</p>
{upload.file && <p className="text-[11px] text-muted-foreground">{fmt(upload.file.size)}</p>}
</div>
</div>
)}
{/* Config summary */}
<div className="space-y-2.5">
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
<span className="text-xs text-muted-foreground">{t('dashboard.translate.language.source')}</span>
<span className="text-xs font-semibold text-foreground bg-muted px-2 py-0.5 rounded">{srcLangName}</span>
</div>
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
<span className="text-xs text-muted-foreground">{t('dashboard.translate.language.target')}</span>
<span className="text-xs font-semibold text-primary bg-primary/10 px-2 py-0.5 rounded">{tgtLangName}</span>
</div>
{currentProvider && (
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
<span className="text-xs text-muted-foreground">{t('dashboard.translate.engine')}</span>
<span className="text-xs font-semibold text-foreground bg-muted px-2 py-0.5 rounded">{currentProvider.label}</span>
</div>
)}
</div>
{/* Quality indicator */}
<div className="rounded-xl border border-border bg-muted/20 p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{t('dashboard.translate.quality')}</span>
<span className="text-xs font-bold text-emerald-600">{qualityLabel}</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-emerald-100 dark:bg-emerald-900/30">
<div
className="h-full rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600 transition-all duration-700"
style={{ width: `${Math.min(95, 40 + submit.progress * 0.55)}%` }}
/>
</div>
</div>
</div>
{/* Cancel */}
<div className="px-6 py-4 border-t border-border/50">
<Button
variant="outline"
className="w-full gap-2 border-destructive/20 text-destructive hover:bg-destructive hover:text-white hover:border-destructive"
onClick={handleNewTranslation}
>
<RotateCcw className="size-4" />{t('dashboard.translate.cancel')}
</Button>
</div>
</>
)}
{/* ── SUMMARY (complete) ──────────────────────────────── */}
{showComplete && (
<>
<div className="px-6 py-4 border-b border-border">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
<CheckCircle2 className="size-4 text-emerald-500" />{t('dashboard.translate.summary')}
</h3>
</div>
<div className="flex-1 p-6 space-y-2.5">
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
<span className="text-xs text-muted-foreground">{t('dashboard.translate.language.source')}</span>
<span className="text-xs font-semibold text-foreground bg-muted px-2 py-0.5 rounded">{srcLangName}</span>
</div>
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
<span className="text-xs text-muted-foreground">{t('dashboard.translate.language.target')}</span>
<span className="text-xs font-semibold text-primary bg-primary/10 px-2 py-0.5 rounded">{tgtLangName}</span>
</div>
{currentProvider && (
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
<span className="text-xs text-muted-foreground">{t('dashboard.translate.engine')}</span>
<span className="text-xs font-semibold text-foreground bg-muted px-2 py-0.5 rounded">{currentProvider.label}</span>
</div>
)}
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-muted-foreground">{t('dashboard.translate.quality')}</span>
<span className="text-xs font-bold text-emerald-600">{qualityLabel}</span>
</div>
</div>
{/* Quality bar */}
<div className="px-6 pb-6">
<div className="rounded-xl border border-border bg-muted/20 p-3">
<div className="h-2 w-full overflow-hidden rounded-full bg-emerald-100 dark:bg-emerald-900/30">
<div className="h-full rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600" style={{ width: '95%' }} />
</div>
</div>
</div>
</>
)}
</div>
</div>
);
}
/* ── STATE: Processing ──────────────────────────────────────────── */
if (isProcessing) {
return (
<div className="flex h-full flex-col items-center justify-center gap-6 overflow-y-auto p-6">
{(submit.fileName || upload.file?.name) && (
<p className="text-sm text-muted-foreground">
{t('dashboard.translate.actions.filePrefix')}{' '}
<span className="font-medium text-foreground">
{submit.fileName || upload.file?.name}
</span>
</p>
)}
<div className="w-full max-w-md">
<TranslationProgress
progress={submit.progress}
currentStep={
submit.currentStep ||
(submit.isSubmitting
? t('dashboard.translate.steps.uploading')
: t('dashboard.translate.steps.starting'))
}
estimatedRemaining={submit.estimatedRemaining}
error={null}
isPolling={submit.isPolling}
isUploading={submit.isSubmitting}
isCompleted={false}
/>
</div>
<Button variant="outline" size="lg" onClick={handleNewTranslation} className="gap-2">
<RotateCcw className="size-4" />
{t('dashboard.translate.actions.cancel')}
</Button>
</div>
);
}
/* ── STATE: Complete ────────────────────────────────────────────── */
if (isCompleted && submit.jobId) {
return (
<div className="flex h-full items-center justify-center overflow-y-auto p-6">
<TranslationComplete
jobId={submit.jobId}
fileName={submit.fileName}
onNewTranslation={handleNewTranslation}
/>
</div>
);
}
/* ── STATE: Failed ──────────────────────────────────────────────── */
if (isFailed) {
return (
<div className="flex h-full flex-col items-center justify-center gap-6 overflow-y-auto p-6">
<div className="w-full max-w-md">
<TranslationProgress
progress={submit.progress}
currentStep={submit.currentStep}
estimatedRemaining={submit.estimatedRemaining}
error={submit.error}
isPolling={false}
isCompleted={false}
/>
</div>
<Button variant="outline" size="lg" onClick={handleNewTranslation} className="gap-2">
<RotateCcw className="size-4" />
{t('dashboard.translate.actions.tryAgain')}
</Button>
</div>
);
}
return null;
</div>
);
}
/* ═══ Sub-components ═══════════════════════════════════════════════ */
/** Compact file strip */
function FileStrip({ file, onRemove, onReplace, t }: { file: File; onRemove: () => void; onReplace: () => void; t: (key: string) => string }) {
const ext = file.name.split('.').pop()?.toLowerCase() ?? '';
const FileIcon = FILE_ICONS[ext] ?? FileText;
const color = FILE_COLORS[ext] ?? 'text-muted-foreground';
return (
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
<FileIcon className={`size-5 shrink-0 ${color}`} />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-semibold text-foreground">{file.name}</span>
<span className="text-xs text-muted-foreground">{fmt(file.size)} · .{ext.toUpperCase()}</span>
</div>
<button type="button" onClick={onReplace} className="flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground transition hover:bg-secondary hover:text-foreground">
<Upload className="size-3.5" />{t('dashboard.translate.replace')}
</button>
<button type="button" aria-label="Remove" onClick={onRemove} className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-secondary hover:text-foreground">
<X className="size-4" />
</button>
</div>
);
}
/** Pipeline stepper */
function PipelineStepper({ activeIdx, t }: { activeIdx: number; t: (key: string) => string }) {
return (
<div className="flex items-start justify-between">
{PIPELINE_STEP_KEYS.map((stepKey, i) => {
const isActive = i === activeIdx;
const isDone = i < activeIdx;
const Icon = PIPELINE_ICONS[i];
return (
<div key={stepKey} className="flex flex-col items-center gap-1.5 relative" style={{ flex: i < PIPELINE_STEP_KEYS.length - 1 ? 1 : 'none' }}>
<div className="flex items-center w-full">
<div className={cn(
'flex size-9 shrink-0 items-center justify-center rounded-full transition-all duration-500',
isDone
? 'bg-primary text-primary-foreground shadow-md shadow-primary/20'
: isActive
? 'bg-primary text-primary-foreground shadow-lg shadow-primary/25 ring-[3px] ring-primary/20'
: 'bg-muted text-muted-foreground',
)}>
{isDone ? <CheckCircle2 className="size-4" /> : <Icon className={cn('size-4', isActive && 'animate-pulse')} />}
</div>
{i < PIPELINE_STEP_KEYS.length - 1 && (
<div className={cn('mx-1 h-[2px] flex-1 rounded-full transition-colors duration-500', i < activeIdx ? 'bg-primary' : 'bg-border')} />
)}
</div>
<span className={cn('text-[11px] font-medium transition-colors', isDone || isActive ? 'text-primary' : 'text-muted-foreground')}>
{t(stepKey)}
</span>
</div>
);
})}
</div>
);
}
/** Small stat box */
function StatBox({ icon, value, label }: { icon: React.ReactNode; value: string; label: string }) {
return (
<div className="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

@@ -41,6 +41,7 @@ export interface TranslationConfig {
targetLang: string;
mode: TranslationMode;
provider?: Provider;
pdfMode?: 'layout' | 'text_only';
}
export interface UseTranslationConfigReturn {

View File

@@ -1,11 +1,11 @@
import { useState, useCallback } from 'react';
import type { UseFileUploadReturn } from './types';
const ACCEPTED_EXTENSIONS = ['xlsx', 'docx', 'pptx'];
const ACCEPTED_EXTENSIONS = ['xlsx', 'docx', 'pptx', 'pdf'];
const MAX_FILE_SIZE = 50 * 1024 * 1024;
export const ERROR_MESSAGES = {
INVALID_FORMAT: 'Format non supporté. Formats acceptés : .xlsx, .docx, .pptx',
INVALID_FORMAT: 'Format non supporté. Formats acceptés : .xlsx, .docx, .pptx, .pdf',
FILE_TOO_LARGE: 'Fichier trop volumineux (max 50 MB)',
} as const;

View File

@@ -10,6 +10,7 @@ import type {
AvailableProvider,
} from './types';
import { API_BASE } from '@/lib/config';
import { useTranslationStore } from '@/lib/store';
/** Fallback when API fails — Google is always available server-side */
const FALLBACK_PROVIDERS: AvailableProvider[] = [
@@ -59,8 +60,9 @@ const FALLBACK_LANGUAGES: Language[] = [
];
export function useTranslationConfig(hasFile: boolean): UseTranslationConfigReturn {
const { settings } = useTranslationStore();
const [sourceLang, setSourceLang] = useState('auto');
const [targetLang, setTargetLang] = useState('');
const [targetLang, setTargetLang] = useState(settings.defaultTargetLanguage || '');
const [provider, setProvider] = useState<Provider | null>(null);
const [availableProviders, setAvailableProviders] = useState<AvailableProvider[]>([]);
const [isLoadingProviders, setIsLoadingProviders] = useState(false);
@@ -69,6 +71,13 @@ export function useTranslationConfig(hasFile: boolean): UseTranslationConfigRetu
const [isLoadingLanguages, setIsLoadingLanguages] = useState(false);
const [languagesError, setLanguagesError] = useState<string | null>(null);
// Sync with store default target language
useEffect(() => {
if (settings.defaultTargetLanguage && !targetLang) {
setTargetLang(settings.defaultTargetLanguage);
}
}, [settings.defaultTargetLanguage]); // eslint-disable-line react-hooks/exhaustive-deps
// Fetch available (admin-configured) providers
useEffect(() => {
const controller = new AbortController();
@@ -105,6 +114,16 @@ export function useTranslationConfig(hasFile: boolean): UseTranslationConfigRetu
return () => { controller.abort(); clearTimeout(timeoutId); };
}, []);
// Auto-select first classic provider for non-Pro users
useEffect(() => {
if (isLoadingProviders) return;
if (provider !== null) return;
if (isPro) return;
if (availableProviders.length === 0) return;
const firstClassic = availableProviders.find((p) => p.mode === 'classic');
if (firstClassic) setProvider(firstClassic.id);
}, [availableProviders, isLoadingProviders, isPro, provider]);
// Fetch supported languages
useEffect(() => {
const controller = new AbortController();
@@ -148,11 +167,12 @@ export function useTranslationConfig(hasFile: boolean): UseTranslationConfigRetu
// Check user tier
useEffect(() => {
const checkTier = async () => {
const isProTier = (u: any) => ['pro', 'business', 'enterprise'].includes(u?.plan ?? u?.tier ?? '');
const userStr = localStorage.getItem('user');
if (userStr) {
try {
const user = JSON.parse(userStr);
if (user.tier) { setIsPro(user.tier === 'pro'); return; }
if (user?.plan || user?.tier) { setIsPro(isProTier(user)); return; }
} catch { /* continue */ }
}
try {
@@ -164,7 +184,7 @@ export function useTranslationConfig(hasFile: boolean): UseTranslationConfigRetu
if (response.ok) {
const result = await response.json();
const user = result.data;
setIsPro(user.tier === 'pro');
setIsPro(isProTier(user));
localStorage.setItem('user', JSON.stringify(user));
} else {
setIsPro(false);

View File

@@ -62,6 +62,10 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
setError('Translation job not found');
return;
}
// 429 (rate-limited) is not a real failure — just skip this poll
if (response.status === 429) {
return;
}
throw new Error(`HTTP error! status: ${response.status}`);
}
@@ -135,6 +139,10 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
if (config.mode === 'llm' && config.provider) {
formData.append('provider', config.provider);
}
// PDF mode: layout (preserve layout) or text_only (clean text output)
if (config.pdfMode) {
formData.append('pdf_mode', config.pdfMode);
}
const token = localStorage.getItem('token');
const headers: Record<string, string> = {};