Files
Art-gallery/client/src/pages/CheckupPage.tsx
T
Danila KhodjaefandCursor 4c6acd5a3a Add checkup review flags, duplicate detection, and corrected painting images.
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>
2026-06-20 20:45:55 +03:00

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>
);
}