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>
This commit is contained in:
Danila Khodjaef
2026-06-20 20:45:55 +03:00
co-authored by Cursor
parent 4683089495
commit 4c6acd5a3a
55 changed files with 750 additions and 39 deletions
+19 -1
View File
@@ -67,6 +67,8 @@ export interface PaintingCheckupRow {
detail_on_demand: boolean;
gallery_file_exists: boolean;
detail_file_exists: boolean | null;
checked: boolean;
fixed: boolean;
}
export interface PaintingCheckupData {
@@ -108,10 +110,26 @@ export const api = {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Fix failed: ${res.status}`);
}
return res.json() as Promise<{ imagePath: string; thumbnailPath: string }>;
return res.json() as Promise<{ imagePath: string; thumbnailPath: string; fixed?: boolean; checked?: boolean }>;
}),
getPaintingCheckup: () => fetchJson<PaintingCheckupData>(`${API}/paintings/checkup`),
updatePaintingCheckupFlags: (
id: number,
flags: { checked?: boolean; fixed?: boolean }
) =>
fetch(`${API}/paintings/${id}/checkup-flags`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(flags),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Update failed: ${res.status}`);
}
return res.json() as Promise<{ checked: boolean; fixed: boolean }>;
}),
};
export function debugImageProxyUrl(imageUrl: string): string {
+74
View File
@@ -65,6 +65,80 @@
font-size: 13px;
}
.checkup-search-visible-btn {
padding: 8px 14px;
border-radius: 6px;
border: 1px solid #e8a040;
background: rgba(232, 160, 64, 0.15);
color: #e8a040;
font-size: 13px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.checkup-search-visible-btn:hover:not(:disabled) {
background: rgba(232, 160, 64, 0.28);
}
.checkup-search-visible-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
border-color: rgba(201, 169, 110, 0.25);
color: rgba(201, 169, 110, 0.45);
background: transparent;
}
.checkup-filter-select-wrap {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: rgba(201, 169, 110, 0.75);
flex-shrink: 0;
}
.checkup-filter-select {
padding: 6px 8px;
border-radius: 6px;
border: 1px solid rgba(201, 169, 110, 0.3);
background: rgba(15, 15, 26, 0.9);
color: #e8d5b5;
font-size: 12px;
}
.checkup-flag-cell {
width: 96px;
}
.checkup-flag-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: rgba(232, 213, 181, 0.85);
cursor: pointer;
user-select: none;
}
.checkup-flag-label input {
accent-color: #e8a040;
}
.checkup-row-checked {
background: rgba(201, 169, 110, 0.04);
}
.checkup-row-done {
background: rgba(76, 175, 80, 0.06);
}
.checkup-table tbody tr.checkup-row-checked:hover,
.checkup-table tbody tr.checkup-row-done:hover {
background: rgba(201, 169, 110, 0.1);
}
.checkup-stats {
display: flex;
flex-wrap: wrap;
+160 -34
View File
@@ -19,7 +19,6 @@ type RowSearchState =
| { status: 'error' };
const SEARCH_CONCURRENCY = 3;
const FILTER_SEARCH_DEBOUNCE_MS = 350;
function previewSrc(
row: PaintingCheckupRow,
@@ -88,9 +87,17 @@ function SearchPreviewCell({
searchState,
onRetry,
}: {
searchState: RowSearchState;
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">
@@ -144,13 +151,13 @@ function FixCell({
fixError,
onFix,
}: {
searchState: RowSearchState;
searchState: RowSearchState | undefined;
fixing: boolean;
fixError: string | null;
onFix: () => void;
}) {
const canFix =
searchState.status === 'done' && !!searchState.result.imageUrl && !fixing;
searchState?.status === 'done' && !!searchState.result.imageUrl && !fixing;
return (
<div className="checkup-fix-cell">
@@ -160,7 +167,7 @@ function FixCell({
onClick={onFix}
disabled={!canFix}
title={
searchState.status === 'done' && searchState.result.imageUrl
searchState?.status === 'done' && searchState.result.imageUrl
? 'Replace gallery and detail images with search result'
: 'Wait for search result'
}
@@ -168,10 +175,10 @@ function FixCell({
{fixing ? 'Replacing…' : 'Fix it'}
</button>
{fixError && <p className="checkup-fix-error">{fixError}</p>}
{(searchState.status === 'loading' || searchState.status === 'idle') && (
{searchState?.status === 'loading' || searchState?.status === 'idle' ? (
<p className="checkup-fix-hint">Waiting for search</p>
)}
{searchState.status === 'done' && !searchState.result.imageUrl && (
) : null}
{searchState?.status === 'done' && !searchState.result.imageUrl && (
<p className="checkup-fix-hint">No image to apply</p>
)}
</div>
@@ -182,15 +189,44 @@ 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);
@@ -315,6 +351,8 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
gallery_file_exists: true,
detail_file_exists: true,
detail_on_demand: false,
fixed: true,
checked: true,
}
: r
)
@@ -331,32 +369,55 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
[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();
if (!q) return rows;
return rows.filter(
(row) =>
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)
);
}, [rows, filter]);
const filteredIds = useMemo(() => filtered.map((row) => row.id), [filtered]);
useEffect(() => {
if (loading) return;
const timer = setTimeout(() => {
for (const rowId of filteredIds) {
enqueueSearch(rowId);
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;
}
}, FILTER_SEARCH_DEBOUNCE_MS);
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]);
return () => clearTimeout(timer);
}, [filteredIds, loading, enqueueSearch]);
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;
@@ -374,7 +435,9 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
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;
return { noGallery, onDemand, missingGallery, missingDetail };
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 (
@@ -386,7 +449,7 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
<div className="checkup-title-block">
<h1>Painting checkup</h1>
<p>
Compare gallery vs detail images; filtered rows are searched automatically.
Compare gallery vs detail images, then use Search visible to fetch reference images.
</p>
</div>
</header>
@@ -399,8 +462,43 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
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` : ''}
@@ -429,13 +527,25 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
<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] ?? { status: 'idle' as const };
const searchState = searchById[row.id];
const flagsBusy = flagSavingId === row.id;
return (
<tr key={row.id}>
<tr
key={row.id}
className={
row.checked && row.fixed
? 'checkup-row-done'
: row.checked
? 'checkup-row-checked'
: undefined
}
>
<td>
{onOpenPainting ? (
<button
@@ -479,6 +589,22 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
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>
);
})}