fix(ui): design critique overhaul — a11y, honest metrics, i18n repair (critique 2026-08-30)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m51s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m51s
P0 a11y: keyboard-accessible dropzone (role/tabIndex/Enter-Space), ARIA combobox + listbox pattern for language selector, role=switch on glossary toggle, role=status live region on notifications, aria-labels on password toggles, visible-on-focus close buttons, RTL logical positioning (start-*). Trust: third-party Memento promo removed from translate page, sidebar and all 13 locales; fabricated stats (99.9%, Turbo, computed layout-integrity bars) replaced with real measurements incl. API estimated remaining time; silent download failure now surfaces an error notification. Honesty: fake 100-byte file injections removed (format chips are now informational); cancel-that-doesn't renamed 'Back to start' with hint; Enterprise contact placeholder replaced with contact@wordly.art. i18n: t() no longer returns raw keys (empty string + defaultValue support, ~30 dead || fallbacks now work); ~170 new keys EN+FR across new reviews/ teams namespaces, glossaries context tab, translate monitor, settings, services, pricing, landing, fileUploader; split-key italic titles replace lastIndexOf() surgery (zh/ja-safe); key-audit script added 0 missing. Flow: active job persisted across refresh with polling resume (24h TTL); client-side recent-jobs history with review links; review page linked from complete state; settings/services added to dashboard nav; Business/ Enterprise regain glossary access (tier gate unified). Typeset (sober-tool direction): 7.5-9px labels raised to 10-12px, /30 opacity to /45-/55, uppercase tracking reduced, trust footer legible, country flags removed from language switcher, localized dates. Cleanup: 5 orphaned translate components, dead site header/footer, fossil tailwind.config.js, PipelineStepper, duplicate pill+H1 titles, two-step confirm for cache clear, dead landing footer links. Verified: next build exit 0, vitest 9/9, eslint 64 errors = HEAD (no regression, -3 warnings), detector 4 -> 3 findings.
This commit is contained in:
@@ -16,6 +16,7 @@ import { apiClient, API_BASE_URL } from '@/lib/apiClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Segment {
|
||||
@@ -48,9 +49,9 @@ function authHeaders(): Record<string, string> {
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function downloadProtected(url: string, filename: string) {
|
||||
async function downloadProtected(url: string, filename: string, failMessage: (status: number) => string) {
|
||||
const res = await fetch(`${API_BASE_URL}${url}`, { headers: authHeaders() });
|
||||
if (!res.ok) throw new Error(`Téléchargement échoué (HTTP ${res.status})`);
|
||||
if (!res.ok) throw new Error(failMessage(res.status));
|
||||
const blob = await res.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -66,6 +67,7 @@ export default function ReviewPage() {
|
||||
const params = useParams<{ jobId: string }>();
|
||||
const jobId = params?.jobId ?? '';
|
||||
const notify = useToast();
|
||||
const { t } = useI18n();
|
||||
|
||||
const [segments, setSegments] = useState<Segment[]>([]);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
@@ -88,7 +90,7 @@ export default function ReviewPage() {
|
||||
setSegments(res.data.segments);
|
||||
setFileName(res.data.file_name);
|
||||
} catch (err) {
|
||||
setLoadError(err instanceof Error ? err.message : 'Erreur de chargement');
|
||||
setLoadError(err instanceof Error ? err.message : t('reviews.error.load'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -125,8 +127,8 @@ export default function ReviewPage() {
|
||||
);
|
||||
} catch (err) {
|
||||
notify.error({
|
||||
title: 'Erreur',
|
||||
description: err instanceof Error ? err.message : 'Mise à jour impossible',
|
||||
title: t('reviews.error.title'),
|
||||
description: err instanceof Error ? err.message : t('reviews.error.update'),
|
||||
});
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
@@ -140,8 +142,8 @@ export default function ReviewPage() {
|
||||
await patchSegment(seg.id, { status: 'approved' });
|
||||
}
|
||||
notify.success({
|
||||
title: 'Segments approuvés',
|
||||
description: `${pending.length} segment(s) approuvé(s).`,
|
||||
title: t('reviews.approvedTitle'),
|
||||
description: t('reviews.approvedDesc', { count: pending.length }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -153,15 +155,16 @@ export default function ReviewPage() {
|
||||
);
|
||||
await downloadProtected(
|
||||
res.data.download_url,
|
||||
`relu_${fileName ?? jobId}`
|
||||
`relu_${fileName ?? jobId}`,
|
||||
(status) => t('reviews.error.download', { status })
|
||||
);
|
||||
notify.success({
|
||||
title: 'Document reconstruit',
|
||||
description: `${res.data.segments_applied} segment(s) relu(s) appliqué(s).`,
|
||||
title: t('reviews.rebuiltTitle'),
|
||||
description: t('reviews.rebuiltDesc', { count: res.data.segments_applied }),
|
||||
});
|
||||
} catch (err) {
|
||||
notify.error({
|
||||
title: 'Reconstruction impossible',
|
||||
title: t('reviews.rebuildFailed'),
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
} finally {
|
||||
@@ -173,11 +176,12 @@ export default function ReviewPage() {
|
||||
try {
|
||||
await downloadProtected(
|
||||
`/api/v1/translations/${jobId}/xliff`,
|
||||
`${jobId}.xliff`
|
||||
`${jobId}.xliff`,
|
||||
(status) => t('reviews.error.download', { status })
|
||||
);
|
||||
} catch (err) {
|
||||
notify.error({
|
||||
title: 'Export XLIFF échoué',
|
||||
title: t('reviews.xliffExportFailed'),
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
@@ -203,13 +207,13 @@ export default function ReviewPage() {
|
||||
}
|
||||
const json = await res.json();
|
||||
notify.success({
|
||||
title: 'XLIFF importé',
|
||||
description: `${json.data.segments_updated} segment(s) mis à jour.`,
|
||||
title: t('reviews.xliffImported'),
|
||||
description: t('reviews.xliffImportedDesc', { count: json.data.segments_updated }),
|
||||
});
|
||||
await load();
|
||||
} catch (err) {
|
||||
notify.error({
|
||||
title: 'Import XLIFF échoué',
|
||||
title: t('reviews.xliffImportFailed'),
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
} finally {
|
||||
@@ -220,25 +224,30 @@ export default function ReviewPage() {
|
||||
|
||||
const statusBadge = (status: Segment['status']) => {
|
||||
if (status === 'approved')
|
||||
return <Badge className="bg-green-500/15 text-green-600 border-green-500/30">Approuvé</Badge>;
|
||||
return <Badge className="bg-green-500/15 text-green-600 border-green-500/30">{t('reviews.status.approved')}</Badge>;
|
||||
if (status === 'edited')
|
||||
return <Badge className="bg-blue-500/15 text-blue-600 border-blue-500/30">Modifié</Badge>;
|
||||
return <Badge variant="outline" className="text-muted-foreground">À relire</Badge>;
|
||||
return <Badge className="bg-blue-500/15 text-blue-600 border-blue-500/30">{t('reviews.status.edited')}</Badge>;
|
||||
return <Badge variant="outline" className="text-muted-foreground">{t('reviews.status.pending')}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">Relecture</h1>
|
||||
<h1 className="text-xl font-semibold text-foreground">{t('reviews.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground truncate max-w-xl">
|
||||
{fileName ?? jobId} — {counts.total} segments ({counts.pending} à
|
||||
relire, {counts.approved} approuvés, {counts.edited} modifiés)
|
||||
{t('reviews.subtitle', {
|
||||
file: fileName ?? jobId,
|
||||
total: counts.total,
|
||||
pending: counts.pending,
|
||||
approved: counts.approved,
|
||||
edited: counts.edited,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={approveAll} disabled={counts.pending === 0}>
|
||||
<CheckCheck className="size-3.5" /> Tout approuver
|
||||
<CheckCheck className="size-3.5" /> {t('reviews.approveAll')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={exportXliff} disabled={!segments.length}>
|
||||
<FileDown className="size-3.5" /> XLIFF
|
||||
@@ -250,7 +259,7 @@ export default function ReviewPage() {
|
||||
disabled={isImporting}
|
||||
>
|
||||
{isImporting ? <Loader2 className="size-3.5 animate-spin" /> : <FileUp className="size-3.5" />}
|
||||
Importer XLIFF
|
||||
{t('reviews.importXliff')}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -264,7 +273,7 @@ export default function ReviewPage() {
|
||||
/>
|
||||
<Button size="sm" onClick={rebuild} disabled={isRebuilding || counts.approved + counts.edited === 0}>
|
||||
{isRebuilding ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
|
||||
Reconstruire et télécharger
|
||||
{t('reviews.rebuild')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -284,9 +293,9 @@ export default function ReviewPage() {
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="w-1/2 px-4 py-2.5 font-medium">Source</th>
|
||||
<th className="w-1/2 px-4 py-2.5 font-medium">Traduction</th>
|
||||
<th className="w-40 px-4 py-2.5 font-medium">Statut</th>
|
||||
<th className="w-1/2 px-4 py-2.5 font-medium">{t('reviews.col.source')}</th>
|
||||
<th className="w-1/2 px-4 py-2.5 font-medium">{t('reviews.col.target')}</th>
|
||||
<th className="w-40 px-4 py-2.5 font-medium">{t('reviews.col.status')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -325,14 +334,14 @@ export default function ReviewPage() {
|
||||
) : (
|
||||
<Check className="size-3.5" />
|
||||
)}
|
||||
Enregistrer
|
||||
{t('reviews.save')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setEditingId(null)}
|
||||
>
|
||||
Annuler
|
||||
{t('reviews.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -343,7 +352,7 @@ export default function ReviewPage() {
|
||||
seg.status === 'edited' && 'text-blue-600 dark:text-blue-400'
|
||||
)}
|
||||
>
|
||||
{finalText || <span className="italic text-muted-foreground">— vide —</span>}
|
||||
{finalText || <span className="italic text-muted-foreground">{t('reviews.emptyCell')}</span>}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
@@ -356,7 +365,7 @@ export default function ReviewPage() {
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2"
|
||||
title="Modifier la traduction"
|
||||
title={t('reviews.action.edit')}
|
||||
onClick={() => {
|
||||
setEditingId(seg.id);
|
||||
setDraft(finalText);
|
||||
@@ -368,7 +377,7 @@ export default function ReviewPage() {
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2"
|
||||
title="Approuver tel quel"
|
||||
title={t('reviews.action.approve')}
|
||||
disabled={seg.status === 'approved' || savingId === seg.id}
|
||||
onClick={() => patchSegment(seg.id, { status: 'approved' })}
|
||||
>
|
||||
@@ -378,7 +387,7 @@ export default function ReviewPage() {
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2"
|
||||
title="Remettre à relire"
|
||||
title={t('reviews.action.reset')}
|
||||
disabled={seg.status === 'pending'}
|
||||
onClick={() => patchSegment(seg.id, { status: 'pending' })}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user