fix(relecture): un travail vide ou oublie restait affiche et impossible a retirer
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m29s

Un travail echoue ou expire cote serveur restait dans « Traductions
recentes » : sa page de relecture n'affichait rien (0 segments) avec une
erreur brute non traduite, et aucune suppression n'existait.

- serveur : nouvelle route DELETE /api/v1/translations/{job_id} —
  retire le travail de la memoire et de Redis, supprime ses segments de
  relecture ; idempotente (200 meme si le serveur avait deja oublie le
  travail) ; refuse les travaux en cours (annuler d'abord) ; proprietaire
  seul (404 pour le travail d'autrui, sans reveler son existence)
- interface : bouton corbeille sur chaque ligne des traductions recentes
  (deux clics pour confirmer) qui nettoie la liste locale ET le serveur ;
  l'historique serveur ne liste plus les travaux non termines
- page de relecture : message d'erreur traduit (fini le message brut) et
  etat « rien a relire » explicite avec explication
- libelles ajoutes dans les 13 langues de l'interface
- 5 tests backend nouveaux (tests/test_delete_translation_history.py)
This commit is contained in:
2026-09-01 21:22:05 +02:00
parent b3b5454f6f
commit 2abd0c0b26
21 changed files with 380 additions and 27 deletions

View File

@@ -106,7 +106,13 @@ export default function ReviewPage() {
setSegments(res.data.segments);
setFileName(res.data.file_name);
} catch (err) {
setLoadError(err instanceof Error ? err.message : t('reviews.error.load'));
setLoadError(
friendlyReviewError(
err instanceof Error ? err.message : undefined,
t,
'reviews.error.load'
) ?? t('reviews.error.load')
);
} finally {
setIsLoading(false);
}
@@ -406,6 +412,15 @@ export default function ReviewPage() {
<div className="flex items-center justify-center py-16">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : segments.length === 0 && !loadError ? (
<div className="rounded-xl border border-black/10 dark:border-white/10 bg-white dark:bg-[#141414] px-6 py-14 text-center shadow-sm">
<p className="text-sm font-semibold text-brand-dark dark:text-white">
{t('reviews.empty.title')}
</p>
<p className="mx-auto mt-2 max-w-md text-xs leading-relaxed text-brand-dark/60 dark:text-white/60">
{t('reviews.empty.desc')}
</p>
</div>
) : (
<div className="overflow-hidden rounded-xl border border-black/10 dark:border-white/10 bg-white dark:bg-[#141414] shadow-sm">
<table className="w-full text-sm">

View File

@@ -8,11 +8,14 @@ import {
Zap, CheckCircle2,
Search, Languages, Wrench, Activity,
Download, AlertTriangle, FileType,
Image as ImageIcon,
Image as ImageIcon, Trash2,
} from 'lucide-react';
import { useFileUpload } from './useFileUpload';
import { useTranslationConfig } from './useTranslationConfig';
import { useTranslationSubmit, getRecentJobs, fetchServerHistory, pushRecentJob, type RecentJob } from './useTranslationSubmit';
import {
useTranslationSubmit, getRecentJobs, fetchServerHistory, pushRecentJob,
removeRecentJob, deleteServerJob, type RecentJob,
} from './useTranslationSubmit';
import { runTranslationJob } from './translationRunner';
import type { BatchItem } from './types';
import LanguageSelector from './LanguageSelector';
@@ -83,6 +86,8 @@ export default function TranslatePage() {
const batchJobRef = useRef<string | null>(null);
const [elapsed, setElapsed] = useState(0);
const [recentJobs, setRecentJobs] = useState<RecentJob[]>([]);
// Job whose delete button is armed (first click) — cleared after 4s or on delete.
const [armedRemoveId, setArmedRemoveId] = useState<string | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isPdf = upload.file?.name.toLowerCase().endsWith('.pdf') ?? false;
@@ -238,6 +243,20 @@ export default function TranslatePage() {
};
const handleNewTranslation = () => { batchAbortRef.current = true; setBatch(null); submit.reset(); upload.removeFile(); setElapsed(0); setRecentJobs(getRecentJobs()); };
/** Two-step delete (arm, then confirm): removes the entry locally and on the server. */
const handleRemoveJob = (jobId: string) => {
if (armedRemoveId !== jobId) {
setArmedRemoveId(jobId);
setTimeout(() => setArmedRemoveId((cur) => (cur === jobId ? null : cur)), 4000);
return;
}
setArmedRemoveId(null);
removeRecentJob(jobId);
setRecentJobs((jobs) => jobs.filter((j) => j.jobId !== jobId));
// Server-side cleanup is best-effort: a 404 just means it already forgot the job.
void deleteServerJob(jobId);
};
const handleDownload = async (jobId: string = submit.jobId ?? '') => {
if (!jobId) return;
const token = localStorage.getItem('token');
@@ -543,7 +562,7 @@ export default function TranslatePage() {
onClick={() => handleDownload(job.jobId)}
className="rounded-lg px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-brand-dark/60 dark:text-white/60 border border-black/10 dark:border-white/10 hover:bg-brand-muted/50 transition-colors"
>
<Download size={11} className="inline" />
<Download size={11} />
</button>
<Link
href={`/dashboard/reviews/${job.jobId}`}
@@ -551,6 +570,20 @@ export default function TranslatePage() {
>
{t('translate.recent.review')}
</Link>
<button
type="button"
onClick={() => handleRemoveJob(job.jobId)}
aria-label={t('translate.recent.remove')}
title={armedRemoveId === job.jobId ? t('translate.recent.removeConfirm') : t('translate.recent.remove')}
className={cn(
'rounded-lg px-2.5 py-1.5 text-[10px] font-bold uppercase tracking-wider border transition-colors',
armedRemoveId === job.jobId
? 'border-red-400/60 text-red-500 bg-red-500/10'
: 'text-brand-dark/40 dark:text-white/40 border-transparent hover:text-red-500 hover:border-red-300/40'
)}
>
<Trash2 size={11} />
</button>
</span>
</li>
))}

View File

@@ -22,7 +22,7 @@ interface StoredJob { jobId: string; fileName: string | null; savedAt: number }
export interface RecentJob { jobId: string; fileName: string; completedAt: number }
/** Server history (last jobs, newest first) — empty when offline/unauthenticated. */
/** Server history (last completed jobs, newest first) — empty when offline/unauthenticated. */
export async function fetchServerHistory(perPage = 6): Promise<RecentJob[]> {
if (typeof window === 'undefined') return [];
const token = localStorage.getItem('token');
@@ -33,14 +33,16 @@ export async function fetchServerHistory(perPage = 6): Promise<RecentJob[]> {
});
if (!res.ok) return [];
const json = await res.json();
return (json.data ?? []).map((j: {
id: string; file_name?: string | null;
completed_at?: string | null; created_at?: string | null;
}) => ({
jobId: j.id,
fileName: j.file_name ?? '',
completedAt: Date.parse(j.completed_at ?? j.created_at ?? '') || Date.now(),
}));
return (json.data ?? [])
.filter((j: { status?: string | null }) => (j.status ?? 'completed') === 'completed')
.map((j: {
id: string; file_name?: string | null; status?: string | null;
completed_at?: string | null; created_at?: string | null;
}) => ({
jobId: j.id,
fileName: j.file_name ?? '',
completedAt: Date.parse(j.completed_at ?? j.created_at ?? '') || Date.now(),
}));
} catch {
return [];
}
@@ -56,6 +58,31 @@ export function getRecentJobs(): RecentJob[] {
}
}
/** Remove a job from this browser's local history. */
export function removeRecentJob(jobId: string) {
try {
const list = getRecentJobs().filter(j => j.jobId !== jobId);
localStorage.setItem(RECENT_JOBS_KEY, JSON.stringify(list));
} catch { /* ignore */ }
}
/**
* Delete a job on the server (history entry + review segments).
* Silent by design: a 404 means the server already forgot the job, which
* is exactly the state the caller wants.
*/
export async function deleteServerJob(jobId: string): Promise<void> {
if (typeof window === 'undefined') return;
const token = localStorage.getItem('token');
if (!token) return;
try {
await fetch(`${API_BASE}/api/v1/translations/${jobId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
} catch { /* offline — the local entry is removed regardless */ }
}
function persistActiveJob(job: StoredJob | null) {
try {
if (!job) localStorage.removeItem(ACTIVE_JOB_KEY);

View File

@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter."
"reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "لا توجد أجزاء لمراجعتها في هذه المهمة",
"reviews.empty.desc": "لا تحتوي هذه الترجمة على أجزاء محفوظة: إما أنها فشلت قبل ترجمة النص، أو لم يعد الخادم يحتفظ بها. يمكنك إزالتها من قائمة الترجمات الأخيرة."
}

View File

@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter."
"reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "Für diesen Auftrag gibt es nichts zu prüfen",
"reviews.empty.desc": "Diese Übersetzung enthält keine gespeicherten Segmente: Entweder ist sie vor der Textübersetzung fehlgeschlagen, oder der Server bewahrt sie nicht mehr auf. Sie können sie aus der Liste der letzten Übersetzungen entfernen."
}

View File

@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter."
"reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "Nothing to review for this job",
"reviews.empty.desc": "This translation has no stored segments: either it failed before any text was translated, or it is no longer retained by the server. You can remove it from your recent translations list."
}

View File

@@ -96,6 +96,8 @@
"translate.downloadFailed": "The download failed. Please try again.",
"translate.recent.title": "Recent translations",
"translate.recent.review": "Review",
"translate.recent.remove": "Remove from history",
"translate.recent.removeConfirm": "Click again to confirm",
"translate.upload.ariaDropzone": "Upload a document: drag and drop, or press Enter to browse files",
"translate.cancelAction": "Cancel translation",
"translate.cancelledTitle": "Translation cancelled",

View File

@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter."
"reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "Nada que revisar en este trabajo",
"reviews.empty.desc": "Esta traducción no tiene segmentos guardados: o falló antes de traducir el texto, o el servidor ya no la conserva. Puede quitarla de la lista de traducciones recientes."
}

View File

@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter."
"reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "بخشی برای بازبینی در این کار وجود ندارد",
"reviews.empty.desc": "این ترجمه بخش ذخیره‌شده‌ای ندارد: یا پیش از ترجمهٔ متن ناموفق بوده، یا سرور دیگر آن را نگه نمی‌دارد. می‌توانید آن را از فهرست ترجمه‌های اخیر حذف کنید."
}

View File

@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Rechercher dans les segments...",
"reviews.shortcutHint": "Ctrl + Entrée pour enregistrer",
"reviews.approvingProgress": "Approbation ({current}/{total})…",
"reviews.noMatchingSegments": "Aucun segment ne correspond à votre filtre."
"reviews.noMatchingSegments": "Aucun segment ne correspond à votre filtre.",
"reviews.empty.title": "Rien à relire pour ce travail",
"reviews.empty.desc": "Cette traduction n'a aucun segment enregistré : soit elle a échoué avant la traduction du texte, soit elle n'est plus conservée par le serveur. Vous pouvez la retirer de la liste des traductions récentes."
}

View File

@@ -96,6 +96,8 @@
"translate.downloadFailed": "Le téléchargement a échoué. Réessayez.",
"translate.recent.title": "Traductions récentes",
"translate.recent.review": "Relire",
"translate.recent.remove": "Retirer de l'historique",
"translate.recent.removeConfirm": "Cliquez encore pour confirmer",
"translate.upload.ariaDropzone": "Déposer un document : glisser-déposer ou appuyer sur Entrée pour parcourir",
"translate.cancelAction": "Annuler la traduction",
"translate.cancelledTitle": "Traduction annulée",

View File

@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter."
"reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "Niente da rivedere per questo lavoro",
"reviews.empty.desc": "Questa traduzione non ha segmenti salvati: o è fallita prima di tradurre il testo, o il server non la conserva più. Puoi rimuoverla dall'elenco delle traduzioni recenti."
}

View File

@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter."
"reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "このジョブに校正するセグメントはありません",
"reviews.empty.desc": "この翻訳には保存されたセグメントがありません。テキストの翻訳前に失敗したか、サーバーに保持されていません。最近の翻訳リストから削除できます。"
}

View File

@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter."
"reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "이 작업에 검토할 세그먼트가 없습니다",
"reviews.empty.desc": "이 번역에는 저장된 세그먼트가 없습니다. 텍스트 번역 전에 실패했거나 서버에 더 이상 보관되지 않습니다. 최근 번역 목록에서 제거할 수 있습니다."
}

View File

@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter."
"reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "Niets te controleren voor deze opdracht",
"reviews.empty.desc": "Deze vertaling heeft geen opgeslagen segmenten: of hij mislukte voordat er tekst werd vertaald, of de server bewaart hem niet meer. U kunt hem verwijderen uit de lijst met recente vertalingen."
}

View File

@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter."
"reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "Nada a revisar neste trabalho",
"reviews.empty.desc": "Esta tradução não tem segmentos armazenados: ou falhou antes de qualquer texto ser traduzido, ou o servidor já não a conserva. Pode removê-la da lista de traduções recentes."
}

View File

@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter."
"reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "Для этого задания нет сегментов для проверки",
"reviews.empty.desc": "У этого перевода нет сохранённых сегментов: либо он завершился ошибкой до перевода текста, либо сервер его больше не хранит. Вы можете удалить его из списка недавних переводов."
}

View File

@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter."
"reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "此任务没有可校对的段落",
"reviews.empty.desc": "此翻译没有已存储的段落:可能是在翻译文本之前失败,或服务器已不再保留。您可以将其从最近翻译列表中移除。"
}