import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api, debugImageProxyUrl, type DebugImageSearchResult, type PaintingCheckupRow, } from '../api/client'; import './CheckupPage.css'; interface Props { onBack: () => void; onOpenPainting?: (paintingId: number) => void; } type RowSearchState = | { status: 'idle' } | { status: 'loading' } | { status: 'done'; result: DebugImageSearchResult } | { status: 'error' }; const SEARCH_CONCURRENCY = 3; function previewSrc( row: PaintingCheckupRow, kind: 'gallery' | 'detail', version: number ): string | null { const bust = version > 0 ? `?v=${version}` : ''; if (kind === 'gallery') { const file = row.gallery_preview ?? row.gallery_file; return file ? `/images/${file}${bust}` : null; } if (row.detail_on_demand) { return `/api/paintings/${row.id}/image?size=thumb${version > 0 ? `&v=${version}` : ''}`; } const file = row.detail_preview ?? row.detail_file; return file ? `/images/${file}${bust}` : null; } function ImagePreviewCell({ row, kind, imageVersion, }: { row: PaintingCheckupRow; kind: 'gallery' | 'detail'; imageVersion: number; }) { const path = kind === 'gallery' ? row.gallery_file : row.detail_file; const src = previewSrc(row, kind, imageVersion); const exists = kind === 'gallery' ? row.gallery_file_exists : row.detail_file_exists; const missing = path && exists === false; if (!path) { return (
No image Hidden in 3D
); } return (
{src && ( { (e.target as HTMLImageElement).src = '/placeholder-art.svg'; }} /> )} {missing && missing file} {row.detail_on_demand && kind === 'detail' && ( on-demand )}
); } function SearchPreviewCell({ searchState, onRetry, }: { searchState: RowSearchState | undefined; onRetry: () => void; }) { if (!searchState) { return (
); } if (searchState.status === 'loading' || searchState.status === 'idle') { return (
{searchState.status === 'loading' ? 'Searching…' : 'Queued…'}
); } if (searchState.status === 'error') { return (
Search failed
); } const { result } = searchState; if (!result.imageUrl) { return (
No result {result.sourceLabel ?? result.source}
); } return (
{`Search: { (e.target as HTMLImageElement).src = '/placeholder-art.svg'; }} /> {result.sourceLabel ?? 'Search'}
); } function FixCell({ searchState, fixing, fixError, onFix, }: { searchState: RowSearchState | undefined; fixing: boolean; fixError: string | null; onFix: () => void; }) { const canFix = searchState?.status === 'done' && !!searchState.result.imageUrl && !fixing; return (
{fixError &&

{fixError}

} {searchState?.status === 'loading' || searchState?.status === 'idle' ? (

Waiting for search…

) : null} {searchState?.status === 'done' && !searchState.result.imageUrl && (

No image to apply

)}
); } function needsSearch(state: RowSearchState | undefined): boolean { return !state || state.status === 'idle' || state.status === 'error'; } type FlagFilter = 'all' | 'yes' | 'no'; function FlagCheckbox({ label, checked, disabled, onChange, }: { label: string; checked: boolean; disabled?: boolean; onChange: (next: boolean) => void; }) { return ( ); } export default function CheckupPage({ onBack, onOpenPainting }: Props) { const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [filter, setFilter] = useState(''); const [filterChecked, setFilterChecked] = useState('all'); const [filterFixed, setFilterFixed] = useState('all'); const [searchById, setSearchById] = useState>({}); const [imageVersionById, setImageVersionById] = useState>({}); const [fixingId, setFixingId] = useState(null); const [fixErrorById, setFixErrorById] = useState>({}); const [flagSavingId, setFlagSavingId] = useState(null); const [queuePending, setQueuePending] = useState(0); const searchByIdRef = useRef(searchById); const queueRef = useRef([]); const activeRef = useRef(0); const queuedSetRef = useRef(new Set()); useEffect(() => { searchByIdRef.current = searchById; }, [searchById]); useEffect(() => { let cancelled = false; setLoading(true); api.getPaintingCheckup() .then((data) => { if (!cancelled) { setRows(data.paintings); setError(null); } }) .catch(() => { if (!cancelled) setError('Could not load checkup data. Is the server running?'); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, []); const updateQueuePending = useCallback(() => { setQueuePending(queueRef.current.length + activeRef.current); }, []); const runSearch = useCallback( (rowId: number) => { activeRef.current += 1; updateQueuePending(); setSearchById((prev) => ({ ...prev, [rowId]: { status: 'loading' } })); api.getPaintingDebugImageSearch(rowId) .then((result) => { setSearchById((prev) => ({ ...prev, [rowId]: { status: 'done', result } })); }) .catch(() => { queuedSetRef.current.delete(rowId); setSearchById((prev) => ({ ...prev, [rowId]: { status: 'error' } })); }) .finally(() => { activeRef.current = Math.max(0, activeRef.current - 1); updateQueuePending(); pumpQueueRef.current(); }); }, [updateQueuePending] ); const pumpQueue = useCallback(() => { while (activeRef.current < SEARCH_CONCURRENCY && queueRef.current.length > 0) { const rowId = queueRef.current.shift()!; runSearch(rowId); } updateQueuePending(); }, [runSearch, updateQueuePending]); const pumpQueueRef = useRef(pumpQueue); useEffect(() => { pumpQueueRef.current = pumpQueue; }, [pumpQueue]); const enqueueSearch = useCallback( (rowId: number) => { const state = searchByIdRef.current[rowId]; if (!needsSearch(state)) return; if (queuedSetRef.current.has(rowId)) return; queuedSetRef.current.add(rowId); setSearchById((prev) => ({ ...prev, [rowId]: { status: 'idle' } })); queueRef.current.push(rowId); updateQueuePending(); pumpQueueRef.current(); }, [updateQueuePending] ); const retrySearch = useCallback( (rowId: number) => { queuedSetRef.current.delete(rowId); queueRef.current = queueRef.current.filter((id) => id !== rowId); setSearchById((prev) => ({ ...prev, [rowId]: { status: 'idle' } })); enqueueSearch(rowId); }, [enqueueSearch] ); const handleFix = useCallback( async (row: PaintingCheckupRow) => { const searchState = searchById[row.id]; if (searchState?.status !== 'done' || !searchState.result.imageUrl || fixingId) return; setFixingId(row.id); setFixErrorById((prev) => { const next = { ...prev }; delete next[row.id]; return next; }); try { const updated = await api.fixPaintingImage(row.id, searchState.result.imageUrl); setImageVersionById((prev) => ({ ...prev, [row.id]: (prev[row.id] ?? 0) + 1 })); setRows((list) => list.map((r) => r.id === row.id ? { ...r, gallery_file: updated.imagePath, detail_file: updated.imagePath, gallery_preview: updated.thumbnailPath, detail_preview: updated.thumbnailPath, gallery_file_exists: true, detail_file_exists: true, detail_on_demand: false, fixed: true, checked: true, } : r ) ); } catch (err) { setFixErrorById((prev) => ({ ...prev, [row.id]: err instanceof Error ? err.message : 'Could not replace image.', })); } finally { setFixingId(null); } }, [fixingId, searchById] ); const updateRowFlags = useCallback( async (rowId: number, flags: { checked?: boolean; fixed?: boolean }) => { setFlagSavingId(rowId); try { const updated = await api.updatePaintingCheckupFlags(rowId, flags); setRows((list) => list.map((r) => r.id === rowId ? { ...r, checked: updated.checked, fixed: updated.fixed } : r ) ); } catch (err) { setError(err instanceof Error ? err.message : 'Could not save checkup flags.'); } finally { setFlagSavingId(null); } }, [] ); const filtered = useMemo(() => { const q = filter.trim().toLowerCase(); return rows.filter((row) => { if (q) { const matchesText = row.title.toLowerCase().includes(q) || row.artist_name.toLowerCase().includes(q) || String(row.year ?? '').includes(q) || (row.gallery_file ?? '').toLowerCase().includes(q) || row.detail_file.toLowerCase().includes(q); if (!matchesText) return false; } if (filterChecked === 'yes' && !row.checked) return false; if (filterChecked === 'no' && row.checked) return false; if (filterFixed === 'yes' && !row.fixed) return false; if (filterFixed === 'no' && row.fixed) return false; return true; }); }, [rows, filter, filterChecked, filterFixed]); const handleSearchVisible = useCallback(() => { for (const row of filtered) { enqueueSearch(row.id); } }, [filtered, enqueueSearch]); const visibleNeedSearch = useMemo( () => filtered.filter((row) => needsSearch(searchById[row.id])).length, [filtered, searchById] ); const searchStats = useMemo(() => { let done = 0; let failed = 0; for (const row of filtered) { const state = searchById[row.id]; if (state?.status === 'done') done += 1; if (state?.status === 'error') failed += 1; } return { done, failed }; }, [filtered, searchById]); const stats = useMemo(() => { const noGallery = rows.filter((r) => !r.gallery_file).length; const onDemand = rows.filter((r) => r.detail_on_demand).length; const missingGallery = rows.filter((r) => r.gallery_file && !r.gallery_file_exists).length; const missingDetail = rows.filter((r) => r.detail_file_exists === false).length; const checked = rows.filter((r) => r.checked).length; const fixed = rows.filter((r) => r.fixed).length; return { noGallery, onDemand, missingGallery, missingDetail, checked, fixed }; }, [rows]); return (

Painting checkup

Compare gallery vs detail images, then use Search visible to fetch reference images.

setFilter(e.target.value)} />
{filtered.length} shown {stats.checked} checked {stats.fixed} fixed search {searchStats.done}/{filtered.length} done {queuePending > 0 ? ` · ${queuePending} active` : ''} {searchStats.failed > 0 && {searchStats.failed} search failed} {stats.noGallery} no gallery file {stats.onDemand} detail on-demand {stats.missingGallery} gallery missing {stats.missingDetail} detail missing
{error &&
{error}
} {loading ? (
Loading paintings…
) : (
{filtered.map((row) => { const searchState = searchById[row.id]; const flagsBusy = flagSavingId === row.id; return ( ); })}
Painting Artist Year Gallery Detail Search Fix Checked Fixed
{onOpenPainting ? ( ) : ( row.title )} {row.artist_name} {row.year ?? '—'} retrySearch(row.id)} /> handleFix(row)} /> updateRowFlags(row.id, { checked })} /> updateRowFlags(row.id, { fixed })} />
)}
); }