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 (
})
{
(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 (
{error &&
{error}
}
{loading ? (
Loading paintings…
) : (
| Painting |
Artist |
Year |
Gallery |
Detail |
Search |
Fix |
Checked |
Fixed |
{filtered.map((row) => {
const searchState = searchById[row.id];
const flagsBusy = flagSavingId === row.id;
return (
|
{onOpenPainting ? (
) : (
row.title
)}
|
{row.artist_name} |
{row.year ?? '—'} |
|
|
retrySearch(row.id)}
/>
|
handleFix(row)}
/>
|
updateRowFlags(row.id, { checked })}
/>
|
updateRowFlags(row.id, { fixed })}
/>
|
);
})}
)}
);
}