diff --git a/frontend/src/app/dashboard/translate/page.tsx b/frontend/src/app/dashboard/translate/page.tsx
index f0308de..b049131 100644
--- a/frontend/src/app/dashboard/translate/page.tsx
+++ b/frontend/src/app/dashboard/translate/page.tsx
@@ -81,12 +81,36 @@ export default function TranslatePage() {
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(() => {
if (submit.error && submit.error !== lastErrorRef.current) {
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
useEffect(() => {
@@ -106,6 +130,13 @@ export default function TranslatePage() {
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 handleDownload = async () => {
if (!submit.jobId) return;
@@ -356,21 +387,46 @@ export default function TranslatePage() {
{/* ── FAILED STATE ───────────────────────────────────── */}
{showFailed && (
-
-
-
-
-
{t('dashboard.translate.progress.failedTitle')}
-
{submit.error}
+ {/* Error message — friendly language */}
+
+
+
+
+
Traduction échouée
+
{humanFriendlyError(submit.error)}
+
+ {/* File strip */}
{(submit.fileName || upload.file?.name) && upload.file && (
-
+
replaceInputRef.current?.click()} t={t} />
)}
+
+ {/* Action buttons */}
+
+ {upload.file && config.isConfigValid && (
+
+
+ Réessayer la traduction
+
+ )}
+
+
+ Nouveau fichier
+
+
)}
@@ -383,95 +439,111 @@ export default function TranslatePage() {
{/* ── CONFIG (upload / configuring / failed) ──────────── */}
{(showUpload || showConfiguring || showFailed) && (
-
-
- {t('landing.translate.configuration')}
-
+
+
+
+ {t('landing.translate.configuration')}
+
-
-
+
+
-
+
-
+ {/* Glossary — Pro + LLM mode only */}
+ {config.isPro && config.mode === 'llm' && (
+
+ )}
- {/* PDF mode selector */}
- {isPdf && (
-
-
- {t('dashboard.translate.pdfMode.title')}
-
-
-
setPdfMode('layout')}
- className={cn(
- 'flex flex-col items-start rounded-2xl border-2 p-3 text-start transition-all',
- pdfMode === 'layout'
- ? 'border-brand-accent bg-brand-accent/5'
- : 'border-black/5 bg-brand-muted/30 hover:border-brand-accent/20 dark:border-white/10 dark:bg-white/5'
- )}
- >
-
-
- {t('dashboard.translate.pdfMode.preserveLayout')}
-
-
- {t('dashboard.translate.pdfMode.preserveLayoutDesc')}
-
-
-
setPdfMode('text_only')}
- className={cn(
- 'flex flex-col items-start rounded-2xl border-2 p-3 text-start transition-all',
- pdfMode === 'text_only'
- ? 'border-brand-accent bg-brand-accent/5'
- : 'border-black/5 bg-brand-muted/30 hover:border-brand-accent/20 dark:border-white/10 dark:bg-white/5'
- )}
- >
-
-
- {t('dashboard.translate.pdfMode.textOnly')}
-
-
- {t('dashboard.translate.pdfMode.textOnlyDesc')}
-
-
+ {/* PDF mode selector */}
+ {isPdf && (
+
+
+ {t('dashboard.translate.pdfMode.title')}
+
+
+
setPdfMode('layout')}
+ className={cn(
+ 'flex flex-col items-start rounded-2xl border-2 p-3 text-start transition-all',
+ pdfMode === 'layout'
+ ? 'border-brand-accent bg-brand-accent/5'
+ : 'border-black/5 bg-brand-muted/30 hover:border-brand-accent/20 dark:border-white/10 dark:bg-white/5'
+ )}
+ >
+
+
+ {t('dashboard.translate.pdfMode.preserveLayout')}
+
+
+ {t('dashboard.translate.pdfMode.preserveLayoutDesc')}
+
+
+
setPdfMode('text_only')}
+ className={cn(
+ 'flex flex-col items-start rounded-2xl border-2 p-3 text-start transition-all',
+ pdfMode === 'text_only'
+ ? 'border-brand-accent bg-brand-accent/5'
+ : 'border-black/5 bg-brand-muted/30 hover:border-brand-accent/20 dark:border-white/10 dark:bg-white/5'
+ )}
+ >
+
+
+ {t('dashboard.translate.pdfMode.textOnly')}
+
+
+ {t('dashboard.translate.pdfMode.textOnlyDesc')}
+
+
+
-
- )}
+ )}
+
+
+ {/* ── TRANSLATE BUTTON — sticky at bottom, always visible ── */}
+
{submit.isSubmitting ? (
- <> {t('dashboard.translate.actions.uploading')}>
+ <> Envoi en cours...>
) : (
- <>{t('landing.translate.startTranslation')} >
+ <> Lancer la traduction>
)}
+ {!upload.file && (
+
↑ Déposez d'abord un fichier
+ )}
+ {upload.file && !config.targetLang && (
+
↑ Sélectionnez une langue cible
+ )}
diff --git a/routes/translate_routes.py b/routes/translate_routes.py
index 3f8907e..07a2ec9 100644
--- a/routes/translate_routes.py
+++ b/routes/translate_routes.py
@@ -1188,14 +1188,23 @@ async def _run_translation_job(
attempted = stats.get("attempted", 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:
ratio = changed / attempted
logger.info(f"Job {job_id}: translation stats — {changed}/{attempted} texts changed ({ratio:.0%})")
if changed == 0:
error_msg = (
- f"Translation failed: 0 out of {attempted} texts were translated. "
- f"The provider ({provider}) may be unavailable or misconfigured. "
- f"Check your API keys in admin settings."
+ f"0 textes sur {attempted} ont été traduits. "
+ f"Le moteur ({provider}) est peut-être indisponible ou mal configuré. "
+ f"Vérifiez les clés API dans les paramètres admin."
)
logger.error(f"Job {job_id}: {error_msg}")
tracker.set_error(error_msg)