'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useParams } from 'next/navigation'; import { Check, CheckCheck, Download, FileDown, FileUp, Loader2, Pencil, Search, Undo2, } from 'lucide-react'; 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 { id: string; segment_index: number; source_text: string; translated_text: string; status: 'pending' | 'approved' | 'edited'; reviewed_text: string | null; } interface SegmentsResponse { data: { job_id: string; file_name: string | null; status: string; segments: Segment[]; counts: { total: number; pending: number; approved: number; edited: number }; }; } interface RebuildResponse { data: { job_id: string; rebuilt: boolean; segments_applied: number; download_url: string }; } /** Map raw backend errors to something the reviewer can act on. */ function friendlyReviewError(raw: string | undefined, t: (k: string) => string, fallbackKey: string): string | undefined { if (!raw) return undefined; const r = raw.toLowerCase(); if (r.includes('401') || r.includes('unauthorized') || r.includes('403')) return t('reviews.error.sessionExpired'); if (r.includes('404') || r.includes('not found')) return t('reviews.error.notFound'); if (r.includes('429') || r.includes('rate')) return t('reviews.error.rateLimited'); if (r.includes('500') || r.includes('server')) return t('reviews.error.server'); return raw || t(fallbackKey); } function authHeaders(): Record { const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null; const headers: Record = {}; if (token) headers['Authorization'] = `Bearer ${token}`; return headers; } 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(failMessage(res.status)); const blob = await res.blob(); const objectUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = objectUrl; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(objectUrl); } export default function ReviewPage() { const params = useParams<{ jobId: string }>(); const jobId = params?.jobId ?? ''; const notify = useToast(); const { t } = useI18n(); const [segments, setSegments] = useState([]); const [fileName, setFileName] = useState(null); const [isLoading, setIsLoading] = useState(true); const [loadError, setLoadError] = useState(null); const [editingId, setEditingId] = useState(null); const [draft, setDraft] = useState(''); const [savingId, setSavingId] = useState(null); const [isRebuilding, setIsRebuilding] = useState(false); const [approveArmed, setApproveArmed] = useState(false); const [approvingProgress, setApprovingProgress] = useState<{ current: number; total: number } | null>(null); const [isImporting, setIsImporting] = useState(false); const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'approved' | 'edited'>('all'); const [searchQuery, setSearchQuery] = useState(''); const fileInputRef = useRef(null); const load = useCallback(async () => { setIsLoading(true); setLoadError(null); try { const res = await apiClient.get( `/api/v1/translations/${jobId}/segments` ); setSegments(res.data.segments); setFileName(res.data.file_name); } catch (err) { setLoadError( friendlyReviewError( err instanceof Error ? err.message : undefined, t, 'reviews.error.load' ) ?? t('reviews.error.load') ); } finally { setIsLoading(false); } }, [jobId, t]); useEffect(() => { if (jobId) load(); }, [jobId, load]); const counts = useMemo(() => { const pending = segments.filter((s) => s.status === 'pending').length; const approved = segments.filter((s) => s.status === 'approved').length; const edited = segments.filter((s) => s.status === 'edited').length; return { total: segments.length, pending, approved, edited }; }, [segments]); const filteredSegments = useMemo(() => { return segments.filter((seg) => { if (statusFilter !== 'all' && seg.status !== statusFilter) return false; if (searchQuery.trim()) { const q = searchQuery.toLowerCase(); const src = (seg.source_text || '').toLowerCase(); const tgt = (seg.reviewed_text || seg.translated_text || '').toLowerCase(); return src.includes(q) || tgt.includes(q); } return true; }); }, [segments, statusFilter, searchQuery]); const patchSegment = async ( id: string, body: { reviewed_text?: string; status?: string } ) => { setSavingId(id); try { await apiClient.patch(`/api/v1/segments/${id}`, body); setSegments((prev) => prev.map((s) => s.id === id ? { ...s, reviewed_text: body.reviewed_text ?? s.reviewed_text, status: (body.status ?? s.status) as Segment['status'], } : s ) ); } catch (err) { notify.error({ title: t('reviews.error.title'), description: friendlyReviewError(err instanceof Error ? err.message : undefined, t, 'reviews.error.update'), }); } finally { setSavingId(null); } }; const approveAll = async () => { // Two-step: the first click arms, the second confirms — no accidental bulk-approve. if (!approveArmed) { setApproveArmed(true); setTimeout(() => setApproveArmed(false), 4000); return; } setApproveArmed(false); const pending = segments.filter((s) => s.status === 'pending'); if (pending.length === 0) return; setApprovingProgress({ current: 0, total: pending.length }); for (let i = 0; i < pending.length; i++) { const seg = pending[i]; await patchSegment(seg.id, { status: 'approved' }); setApprovingProgress({ current: i + 1, total: pending.length }); } setApprovingProgress(null); notify.success({ title: t('reviews.approvedTitle'), description: t('reviews.approvedDesc', { count: pending.length }), }); }; const rebuild = async () => { setIsRebuilding(true); try { const res = await apiClient.post( `/api/v1/translations/${jobId}/rebuild` ); await downloadProtected( res.data.download_url, `relu_${fileName ?? jobId}`, (status) => t('reviews.error.download', { status }) ); notify.success({ title: t('reviews.rebuiltTitle'), description: t('reviews.rebuiltDesc', { count: res.data.segments_applied }), }); } catch (err) { notify.error({ title: t('reviews.rebuildFailed'), description: friendlyReviewError(err instanceof Error ? err.message : undefined, t, 'reviews.error.update'), }); } finally { setIsRebuilding(false); } }; const exportXliff = async () => { try { await downloadProtected( `/api/v1/translations/${jobId}/xliff`, `${jobId}.xliff`, (status) => t('reviews.error.download', { status }) ); } catch (err) { notify.error({ title: t('reviews.xliffExportFailed'), description: friendlyReviewError(err instanceof Error ? err.message : undefined, t, 'reviews.error.update'), }); } }; const importXliff = async (file: File) => { setIsImporting(true); try { const text = await file.text(); const res = await fetch( `${API_BASE_URL}/api/v1/translations/${jobId}/xliff`, { method: 'POST', headers: { ...authHeaders() }, body: text, } ); if (!res.ok) { const json = await res.json().catch(() => ({})); throw new Error( json?.detail?.message || json?.message || `HTTP ${res.status}` ); } const json = await res.json(); notify.success({ title: t('reviews.xliffImported'), description: t('reviews.xliffImportedDesc', { count: json.data.segments_updated }), }); await load(); } catch (err) { notify.error({ title: t('reviews.xliffImportFailed'), description: friendlyReviewError(err instanceof Error ? err.message : undefined, t, 'reviews.error.update'), }); } finally { setIsImporting(false); if (fileInputRef.current) fileInputRef.current.value = ''; } }; const statusBadge = (status: Segment['status']) => { if (status === 'approved') return {t('reviews.status.approved')}; if (status === 'edited') return {t('reviews.status.edited')}; return {t('reviews.status.pending')}; }; return (
{t('reviews.pill')}

{t('reviews.titleBase')} {t('reviews.titleAccent')}

{t('reviews.subtitle', { file: fileName ?? jobId, total: counts.total, pending: counts.pending, approved: counts.approved, edited: counts.edited, })}

{t('reviews.xliffHint')}

{ const f = e.target.files?.[0]; if (f) importXliff(f); }} />
{loadError && (
{loadError}
)} {/* ── Filtering and Search bar ──────────────────────────────── */} {!isLoading && segments.length > 0 && (
{/* Status tabs */}
{[ { id: 'all', label: t('reviews.filter.all') || 'Tous', count: counts.total }, { id: 'pending', label: t('reviews.filter.pending') || 'À relire', count: counts.pending }, { id: 'approved', label: t('reviews.filter.approved') || 'Approuvés', count: counts.approved }, { id: 'edited', label: t('reviews.filter.edited') || 'Modifiés', count: counts.edited }, ].map((tab) => ( ))}
{/* Search box */}
setSearchQuery(e.target.value)} className="w-full text-xs px-3 py-2 pl-8 rounded-xl bg-white dark:bg-[#141414] border border-black/10 dark:border-white/10 text-brand-dark dark:text-white placeholder:text-brand-dark/30 dark:placeholder:text-white/30 outline-none focus:border-brand-accent/60" />
)} {isLoading ? (
) : segments.length === 0 && !loadError ? (

{t('reviews.empty.title')}

{t('reviews.empty.desc')}

) : (
{filteredSegments.length === 0 ? ( ) : ( filteredSegments.map((seg) => { const isEditing = editingId === seg.id; const finalText = seg.status === 'edited' && seg.reviewed_text ? seg.reviewed_text : seg.translated_text; return (
{t('reviews.col.source')} {t('reviews.col.target')} {t('reviews.col.status')}
{t('reviews.noMatchingSegments') || 'Aucun segment ne correspond à votre filtre.'}
{seg.source_text} {isEditing ? (