diff --git a/Documentation/API.md b/Documentation/API.md index bbdf612..1199b89 100644 --- a/Documentation/API.md +++ b/Documentation/API.md @@ -204,6 +204,112 @@ Returns the image bytes with `Cache-Control: public, max-age=86400`, or `404` if --- +## Developer image audit + +Routes for the **Checkup** page and **Debug mode** on painting detail. Register `GET /api/paintings/checkup` **before** `GET /api/paintings/:id` so `"checkup"` is not parsed as a painting id. + +### `GET /api/paintings/checkup` + +Full-catalog audit table for the Checkup UI. + +**Response** + +```json +{ + "paintings": [ + { + "id": 36, + "title": "Trinity", + "artist_name": "Andrei Rublev", + "year": 1411, + "gallery_file": "paintings/Andrei_Rublev_Trinity.jpg", + "gallery_preview": "paintings/thumbs/Andrei_Rublev_Trinity_thumb.jpg", + "detail_file": "paintings/Andrei_Rublev_Trinity.jpg", + "detail_preview": "paintings/thumbs/Andrei_Rublev_Trinity_thumb.jpg", + "detail_on_demand": false, + "gallery_file_exists": true, + "detail_file_exists": true, + "checked": false, + "fixed": false + } + ], + "total": 1201 +} +``` + +| Field | Meaning | +|-------|---------| +| `checked` | Reviewed in checkup workflow (`checkup_checked`) | +| `fixed` | Image replaced via Fix (`checkup_fixed`) | +| `detail_on_demand` | `true` when detail would use `GET /api/paintings/:id/image?size=full` | +| `gallery_file_exists` / `detail_file_exists` | Disk check under `IMAGE_DIR` | + +--- + +### `PATCH /api/paintings/:id/checkup-flags` + +Update review flags. Body: `{ "checked"?: boolean, "fixed"?: boolean }` — at least one field required. + +Setting `fixed: true` also sets `checked: true`. While `fixed` is true, the API rejects clearing `checked`. + +**Response** + +```json +{ "checked": true, "fixed": false } +``` + +--- + +### `GET /api/paintings/:id/debug-image-search` + +Google-family image search for debug / checkup (Custom Search → Google Arts & Culture → scrape → DuckDuckGo). Used by **Search visible** on the Checkup page and the debug panel on painting detail. + +**Response** + +```json +{ + "query": "Andrei Rublev Trinity painting", + "imageUrl": "https://…", + "source": "google-arts", + "pageUrl": "https://…" +} +``` + +--- + +### `POST /api/paintings/:id/fix-image` + +Download a remote URL and replace the painting’s local full image + thumbnail. Sets `checkup_fixed = true` and `checkup_checked = true`. + +**Body** + +```json +{ "imageUrl": "https://…" } +``` + +**Response** + +```json +{ + "imagePath": "paintings/Artist_Title.jpg", + "thumbnailPath": "paintings/thumbs/Artist_Title_thumb.jpg", + "fixed": true, + "checked": true +} +``` + +--- + +### `GET /api/debug/image-proxy` + +Proxy a remote image URL for debug preview (avoids hotlink / CORS blocks in the browser). + +**Query:** `url` — must be `http://` or `https://` + +Returns image bytes with appropriate `Content-Type`. + +--- + ## Frontend helpers The React client wraps these endpoints in `client/src/api/client.ts`: @@ -220,3 +326,8 @@ The React client wraps these endpoints in `client/src/api/client.ts`: | `imageUrl(path)` | `/images/` or placeholder | | `galleryImageUrl(painting)` | Local thumb/full only (3D) | | `paintingImageUrl(painting)` | Local file or on-demand API | +| `api.getPaintingCheckup()` | `GET /api/paintings/checkup` | +| `api.updatePaintingCheckupFlags(id, flags)` | `PATCH /api/paintings/:id/checkup-flags` | +| `api.getPaintingDebugImageSearch(id)` | `GET /api/paintings/:id/debug-image-search` | +| `api.fixPaintingImage(id, imageUrl)` | `POST /api/paintings/:id/fix-image` | +| `debugImageProxyUrl(imageUrl)` | `GET /api/debug/image-proxy?url=…` | diff --git a/Documentation/DB_structure.md b/Documentation/DB_structure.md index ca56dcf..3a99952 100644 --- a/Documentation/DB_structure.md +++ b/Documentation/DB_structure.md @@ -98,6 +98,12 @@ Phases within an artist’s career (e.g. “Blue Period”, “Roman Period”). | `thumbnail_path` | VARCHAR(500) | Smaller variant for lists / 3D | | `wikipedia_title` | VARCHAR(300) | Used by image fetcher | | `sort_order` | INTEGER | | +| `checkup_checked` | BOOLEAN NOT NULL DEFAULT false | Reviewed in image checkup workflow (UI label: **Reviewed**) | +| `checkup_fixed` | BOOLEAN NOT NULL DEFAULT false | Image corrected via checkup / debug **Fix** | + +When `checkup_fixed` is true, `checkup_checked` is set automatically and cannot be cleared until **Fixed** is off. + +Applied by `npm run migrate:checkup-flags` (`db/migrate-checkup-flags.sql`). ### `painting_influences` @@ -150,7 +156,7 @@ Used by painting detail API (`influencedBy`). Painting-type rows also feed `has_ ## Indexes - `artists(movement_id)`, `artists(century)` -- `paintings(artist_id)`, `paintings(period_id)` +- `paintings(artist_id)`, `paintings(period_id)`, `paintings(checkup_checked)`, `paintings(checkup_fixed)` - `art_movements(era_id)`, `art_movements(start_year, end_year)` - `painting_influences(painting_id)`, `painting_influences(influenced_by_painting_id)` - `painting_influence_sources(painting_id)`, `painting_influence_sources(source_artist_id)`, `painting_influence_sources(source_movement_id)` diff --git a/Documentation/basics.md b/Documentation/basics.md index 1e4ae18..fd2144e 100644 --- a/Documentation/basics.md +++ b/Documentation/basics.md @@ -36,11 +36,12 @@ Gallery/ │ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI) │ │ └── utils/timelineView.ts # Shared zoom/pan math for timeline + movements │ └── dist/ # Production build (served by API when present) -├── scripts/ # Seed, bios, catalog expansion, image fetch +├── scripts/ # Seed, bios, catalog expansion, image fetch, checkup tools │ ├── fetch-artist-bios.js │ ├── expand-paintings.js │ ├── famous-paintings-data.js │ ├── fetch-missing-images.js +│ ├── find-duplicate-paintings.js │ └── image-fetcher.js ├── data/images/ # Local portraits and paintings (+ thumbs/) ├── db/ # SQL schema and migrations (when present) @@ -222,6 +223,24 @@ Opened from the 3D hall (click a frame) or from influence thumbnails on another - **3D gallery images** use locally cached files only; slow remote fetches would break realtime rendering. - **Influence data** is stored as directed links from paintings to sources (another painting, an artist, or a movement), with optional period fields and citation metadata (source author, quote, URL). +## Developer tools (image audit) + +Optional workflow for curating local image files — not part of the public visitor experience. + +| Feature | Where | Purpose | +|---------|--------|---------| +| **Debug mode** | Home header toggle (`client/src/utils/debugMode.ts`) | Persists in `localStorage`; enables debug panel on painting detail | +| **Checkup page** | Home header → **Checkup** (`CheckupPage.tsx`) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags | +| **Debug panel** | Painting detail (bottom-left, when debug mode on) | Google-family image search preview + **Fix it** for the current work | + +**Checkup columns:** Gallery and Detail thumbnails, Search (reference image), Fix (replace local file), **Reviewed** (`checkup_checked`), **Fixed** (`checkup_fixed`). + +**Search visible** runs image search only for rows currently shown after text/filter — not automatically on page load. Fixing an image sets **Fixed** and **Reviewed**. + +Run `npm run migrate:checkup-flags` once on existing databases. After server code changes, restart `npm run dev` so new routes (e.g. `PATCH …/checkup-flags`) are registered. + +See [API.md](API.md#developer-image-audit) and [data-and-images.md](data-and-images.md#duplicate-paintings). + ## Related docs | Document | Contents | diff --git a/Documentation/data-and-images.md b/Documentation/data-and-images.md index 5e767a2..0ac990c 100644 --- a/Documentation/data-and-images.md +++ b/Documentation/data-and-images.md @@ -36,6 +36,8 @@ File names are sanitised `{Artist}_{Title}.{ext}`. The image service can redisco | `image-fetcher.js` | *(library)* | Wikimedia / museum resolution used by fetch scripts and API | | `regenerate-thumbnails.js` | `npm run regenerate-thumbnails` | Rebuild thumbs from full images via `sharp` | | `audit-painting-images.js` | `npm run audit-painting-images` | Detect thumb/full aspect-ratio mismatches | +| `find-duplicate-paintings.js` | `npm run find-duplicates` | Report exact and near-duplicate catalog rows | +| `migrate-checkup-flags.js` | `npm run migrate:checkup-flags` | Add `checkup_checked` / `checkup_fixed` columns | ## Typical workflow @@ -305,3 +307,38 @@ Examples already in the repo: - `Charing Cross Bridge` → Derain (not Monet) After adding an override, delete any wrong cached file under `data/images/paintings/` and re-run fetch or call the on-demand image endpoint for that painting. + +## Duplicate paintings + +The catalog can contain the same work more than once — usually from a **double import** (identical artist + title + year + image) or **Wikipedia scrape variants** (different article titles for one icon, e.g. Andrei Rublev’s Trinity). + +### Find duplicates + +```bash +npm run find-duplicates +``` + +Runs `scripts/find-duplicate-paintings.js`, which reports: + +| Report | Rule | +|--------|------| +| **Exact duplicates** | Same `artist_id`, `title`, and `year` | +| **Normalized title duplicates** | Same artist, titles differing only in punctuation/spacing | +| **Rublev / Trinity cluster** | Known multi-entry example for manual merge | + +As of a recent audit (~1200 paintings): **52 exact duplicate pairs** (52 removable rows), concentrated in **Hieronymus Bosch** (25), **Albrecht Dürer** (14), and **Domenico Ghirlandaio** (13). Duplicate copies typically share the same image file and have **no influence links**, so the higher id in each pair is safe to delete after review. + +**Near-duplicates** (different titles, same work) need curator judgment — e.g. Rublev ids 36, 316, 320, 322, 323 all describe the Trinity icon under different Wikipedia labels; keep id **36** (`Trinity`, wiki `Trinity (Andrei Rublev)`). + +`expand-paintings.js` skips inserts when normalized titles match, but duplicates can still appear if seed and expansion use different title strings or if influence discovery creates works independently. + +## Debug and checkup image fix + +When **Debug mode** is on (home header) or from the **Checkup** page: + +1. **Search** — `GET /api/paintings/:id/debug-image-search` tries Google Custom Search (if `GOOGLE_CSE_API_KEY` + `GOOGLE_CSE_CX` are set in `.env`), Google Arts & Culture, Google Images scrape, then DuckDuckGo (`searchGoogleImagesFirst` in `scripts/image-fetcher.js`). +2. **Fix** — `POST /api/paintings/:id/fix-image` downloads the chosen URL via `replacePaintingImageFromUrl` in `server/image-service.js`, regenerates the thumbnail with `sharp`, and sets checkup flags. + +Checkup **Search visible** queues search for filtered rows only (3 concurrent); it does not search the full catalog on load. + +See [API.md](API.md#developer-image-audit) and [basics.md](basics.md#developer-tools-image-audit). diff --git a/Documentation/setup.md b/Documentation/setup.md index 196b11b..899b3dc 100644 --- a/Documentation/setup.md +++ b/Documentation/setup.md @@ -65,6 +65,7 @@ After a fresh seed, run these to match a fully populated local install: npm run fetch-artist-bios # bio_short / bio_full from Wikipedia npm run expand-catalog # famous works for artists below MIN_PAINTINGS npm run update-influences # painting influence graph for detail view + hall exits +npm run migrate:checkup-flags # optional: review/fixed flags for Checkup page npm run fetch-images -- --limit=50 # random sample; 10s max per painting (default) npm run fetch-images -- --limit=50 --max-wait=120 # same batch size, longer lookup per work cd client && npm run build && cd .. @@ -112,6 +113,8 @@ Open http://localhost:3001 (or your configured `PORT`). | `npm run fetch-artist-images` | `scripts/fetch-artist-images.js` | Backfill portrait files *(if present)* | | `npm run sync-image-paths` | `scripts/sync-image-paths.js` | Align DB paths with disk *(if present)* | | `npm run migrate:influence-sources` | `scripts/migrate-influence-sources.js` | Create `painting_influence_sources` + backfill legacy edges | +| `npm run migrate:checkup-flags` | `scripts/migrate-checkup-flags.js` | Add `checkup_checked` / `checkup_fixed` on `paintings` | +| `npm run find-duplicates` | `scripts/find-duplicate-paintings.js` | Report duplicate and near-duplicate painting rows | | `npm run update-influences` | `scripts/update-influences.js` | Insert influence links (painting / artist / movement) from `art-influences-data.js` | | `npm run update-influences -- --fetch-images` | ↑ | Also download images for newly created works | | `npm run update-influences -- --discover` | ↑ | Curated pass + web discovery (Wikipedia, Wikidata, Met, art-history sites) | @@ -127,6 +130,8 @@ These are checked in and maintained: - `update-influences.js` + `art-influences-data.js` — influence graph (paintings, artists, movements) - `influence-discovery.js` + `influence-resolver.js` — web discovery and polymorphic source resolution - `fetch-missing-images.js` — batch image backfill +- `find-duplicate-paintings.js` — duplicate catalog audit +- `migrate-checkup-flags.js` — checkup workflow columns - `regenerate-thumbnails.js`, `audit-painting-images.js` These are referenced in `package.json` but may need to be restored from git history if missing locally: `seed-wikipedia.js`, `fetch-artist-images.js`, `sync-image-paths.js`. @@ -162,3 +167,5 @@ After clone: copy `.env.example` → `.env`, install dependencies, run `npm run | Default Vite page instead of gallery | `client/dist` missing or stale | `cd client && npm run build` | | Permission denied creating tables | `gallery` user lacks CREATE | Run admin grants, then migrate | | Wikipedia API rate limit during fetch | Too many requests in a row | Wait and re-run; scripts retry with backoff | +| Checkup **Reviewed** toggle returns 404 | Stale server process missing new routes | Restart `npm run dev` after pulling API changes | +| Duplicate works in gallery / timeline | Double import or variant Wikipedia titles | `npm run find-duplicates`; merge or delete spare rows manually | diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 6d8a2cd..85bea2c 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -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(`${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 { diff --git a/client/src/pages/CheckupPage.css b/client/src/pages/CheckupPage.css index 46f40a2..5b95def 100644 --- a/client/src/pages/CheckupPage.css +++ b/client/src/pages/CheckupPage.css @@ -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; diff --git a/client/src/pages/CheckupPage.tsx b/client/src/pages/CheckupPage.tsx index 8d1e2db..90dfdef 100644 --- a/client/src/pages/CheckupPage.tsx +++ b/client/src/pages/CheckupPage.tsx @@ -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 ( +
+ +
+ ); + } + if (searchState.status === 'loading' || searchState.status === 'idle') { return (
@@ -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 (
@@ -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'} {fixError &&

{fixError}

} - {(searchState.status === 'loading' || searchState.status === 'idle') && ( + {searchState?.status === 'loading' || searchState?.status === 'idle' ? (

Waiting for search…

- )} - {searchState.status === 'done' && !searchState.result.imageUrl && ( + ) : null} + {searchState?.status === 'done' && !searchState.result.imageUrl && (

No image to apply

)}
@@ -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 ( + + ); +} + 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); @@ -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) {

Painting checkup

- Compare gallery vs detail images; filtered rows are searched automatically. + Compare gallery vs detail images, then use Search visible to fetch reference images.

@@ -399,8 +462,43 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) { value={filter} onChange={(e) => setFilter(e.target.value)} /> + + +
{filtered.length} shown + {stats.checked} checked + {stats.fixed} fixed search {searchStats.done}/{filtered.length} done {queuePending > 0 ? ` · ${queuePending} active` : ''} @@ -429,13 +527,25 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) { Detail Search Fix + Checked + Fixed {filtered.map((row) => { - const searchState = searchById[row.id] ?? { status: 'idle' as const }; + const searchState = searchById[row.id]; + const flagsBusy = flagSavingId === row.id; return ( - + {onOpenPainting ? (