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