fix(ui): critical translation workflow improvements
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2s

- Extracted Translate button to sticky bottom wrapper (always visible)
- Added humanFriendlyError mapper for readable error messages
- Added 'Réessayer' and 'Nouveau fichier' buttons to FAILED state
- Hid GlossarySelector for non-Pro users
- Fixed silent failure: attempted=0 now fails explicitly
This commit is contained in:
2026-05-17 16:54:59 +02:00
parent 3e41bee470
commit 669cf7fde8
2 changed files with 166 additions and 85 deletions

View File

@@ -81,12 +81,36 @@ export default function TranslatePage() {
const isPdf = upload.file?.name.toLowerCase().endsWith('.pdf') ?? false; const isPdf = upload.file?.name.toLowerCase().endsWith('.pdf') ?? false;
/* ── Human-friendly error messages ──────────────────────────── */
const humanFriendlyError = (raw: string | null): string => {
if (!raw) return 'Une erreur inattendue est survenue. Réessayez.';
const r = raw.toLowerCase();
if (r.includes('0 out of') || r.includes('0 textes') || r.includes('0 texts'))
return "La traduction n'a produit aucun résultat. Vérifiez que le document contient du texte, puis réessayez ou choisissez un autre moteur.";
if (r.includes('api key') || r.includes('api_key') || r.includes('unauthorized') || r.includes('401'))
return "Clé API invalide ou manquante. Contactez l'administrateur pour configurer les clés.";
if (r.includes('quota') || r.includes('rate limit') || r.includes('429'))
return "Limite d'utilisation atteinte. Réessayez dans quelques minutes ou choisissez un autre moteur.";
if (r.includes('timeout') || r.includes('timed out') || r.includes('connexion'))
return "La connexion au service de traduction a expiré. Vérifiez votre réseau et réessayez.";
if (r.includes('not found') || r.includes('404'))
return "La session a expiré. Cliquez sur Réessayer pour relancer la traduction.";
if (r.includes('empty') || r.includes('vide') || r.includes('no translatable'))
return "Le document semble vide ou ne contient pas de texte traduisible (PDF image ?).";
if (r.includes('unsupported') || r.includes('format'))
return "Format de fichier non supporté ou fichier corrompu.";
if (r.includes('lost connection') || r.includes('internet'))
return "Connexion perdue. Vérifiez votre réseau et réessayez.";
// Generic fallback — show raw but readable
return `Traduction échouée : ${raw.charAt(0).toUpperCase() + raw.slice(1)}`;
};
useEffect(() => { useEffect(() => {
if (submit.error && submit.error !== lastErrorRef.current) { if (submit.error && submit.error !== lastErrorRef.current) {
lastErrorRef.current = submit.error; lastErrorRef.current = submit.error;
showError({ title: t('dashboard.translate.errorNotificationTitle'), description: submit.error }); showError({ title: 'Traduction échouée', description: humanFriendlyError(submit.error) });
} }
}, [submit.error, showError, t]); }, [submit.error, showError]);
// Elapsed timer // Elapsed timer
useEffect(() => { useEffect(() => {
@@ -106,6 +130,13 @@ export default function TranslatePage() {
await submit.submitTranslation(upload.file, cfg); await submit.submitTranslation(upload.file, cfg);
}; };
const handleRetry = async () => {
submit.reset();
// Small delay to ensure reset completes
await new Promise(r => setTimeout(r, 50));
await handleTranslate();
};
const handleNewTranslation = () => { submit.reset(); upload.removeFile(); setElapsed(0); }; const handleNewTranslation = () => { submit.reset(); upload.removeFile(); setElapsed(0); };
const handleDownload = async () => { const handleDownload = async () => {
if (!submit.jobId) return; if (!submit.jobId) return;
@@ -356,21 +387,46 @@ export default function TranslatePage() {
{/* ── FAILED STATE ───────────────────────────────────── */} {/* ── FAILED STATE ───────────────────────────────────── */}
{showFailed && ( {showFailed && (
<div className="editorial-card p-10 bg-white border-none shadow-editorial dark:bg-[#141414]"> <div className="editorial-card p-10 bg-white border-none shadow-editorial dark:bg-[#141414]">
<div className="rounded-[24px] bg-destructive/10 border border-destructive/20 p-6" role="alert"> {/* Error message — friendly language */}
<div className="flex items-start gap-3"> <div className="rounded-[24px] bg-red-50 border-2 border-red-200 dark:bg-red-950/30 dark:border-red-800/40 p-6 mb-6" role="alert">
<AlertTriangle className="size-5 text-destructive shrink-0 mt-0.5" /> <div className="flex items-start gap-4">
<div> <div className="w-10 h-10 rounded-2xl bg-red-100 dark:bg-red-900/40 flex items-center justify-center shrink-0">
<p className="text-sm font-semibold text-destructive mb-1">{t('dashboard.translate.progress.failedTitle')}</p> <AlertTriangle className="size-5 text-red-500" />
<p className="text-sm text-destructive/80">{submit.error}</p> </div>
<div className="flex-1 min-w-0">
<p className="text-sm font-black uppercase tracking-tight text-red-600 dark:text-red-400 mb-2">Traduction échouée</p>
<p className="text-sm text-red-600/80 dark:text-red-300/80 leading-relaxed">{humanFriendlyError(submit.error)}</p>
</div> </div>
</div> </div>
</div> </div>
{/* File strip */}
{(submit.fileName || upload.file?.name) && upload.file && ( {(submit.fileName || upload.file?.name) && upload.file && (
<div className="mt-6"> <div className="mb-6">
<FileStrip file={upload.file} onRemove={upload.removeFile} onReplace={() => replaceInputRef.current?.click()} t={t} /> <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} /> <input ref={replaceInputRef} type="file" accept=".xlsx,.docx,.pptx,.pdf" className="hidden" onChange={upload.handleFileSelect} />
</div> </div>
)} )}
{/* Action buttons */}
<div className="flex flex-col gap-3">
{upload.file && config.isConfigValid && (
<button
onClick={handleRetry}
className="premium-button w-full py-5 text-[12px] uppercase tracking-[0.25em] flex items-center justify-center gap-3 !rounded-2xl"
>
<RotateCcw size={18} />
Réessayer la traduction
</button>
)}
<button
onClick={handleNewTranslation}
className="w-full py-4 border-2 border-black/10 dark:border-white/10 rounded-2xl text-[11px] font-black uppercase tracking-[0.25em] text-brand-dark/50 dark:text-white/50 hover:text-brand-dark dark:hover:text-white hover:border-brand-dark/20 dark:hover:border-white/20 transition-all flex items-center justify-center gap-3"
>
<Upload size={16} />
Nouveau fichier
</button>
</div>
</div> </div>
)} )}
</div> </div>
@@ -383,95 +439,111 @@ export default function TranslatePage() {
{/* ── CONFIG (upload / configuring / failed) ──────────── */} {/* ── CONFIG (upload / configuring / failed) ──────────── */}
{(showUpload || showConfiguring || showFailed) && ( {(showUpload || showConfiguring || showFailed) && (
<div className="space-y-8"> <div className="space-y-8">
<div className="editorial-card p-10 bg-white border-none shadow-editorial dark:bg-[#141414]"> <div className="editorial-card bg-white border-none shadow-editorial dark:bg-[#141414] overflow-hidden">
<h4 className="text-[10px] font-black uppercase tracking-[0.3em] mb-10 text-brand-dark/30 border-b border-black/5 pb-6 dark:text-white/30 dark:border-white/5"> <div className="p-10 pb-6">
{t('landing.translate.configuration')} <h4 className="text-[10px] font-black uppercase tracking-[0.3em] mb-10 text-brand-dark/30 border-b border-black/5 pb-6 dark:text-white/30 dark:border-white/5">
</h4> {t('landing.translate.configuration')}
</h4>
<div className="space-y-8"> <div className="space-y-8">
<LanguageSelector <LanguageSelector
sourceLang={config.sourceLang} targetLang={config.targetLang} sourceLang={config.sourceLang} targetLang={config.targetLang}
languages={config.languages} isLoading={config.isLoadingLanguages} languages={config.languages} isLoading={config.isLoadingLanguages}
error={config.languagesError} onSourceChange={config.setSourceLang} error={config.languagesError} onSourceChange={config.setSourceLang}
onTargetChange={config.setTargetLang} onTargetChange={config.setTargetLang}
/> />
<ProviderSelector <ProviderSelector
provider={config.provider} onProviderChange={config.setProvider} provider={config.provider} onProviderChange={config.setProvider}
availableProviders={config.availableProviders} isLoadingProviders={config.isLoadingProviders} availableProviders={config.availableProviders} isLoadingProviders={config.isLoadingProviders}
isPro={config.isPro} isPro={config.isPro}
/> />
<GlossarySelector {/* Glossary — Pro + LLM mode only */}
sourceLang={config.sourceLang} {config.isPro && config.mode === 'llm' && (
targetLang={config.targetLang} <GlossarySelector
isPro={config.isPro} sourceLang={config.sourceLang}
glossaryId={config.glossaryId} targetLang={config.targetLang}
onChange={config.setGlossaryId} isPro={config.isPro}
disabled={submit.isSubmitting} glossaryId={config.glossaryId}
/> onChange={config.setGlossaryId}
disabled={submit.isSubmitting}
/>
)}
{/* PDF mode selector */} {/* PDF mode selector */}
{isPdf && ( {isPdf && (
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[9px] font-black text-brand-dark/40 uppercase tracking-[0.2em] block mb-3 dark:text-white/40"> <label className="text-[9px] font-black text-brand-dark/40 uppercase tracking-[0.2em] block mb-3 dark:text-white/40">
{t('dashboard.translate.pdfMode.title')} {t('dashboard.translate.pdfMode.title')}
</label> </label>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<button <button
type="button" type="button"
onClick={() => setPdfMode('layout')} onClick={() => setPdfMode('layout')}
className={cn( className={cn(
'flex flex-col items-start rounded-2xl border-2 p-3 text-start transition-all', 'flex flex-col items-start rounded-2xl border-2 p-3 text-start transition-all',
pdfMode === 'layout' pdfMode === 'layout'
? 'border-brand-accent bg-brand-accent/5' ? 'border-brand-accent bg-brand-accent/5'
: 'border-black/5 bg-brand-muted/30 hover:border-brand-accent/20 dark:border-white/10 dark:bg-white/5' : 'border-black/5 bg-brand-muted/30 hover:border-brand-accent/20 dark:border-white/10 dark:bg-white/5'
)} )}
> >
<div className="flex items-center gap-2 text-[11px] font-black uppercase tracking-tight text-brand-dark dark:text-white"> <div className="flex items-center gap-2 text-[11px] font-black uppercase tracking-tight text-brand-dark dark:text-white">
<FileText className="size-4 text-brand-accent" /> <FileText className="size-4 text-brand-accent" />
{t('dashboard.translate.pdfMode.preserveLayout')} {t('dashboard.translate.pdfMode.preserveLayout')}
</div> </div>
<p className="mt-1 text-[9px] text-brand-dark/50 font-bold uppercase tracking-widest leading-relaxed dark:text-white/40"> <p className="mt-1 text-[9px] text-brand-dark/50 font-bold uppercase tracking-widest leading-relaxed dark:text-white/40">
{t('dashboard.translate.pdfMode.preserveLayoutDesc')} {t('dashboard.translate.pdfMode.preserveLayoutDesc')}
</p> </p>
</button> </button>
<button <button
type="button" type="button"
onClick={() => setPdfMode('text_only')} onClick={() => setPdfMode('text_only')}
className={cn( className={cn(
'flex flex-col items-start rounded-2xl border-2 p-3 text-start transition-all', 'flex flex-col items-start rounded-2xl border-2 p-3 text-start transition-all',
pdfMode === 'text_only' pdfMode === 'text_only'
? 'border-brand-accent bg-brand-accent/5' ? 'border-brand-accent bg-brand-accent/5'
: 'border-black/5 bg-brand-muted/30 hover:border-brand-accent/20 dark:border-white/10 dark:bg-white/5' : 'border-black/5 bg-brand-muted/30 hover:border-brand-accent/20 dark:border-white/10 dark:bg-white/5'
)} )}
> >
<div className="flex items-center gap-2 text-[11px] font-black uppercase tracking-tight text-brand-dark dark:text-white"> <div className="flex items-center gap-2 text-[11px] font-black uppercase tracking-tight text-brand-dark dark:text-white">
<Languages className="size-4 text-brand-accent" /> <Languages className="size-4 text-brand-accent" />
{t('dashboard.translate.pdfMode.textOnly')} {t('dashboard.translate.pdfMode.textOnly')}
</div> </div>
<p className="mt-1 text-[9px] text-brand-dark/50 font-bold uppercase tracking-widest leading-relaxed dark:text-white/40"> <p className="mt-1 text-[9px] text-brand-dark/50 font-bold uppercase tracking-widest leading-relaxed dark:text-white/40">
{t('dashboard.translate.pdfMode.textOnlyDesc')} {t('dashboard.translate.pdfMode.textOnlyDesc')}
</p> </p>
</button> </button>
</div>
</div> </div>
</div> )}
)} </div>
</div>
{/* ── TRANSLATE BUTTON — sticky at bottom, always visible ── */}
<div className="sticky bottom-0 p-6 pt-4 bg-white dark:bg-[#141414] border-t border-black/5 dark:border-white/5">
<button <button
disabled={!config.isConfigValid || submit.isSubmitting || !upload.file} disabled={!config.isConfigValid || submit.isSubmitting || !upload.file}
onClick={handleTranslate} onClick={handleTranslate}
className={cn( className={cn(
'premium-button w-full py-6 text-[11px] uppercase tracking-[0.3em] flex items-center justify-center gap-3 !rounded-2xl', 'w-full py-5 rounded-2xl text-[13px] font-black uppercase tracking-[0.2em] flex items-center justify-center gap-3 transition-all duration-200',
(!config.isConfigValid || submit.isSubmitting || !upload.file) && 'opacity-40 cursor-not-allowed' config.isConfigValid && upload.file && !submit.isSubmitting
? 'bg-brand-dark text-white hover:bg-brand-dark/90 shadow-lg shadow-brand-dark/20 dark:bg-brand-accent dark:text-brand-dark dark:hover:bg-brand-accent/90'
: 'bg-black/5 dark:bg-white/5 text-brand-dark/25 dark:text-white/25 cursor-not-allowed'
)} )}
> >
{submit.isSubmitting ? ( {submit.isSubmitting ? (
<><Loader2 className="size-5 animate-spin" />{t('dashboard.translate.actions.uploading')}</> <><Loader2 className="size-5 animate-spin" />Envoi en cours...</>
) : ( ) : (
<>{t('landing.translate.startTranslation')}<ArrowRight size={18} className="text-brand-accent" /></> <><ArrowRight size={20} />Lancer la traduction</>
)} )}
</button> </button>
{!upload.file && (
<p className="text-center text-[10px] text-brand-dark/30 dark:text-white/30 mt-2 font-bold uppercase tracking-widest"> Déposez d'abord un fichier</p>
)}
{upload.file && !config.targetLang && (
<p className="text-center text-[10px] text-brand-dark/30 dark:text-white/30 mt-2 font-bold uppercase tracking-widest">↑ Sélectionnez une langue cible</p>
)}
</div> </div>
</div> </div>

View File

@@ -1188,14 +1188,23 @@ async def _run_translation_job(
attempted = stats.get("attempted", 0) attempted = stats.get("attempted", 0)
changed = stats.get("changed", 0) changed = stats.get("changed", 0)
if attempted == 0 and file_extension in ('.docx', '.xlsx', '.pptx'):
error_msg = (
"Aucun texte traduisible détecté dans le document. "
"Le fichier est peut-être vide, protégé, ou ne contient que des images."
)
logger.error(f"Job {job_id}: {error_msg}")
tracker.set_error(error_msg)
return
if attempted > 0: if attempted > 0:
ratio = changed / attempted ratio = changed / attempted
logger.info(f"Job {job_id}: translation stats — {changed}/{attempted} texts changed ({ratio:.0%})") logger.info(f"Job {job_id}: translation stats — {changed}/{attempted} texts changed ({ratio:.0%})")
if changed == 0: if changed == 0:
error_msg = ( error_msg = (
f"Translation failed: 0 out of {attempted} texts were translated. " f"0 textes sur {attempted} ont été traduits. "
f"The provider ({provider}) may be unavailable or misconfigured. " f"Le moteur ({provider}) est peut-être indisponible ou mal configuré. "
f"Check your API keys in admin settings." f"Vérifiez les clés API dans les paramètres admin."
) )
logger.error(f"Job {job_id}: {error_msg}") logger.error(f"Job {job_id}: {error_msg}")
tracker.set_error(error_msg) tracker.set_error(error_msg)