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;
|
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>
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user