IIIF-Studio / frontend /src /pages /Editor.tsx
Claude
feat: Sprint 7 — integration test, export limit, French accents
568e457 unverified
Raw
History Blame Contribute Delete
15 kB
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
applyCorrections,
getHistory,
fetchMasterJson,
ApiError,
type PageMaster,
type VersionInfo,
} from '../lib/api.ts'
import Viewer from '../components/Viewer.tsx'
import {
RetroMenuBar,
RetroWindow,
RetroButton,
RetroTextarea,
RetroSelect,
RetroBadge,
} from '../components/retro'
type Panel = 'transcription' | 'commentary' | 'regions' | 'history'
const PANEL_LABELS: Record<Panel, string> = {
transcription: 'Transcription',
commentary: 'Commentaire',
regions: 'Regions',
history: 'Historique',
}
export default function Editor() {
const { pageId = '' } = useParams()
const navigate = useNavigate()
const [master, setMaster] = useState<PageMaster | null>(null)
const [history, setHistory] = useState<VersionInfo[]>([])
const [activePanel, setActivePanel] = useState<Panel>('transcription')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [saveError, setSaveError] = useState<string | null>(null)
const [saveSuccess, setSaveSuccess] = useState(false)
const [ocrText, setOcrText] = useState('')
const [commentaryPublic, setCommentaryPublic] = useState('')
const [commentaryScholarly, setCommentaryScholarly] = useState('')
const [editorialStatus, setEditorialStatus] = useState('')
const [regionValidations, setRegionValidations] = useState<Record<string, string>>({})
const successTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
// Nettoyage du timeout de succès lors du démontage du composant
useEffect(() => {
return () => {
if (successTimeout.current) clearTimeout(successTimeout.current)
}
}, [])
const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [m, h] = await Promise.all([fetchMasterJson(pageId), getHistory(pageId)])
setMaster(m)
setHistory(h)
setOcrText(m.ocr?.diplomatic_text ?? '')
setCommentaryPublic(m.commentary?.public ?? '')
setCommentaryScholarly(m.commentary?.scholarly ?? '')
setEditorialStatus(m.editorial.status)
const ext = m.extensions as { region_validations?: Record<string, string> } | undefined
setRegionValidations(ext?.region_validations ?? {})
} catch (e: unknown) {
if (e instanceof ApiError && e.status === 404) {
setError('Cette page n\'a pas encore été analysée par l\'IA. Lancez le pipeline depuis Administration.')
} else {
const msg = e instanceof Error ? e.message : ''
setError(msg || 'Erreur de chargement')
}
} finally {
setLoading(false)
}
}, [pageId])
useEffect(() => {
void loadData()
}, [loadData])
const handleSave = async () => {
setSaving(true)
setSaveError(null)
setSaveSuccess(false)
try {
const updated = await applyCorrections(pageId, {
ocr_diplomatic_text: ocrText !== (master?.ocr?.diplomatic_text ?? '') ? ocrText : undefined,
editorial_status: editorialStatus !== master?.editorial.status ? editorialStatus : undefined,
commentary_public: commentaryPublic !== (master?.commentary?.public ?? '') ? commentaryPublic : undefined,
commentary_scholarly: commentaryScholarly !== (master?.commentary?.scholarly ?? '') ? commentaryScholarly : undefined,
region_validations: Object.keys(regionValidations).length > 0 ? regionValidations : undefined,
})
setMaster(updated)
const h = await getHistory(pageId)
setHistory(h)
setSaveSuccess(true)
if (successTimeout.current) clearTimeout(successTimeout.current)
successTimeout.current = setTimeout(() => setSaveSuccess(false), 3000)
} catch (e: unknown) {
setSaveError((e as Error).message)
} finally {
setSaving(false)
}
}
const handleRestore = async (version: number) => {
setSaving(true)
setSaveError(null)
try {
const updated = await applyCorrections(pageId, { restore_to_version: version })
setMaster(updated)
setOcrText(updated.ocr?.diplomatic_text ?? '')
setCommentaryPublic(updated.commentary?.public ?? '')
setCommentaryScholarly(updated.commentary?.scholarly ?? '')
setEditorialStatus(updated.editorial.status)
const h = await getHistory(pageId)
setHistory(h)
} catch (e: unknown) {
setSaveError((e as Error).message)
} finally {
setSaving(false)
}
}
const setRegionValidation = (regionId: string, val: string) => {
setRegionValidations((prev) => ({ ...prev, [regionId]: val }))
}
// ── Loading / Error ─────────────────────────────────────────────────
if (loading) {
return (
<div className="min-h-screen bg-retro-dither flex items-center justify-center">
<RetroWindow title="Chargement" className="w-64">
<div className="p-4 text-retro-sm text-center">Chargement...</div>
</RetroWindow>
</div>
)
}
if (error) {
return (
<div className="min-h-screen bg-retro-dither flex items-center justify-center">
<RetroWindow title="Erreur" className="w-80">
<div className="p-4 text-retro-sm">
{error}
<div className="mt-2"><RetroButton onClick={() => navigate(-1)}>Retour</RetroButton></div>
</div>
</RetroWindow>
</div>
)
}
const iiifServiceUrl = master?.image?.iiif_service_url ?? null
const fallbackImageUrl = master?.image?.derivative_web ?? master?.image?.master ?? ''
const regions = master?.layout?.regions ?? []
return (
<div className="flex flex-col h-screen bg-retro-dither">
{/* ── Menu bar ───────────────────────────────────────────────── */}
<RetroMenuBar
items={[
{ label: 'IIIF Studio', onClick: () => navigate('/') },
{ label: `Éditeur — ${master?.folio_label ?? pageId}` },
]}
right={
<div className="flex items-center gap-1">
{master && (
<span className="text-retro-xs px-2">
v{master.editorial.version} — {master.editorial.status}
</span>
)}
{saveSuccess && <RetroBadge variant="success">OK</RetroBadge>}
{saveError && <RetroBadge variant="error">Err</RetroBadge>}
<RetroButton
size="sm"
onClick={() => void handleSave()}
disabled={saving}
>
{saving ? 'Saving...' : 'Sauvegarder'}
</RetroButton>
</div>
}
/>
{/* ── Main layout 50/50 ──────────────────────────────────────── */}
<div className="flex flex-1 min-h-0 overflow-hidden p-1 gap-1">
{/* ── Viewer window (left) ───────────────────────────────── */}
<RetroWindow
title={`Image — ${master?.folio_label ?? pageId}`}
className="flex-1 min-w-0"
>
<div className="relative w-full h-full">
<Viewer iiifServiceUrl={iiifServiceUrl} fallbackImageUrl={fallbackImageUrl} onViewerReady={() => {}} />
{!iiifServiceUrl && !fallbackImageUrl && (
<div className="absolute inset-0 flex items-center justify-center bg-retro-gray text-retro-darkgray text-retro-sm">
Aperçu non disponible
</div>
)}
</div>
</RetroWindow>
{/* ── Editor window (right) ──────────────────────────────── */}
<RetroWindow
title="Éditeur"
className="flex-1 min-w-0"
scrollable
>
<div className="flex flex-col">
{/* ── Tab bar ──────────────────────────────────────── */}
<div className="flex shrink-0 border-b border-retro-black bg-retro-gray">
{(['transcription', 'commentary', 'regions', 'history'] as Panel[]).map((p) => (
<RetroButton
key={p}
size="sm"
pressed={activePanel === p}
onClick={() => setActivePanel(p)}
className="flex-1 border-0 border-r border-retro-darkgray last:border-r-0"
>
{PANEL_LABELS[p]}
</RetroButton>
))}
</div>
{/* ── Panel content ─────────────────────────────────── */}
<div className="p-2">
{/* Transcription */}
{activePanel === 'transcription' && (
<div className="flex flex-col gap-2">
<RetroTextarea
label="Texte diplomatique (OCR)"
value={ocrText}
onChange={(e) => setOcrText(e.target.value)}
rows={12}
/>
<RetroSelect
label="Statut éditorial"
value={editorialStatus}
onChange={(e) => setEditorialStatus(e.target.value)}
options={[
{ value: 'machine_draft', label: 'machine_draft' },
{ value: 'needs_review', label: 'needs_review' },
{ value: 'reviewed', label: 'reviewed' },
{ value: 'validated', label: 'validated' },
{ value: 'published', label: 'published' },
]}
/>
{master?.ocr && (
<div className="text-retro-xs text-retro-darkgray">
Langue: {master.ocr.language} — Confiance: {(master.ocr.confidence * 100).toFixed(0)}%
</div>
)}
</div>
)}
{/* Commentary */}
{activePanel === 'commentary' && (
<div className="flex flex-col gap-2">
<RetroTextarea
label="Commentaire public"
value={commentaryPublic}
onChange={(e) => setCommentaryPublic(e.target.value)}
rows={6}
/>
<RetroTextarea
label="Commentaire savant"
value={commentaryScholarly}
onChange={(e) => setCommentaryScholarly(e.target.value)}
rows={8}
/>
</div>
)}
{/* Regions */}
{activePanel === 'regions' && (
<div className="flex flex-col gap-[2px]">
{regions.length === 0 ? (
<p className="text-retro-sm text-retro-darkgray p-2">Aucune région détectée.</p>
) : (
regions.map((region) => {
const validation = regionValidations[region.id]
return (
<div
key={region.id}
className="
flex items-center justify-between
border border-retro-black p-2
bg-retro-white
"
>
<div>
<span className="text-retro-sm font-bold capitalize">
{region.type.replace(/_/g, ' ')}
</span>
<span className="ml-2 text-retro-xs text-retro-darkgray">
{region.id}
</span>
<div className="text-retro-xs text-retro-darkgray">
confiance: {(region.confidence * 100).toFixed(0)}%
</div>
</div>
<div className="flex gap-[2px] ml-2 shrink-0">
<RetroButton
size="sm"
pressed={validation === 'validated'}
onClick={() => setRegionValidation(region.id, 'validated')}
>
OK
</RetroButton>
<RetroButton
size="sm"
pressed={validation === 'rejected'}
onClick={() => setRegionValidation(region.id, 'rejected')}
>
X
</RetroButton>
</div>
</div>
)
})
)}
</div>
)}
{/* History */}
{activePanel === 'history' && (
<div className="flex flex-col gap-[2px]">
{history.length === 0 ? (
<p className="text-retro-sm text-retro-darkgray p-2">Aucune version archivée.</p>
) : (
history.map((v) => (
<div
key={v.version}
className="
flex items-center justify-between
border border-retro-black p-2
bg-retro-white
"
>
<div>
<span className="text-retro-sm font-bold">v{v.version}</span>
<RetroBadge className="ml-2">{v.status}</RetroBadge>
<div className="text-retro-xs text-retro-darkgray mt-[2px]">
{new Date(v.saved_at).toLocaleString('fr-FR')}
</div>
</div>
<RetroButton
size="sm"
onClick={() => void handleRestore(v.version)}
disabled={saving}
>
Restaurer
</RetroButton>
</div>
))
)}
</div>
)}
</div>
</div>
</RetroWindow>
</div>
</div>
)
}