Add bulk image search on Checkup page and apply image fixes.

Auto-queue Google-family searches for filtered rows with Fix-it support; include corrected painting images from checkup workflow.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-06-20 19:52:43 +03:00
co-authored by Cursor
parent bf7db9b25e
commit 4683089495
218 changed files with 419 additions and 36 deletions
+85
View File
@@ -187,6 +187,91 @@
color: #9ec5e8;
}
.checkup-thumb-search {
border-color: rgba(232, 160, 64, 0.35);
}
.checkup-thumb-loading {
color: rgba(201, 169, 110, 0.55);
font-size: 11px;
}
.checkup-thumb-badge-search {
border-color: rgba(232, 160, 64, 0.45);
color: #e8a040;
max-width: calc(100% - 12px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.checkup-retry-btn {
margin-top: 6px;
padding: 4px 8px;
border: 1px solid rgba(201, 169, 110, 0.35);
border-radius: 4px;
background: transparent;
color: #c9a96e;
font-size: 10px;
cursor: pointer;
}
.checkup-retry-btn:hover {
border-color: #c9a96e;
color: #e8d5b5;
}
.checkup-fix-cell-wrap {
width: 120px;
}
.checkup-fix-cell {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-start;
}
.checkup-fix-btn {
padding: 8px 12px;
border: 1px solid #e8a040;
border-radius: 6px;
background: rgba(232, 160, 64, 0.15);
color: #e8a040;
font-size: 12px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.checkup-fix-btn:hover:not(:disabled) {
background: rgba(232, 160, 64, 0.28);
}
.checkup-fix-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-fix-error {
margin: 0;
font-size: 10px;
color: #ff8a80;
line-height: 1.3;
max-width: 110px;
}
.checkup-fix-hint {
margin: 0;
font-size: 10px;
color: rgba(201, 169, 110, 0.55);
line-height: 1.3;
max-width: 110px;
}
.checkup-table tbody tr:hover {
background: rgba(201, 169, 110, 0.06);
}
+334 -36
View File
@@ -1,5 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { api, type PaintingCheckupRow } from '../api/client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
api,
debugImageProxyUrl,
type DebugImageSearchResult,
type PaintingCheckupRow,
} from '../api/client';
import './CheckupPage.css';
interface Props {
@@ -7,27 +12,43 @@ interface Props {
onOpenPainting?: (paintingId: number) => void;
}
function previewSrc(row: PaintingCheckupRow, kind: 'gallery' | 'detail'): string | null {
type RowSearchState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'done'; result: DebugImageSearchResult }
| { status: 'error' };
const SEARCH_CONCURRENCY = 3;
const FILTER_SEARCH_DEBOUNCE_MS = 350;
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}` : null;
return file ? `/images/${file}${bust}` : null;
}
if (row.detail_on_demand) {
return `/api/paintings/${row.id}/image?size=thumb`;
return `/api/paintings/${row.id}/image?size=thumb${version > 0 ? `&v=${version}` : ''}`;
}
const file = row.detail_preview ?? row.detail_file;
return file ? `/images/${file}` : null;
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);
const src = previewSrc(row, kind, imageVersion);
const exists =
kind === 'gallery' ? row.gallery_file_exists : row.detail_file_exists;
const missing = path && exists === false;
@@ -63,11 +84,123 @@ function ImagePreviewCell({
);
}
function SearchPreviewCell({
searchState,
onRetry,
}: {
searchState: RowSearchState;
onRetry: () => void;
}) {
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;
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>
)}
{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';
}
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 [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 [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;
@@ -90,6 +223,114 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
};
}, []);
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,
}
: r
)
);
} catch (err) {
setFixErrorById((prev) => ({
...prev,
[row.id]: err instanceof Error ? err.message : 'Could not replace image.',
}));
} finally {
setFixingId(null);
}
},
[fixingId, searchById]
);
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return rows;
@@ -103,13 +344,36 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
);
}, [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);
}
}, FILTER_SEARCH_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [filteredIds, loading, enqueueSearch]);
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 missingDetail = rows.filter((r) => r.detail_file_exists === false).length;
return { noGallery, onDemand, missingGallery, missingDetail };
}, [rows]);
@@ -121,7 +385,9 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
</button>
<div className="checkup-title-block">
<h1>Painting checkup</h1>
<p>Compare image files used in the 3D gallery vs painting detail view.</p>
<p>
Compare gallery vs detail images; filtered rows are searched automatically.
</p>
</div>
</header>
@@ -135,6 +401,11 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
/>
<div className="checkup-stats">
<span>{filtered.length} shown</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>
@@ -156,34 +427,61 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
<th>Year</th>
<th>Gallery</th>
<th>Detail</th>
<th>Search</th>
<th>Fix</th>
</tr>
</thead>
<tbody>
{filtered.map((row) => (
<tr key={row.id}>
<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" />
</td>
<td className="checkup-thumb-cell">
<ImagePreviewCell row={row} kind="detail" />
</td>
</tr>
))}
{filtered.map((row) => {
const searchState = searchById[row.id] ?? { status: 'idle' as const };
return (
<tr key={row.id}>
<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>
</tr>
);
})}
</tbody>
</table>
</div>