fix(ui): critical translation workflow improvements
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2s
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:
@@ -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 && (
|
||||
<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">
|
||||
<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>
|
||||
{/* Error message — friendly language */}
|
||||
<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">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-2xl bg-red-100 dark:bg-red-900/40 flex items-center justify-center shrink-0">
|
||||
<AlertTriangle className="size-5 text-red-500" />
|
||||
</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>
|
||||
|
||||
{/* File strip */}
|
||||
{(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} />
|
||||
<input ref={replaceInputRef} type="file" accept=".xlsx,.docx,.pptx,.pdf" className="hidden" onChange={upload.handleFileSelect} />
|
||||
</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>
|
||||
@@ -383,95 +439,111 @@ export default function TranslatePage() {
|
||||
{/* ── CONFIG (upload / configuring / failed) ──────────── */}
|
||||
{(showUpload || showConfiguring || showFailed) && (
|
||||
<div className="space-y-8">
|
||||
<div className="editorial-card p-10 bg-white border-none shadow-editorial dark:bg-[#141414]">
|
||||
<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">
|
||||
{t('landing.translate.configuration')}
|
||||
</h4>
|
||||
<div className="editorial-card bg-white border-none shadow-editorial dark:bg-[#141414] overflow-hidden">
|
||||
<div className="p-10 pb-6">
|
||||
<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">
|
||||
{t('landing.translate.configuration')}
|
||||
</h4>
|
||||
|
||||
<div className="space-y-8">
|
||||
<LanguageSelector
|
||||
sourceLang={config.sourceLang} targetLang={config.targetLang}
|
||||
languages={config.languages} isLoading={config.isLoadingLanguages}
|
||||
error={config.languagesError} onSourceChange={config.setSourceLang}
|
||||
onTargetChange={config.setTargetLang}
|
||||
/>
|
||||
<div className="space-y-8">
|
||||
<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}
|
||||
/>
|
||||
<ProviderSelector
|
||||
provider={config.provider} onProviderChange={config.setProvider}
|
||||
availableProviders={config.availableProviders} isLoadingProviders={config.isLoadingProviders}
|
||||
isPro={config.isPro}
|
||||
/>
|
||||
|
||||
<GlossarySelector
|
||||
sourceLang={config.sourceLang}
|
||||
targetLang={config.targetLang}
|
||||
isPro={config.isPro}
|
||||
glossaryId={config.glossaryId}
|
||||
onChange={config.setGlossaryId}
|
||||
disabled={submit.isSubmitting}
|
||||
/>
|
||||
{/* Glossary — Pro + LLM mode only */}
|
||||
{config.isPro && config.mode === 'llm' && (
|
||||
<GlossarySelector
|
||||
sourceLang={config.sourceLang}
|
||||
targetLang={config.targetLang}
|
||||
isPro={config.isPro}
|
||||
glossaryId={config.glossaryId}
|
||||
onChange={config.setGlossaryId}
|
||||
disabled={submit.isSubmitting}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* PDF mode selector */}
|
||||
{isPdf && (
|
||||
<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">
|
||||
{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-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'
|
||||
)}
|
||||
>
|
||||
<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" />
|
||||
{t('dashboard.translate.pdfMode.preserveLayout')}
|
||||
</div>
|
||||
<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')}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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'
|
||||
)}
|
||||
>
|
||||
<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" />
|
||||
{t('dashboard.translate.pdfMode.textOnly')}
|
||||
</div>
|
||||
<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')}
|
||||
</p>
|
||||
</button>
|
||||
{/* PDF mode selector */}
|
||||
{isPdf && (
|
||||
<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">
|
||||
{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-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'
|
||||
)}
|
||||
>
|
||||
<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" />
|
||||
{t('dashboard.translate.pdfMode.preserveLayout')}
|
||||
</div>
|
||||
<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')}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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'
|
||||
)}
|
||||
>
|
||||
<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" />
|
||||
{t('dashboard.translate.pdfMode.textOnly')}
|
||||
</div>
|
||||
<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')}
|
||||
</p>
|
||||
</button>
|
||||
</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
|
||||
disabled={!config.isConfigValid || submit.isSubmitting || !upload.file}
|
||||
onClick={handleTranslate}
|
||||
className={cn(
|
||||
'premium-button w-full py-6 text-[11px] uppercase tracking-[0.3em] flex items-center justify-center gap-3 !rounded-2xl',
|
||||
(!config.isConfigValid || submit.isSubmitting || !upload.file) && 'opacity-40 cursor-not-allowed'
|
||||
'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 && 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 ? (
|
||||
<><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>
|
||||
{!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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user