Introduce checkup_checked/checkup_fixed on paintings with Checkup page filters and API. Fixed paintings auto-mark as reviewed. Add find-duplicates tooling and document debug/checkup workflow. Include Botticelli and Michelangelo image fixes from checkup. Co-authored-by: Cursor <cursoragent@cursor.com>
618 lines
19 KiB
TypeScript
618 lines
19 KiB
TypeScript
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 (
|
|
<div className="checkup-thumb checkup-thumb-empty">
|
|
<span>No image</span>
|
|
<small>Hidden in 3D</small>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={`checkup-thumb${missing ? ' checkup-thumb-missing' : ''}`}>
|
|
{src && (
|
|
<img
|
|
src={src}
|
|
alt=""
|
|
loading="lazy"
|
|
decoding="async"
|
|
title={path}
|
|
onError={(e) => {
|
|
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
|
|
}}
|
|
/>
|
|
)}
|
|
{missing && <span className="checkup-thumb-badge">missing file</span>}
|
|
{row.detail_on_demand && kind === 'detail' && (
|
|
<span className="checkup-thumb-badge checkup-thumb-badge-api">on-demand</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SearchPreviewCell({
|
|
searchState,
|
|
onRetry,
|
|
}: {
|
|
searchState: RowSearchState | undefined;
|
|
onRetry: () => void;
|
|
}) {
|
|
if (!searchState) {
|
|
return (
|
|
<div className="checkup-thumb checkup-thumb-search checkup-thumb-empty">
|
|
<span>—</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (searchState.status === 'loading' || searchState.status === 'idle') {
|
|
return (
|
|
<div className="checkup-thumb checkup-thumb-search checkup-thumb-loading">
|
|
<span>{searchState.status === 'loading' ? 'Searching…' : 'Queued…'}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (searchState.status === 'error') {
|
|
return (
|
|
<div className="checkup-thumb checkup-thumb-search checkup-thumb-empty">
|
|
<span>Search failed</span>
|
|
<button type="button" className="checkup-retry-btn" onClick={onRetry}>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const { result } = searchState;
|
|
if (!result.imageUrl) {
|
|
return (
|
|
<div className="checkup-thumb checkup-thumb-search checkup-thumb-empty">
|
|
<span>No result</span>
|
|
<small title={result.query}>{result.sourceLabel ?? result.source}</small>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="checkup-thumb checkup-thumb-search" title={result.query}>
|
|
<img
|
|
src={debugImageProxyUrl(result.imageUrl)}
|
|
alt={`Search: ${result.query}`}
|
|
loading="lazy"
|
|
decoding="async"
|
|
onError={(e) => {
|
|
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
|
|
}}
|
|
/>
|
|
<span className="checkup-thumb-badge checkup-thumb-badge-search">
|
|
{result.sourceLabel ?? 'Search'}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="checkup-fix-cell">
|
|
<button
|
|
type="button"
|
|
className="checkup-fix-btn"
|
|
onClick={onFix}
|
|
disabled={!canFix}
|
|
title={
|
|
searchState?.status === 'done' && searchState.result.imageUrl
|
|
? 'Replace gallery and detail images with search result'
|
|
: 'Wait for search result'
|
|
}
|
|
>
|
|
{fixing ? 'Replacing…' : 'Fix it'}
|
|
</button>
|
|
{fixError && <p className="checkup-fix-error">{fixError}</p>}
|
|
{searchState?.status === 'loading' || searchState?.status === 'idle' ? (
|
|
<p className="checkup-fix-hint">Waiting for search…</p>
|
|
) : null}
|
|
{searchState?.status === 'done' && !searchState.result.imageUrl && (
|
|
<p className="checkup-fix-hint">No image to apply</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<label className="checkup-flag-label">
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
disabled={disabled}
|
|
onChange={(e) => onChange(e.target.checked)}
|
|
/>
|
|
<span>{label}</span>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
export default function CheckupPage({ onBack, onOpenPainting }: Props) {
|
|
const [rows, setRows] = useState<PaintingCheckupRow[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [filter, setFilter] = useState('');
|
|
const [filterChecked, setFilterChecked] = useState<FlagFilter>('all');
|
|
const [filterFixed, setFilterFixed] = useState<FlagFilter>('all');
|
|
const [searchById, setSearchById] = useState<Record<number, RowSearchState>>({});
|
|
const [imageVersionById, setImageVersionById] = useState<Record<number, number>>({});
|
|
const [fixingId, setFixingId] = useState<number | null>(null);
|
|
const [fixErrorById, setFixErrorById] = useState<Record<number, string>>({});
|
|
const [flagSavingId, setFlagSavingId] = useState<number | null>(null);
|
|
const [queuePending, setQueuePending] = useState(0);
|
|
|
|
const searchByIdRef = useRef(searchById);
|
|
const queueRef = useRef<number[]>([]);
|
|
const activeRef = useRef(0);
|
|
const queuedSetRef = useRef(new Set<number>());
|
|
|
|
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 (
|
|
<div className="checkup-page">
|
|
<header className="checkup-header">
|
|
<button type="button" className="checkup-back-btn" onClick={onBack}>
|
|
← Back to timeline
|
|
</button>
|
|
<div className="checkup-title-block">
|
|
<h1>Painting checkup</h1>
|
|
<p>
|
|
Compare gallery vs detail images, then use Search visible to fetch reference images.
|
|
</p>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="checkup-toolbar">
|
|
<input
|
|
type="search"
|
|
className="checkup-filter"
|
|
placeholder="Filter by title, artist, year…"
|
|
value={filter}
|
|
onChange={(e) => setFilter(e.target.value)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="checkup-search-visible-btn"
|
|
onClick={handleSearchVisible}
|
|
disabled={loading || filtered.length === 0 || visibleNeedSearch === 0}
|
|
title="Run Google-family image search for all rows currently shown in the table"
|
|
>
|
|
Search visible{visibleNeedSearch > 0 ? ` (${visibleNeedSearch})` : ''}
|
|
</button>
|
|
<label className="checkup-filter-select-wrap">
|
|
<span>Checked</span>
|
|
<select
|
|
className="checkup-filter-select"
|
|
value={filterChecked}
|
|
onChange={(e) => setFilterChecked(e.target.value as FlagFilter)}
|
|
>
|
|
<option value="all">All</option>
|
|
<option value="yes">Checked</option>
|
|
<option value="no">Not checked</option>
|
|
</select>
|
|
</label>
|
|
<label className="checkup-filter-select-wrap">
|
|
<span>Fixed</span>
|
|
<select
|
|
className="checkup-filter-select"
|
|
value={filterFixed}
|
|
onChange={(e) => setFilterFixed(e.target.value as FlagFilter)}
|
|
>
|
|
<option value="all">All</option>
|
|
<option value="yes">Fixed</option>
|
|
<option value="no">Not fixed</option>
|
|
</select>
|
|
</label>
|
|
<div className="checkup-stats">
|
|
<span>{filtered.length} shown</span>
|
|
<span>{stats.checked} checked</span>
|
|
<span>{stats.fixed} fixed</span>
|
|
<span>
|
|
search {searchStats.done}/{filtered.length} done
|
|
{queuePending > 0 ? ` · ${queuePending} active` : ''}
|
|
</span>
|
|
{searchStats.failed > 0 && <span>{searchStats.failed} search failed</span>}
|
|
<span>{stats.noGallery} no gallery file</span>
|
|
<span>{stats.onDemand} detail on-demand</span>
|
|
<span>{stats.missingGallery} gallery missing</span>
|
|
<span>{stats.missingDetail} detail missing</span>
|
|
</div>
|
|
</div>
|
|
|
|
{error && <div className="checkup-error">{error}</div>}
|
|
|
|
{loading ? (
|
|
<div className="checkup-loading">Loading paintings…</div>
|
|
) : (
|
|
<div className="checkup-table-wrap">
|
|
<table className="checkup-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Painting</th>
|
|
<th>Artist</th>
|
|
<th>Year</th>
|
|
<th>Gallery</th>
|
|
<th>Detail</th>
|
|
<th>Search</th>
|
|
<th>Fix</th>
|
|
<th>Checked</th>
|
|
<th>Fixed</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filtered.map((row) => {
|
|
const searchState = searchById[row.id];
|
|
const flagsBusy = flagSavingId === row.id;
|
|
return (
|
|
<tr
|
|
key={row.id}
|
|
className={
|
|
row.checked && row.fixed
|
|
? 'checkup-row-done'
|
|
: row.checked
|
|
? 'checkup-row-checked'
|
|
: undefined
|
|
}
|
|
>
|
|
<td>
|
|
{onOpenPainting ? (
|
|
<button
|
|
type="button"
|
|
className="checkup-title-link"
|
|
onClick={() => onOpenPainting(row.id)}
|
|
>
|
|
{row.title}
|
|
</button>
|
|
) : (
|
|
row.title
|
|
)}
|
|
</td>
|
|
<td>{row.artist_name}</td>
|
|
<td className="checkup-year">{row.year ?? '—'}</td>
|
|
<td className="checkup-thumb-cell">
|
|
<ImagePreviewCell
|
|
row={row}
|
|
kind="gallery"
|
|
imageVersion={imageVersionById[row.id] ?? 0}
|
|
/>
|
|
</td>
|
|
<td className="checkup-thumb-cell">
|
|
<ImagePreviewCell
|
|
row={row}
|
|
kind="detail"
|
|
imageVersion={imageVersionById[row.id] ?? 0}
|
|
/>
|
|
</td>
|
|
<td className="checkup-thumb-cell">
|
|
<SearchPreviewCell
|
|
searchState={searchState}
|
|
onRetry={() => retrySearch(row.id)}
|
|
/>
|
|
</td>
|
|
<td className="checkup-fix-cell-wrap">
|
|
<FixCell
|
|
searchState={searchState}
|
|
fixing={fixingId === row.id}
|
|
fixError={fixErrorById[row.id] ?? null}
|
|
onFix={() => handleFix(row)}
|
|
/>
|
|
</td>
|
|
<td className="checkup-flag-cell">
|
|
<FlagCheckbox
|
|
label="Reviewed"
|
|
checked={row.checked}
|
|
disabled={flagsBusy || row.fixed}
|
|
onChange={(checked) => updateRowFlags(row.id, { checked })}
|
|
/>
|
|
</td>
|
|
<td className="checkup-flag-cell">
|
|
<FlagCheckbox
|
|
label="Fixed"
|
|
checked={row.fixed}
|
|
disabled={flagsBusy}
|
|
onChange={(fixed) => updateRowFlags(row.id, { fixed })}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|