diff --git a/Documentation/API.md b/Documentation/API.md index ab2d3f2..e7accd5 100644 --- a/Documentation/API.md +++ b/Documentation/API.md @@ -93,7 +93,9 @@ Full artist profile for the bio page and 3D gallery entry. "portrait_path": "portraits/Claude_Monet.jpg", "bio_short": "First two sentences from Wikipedia…", "bio_full": "Full Wikipedia lead section…", - "wikipedia_title": "Claude Monet" + "wikipedia_title": "Claude Monet", + "checkup_checked": false, + "checkup_fixed": false }, "periods": [ { "id": 1, "name": "Milan Period", "start_year": 1482, "end_year": 1499, ... } ], "paintings": [ { "id": 10, "title": "...", "year": 1498, "image_path": "...", "thumbnail_path": "...", "wikipedia_title": "...", "has_influence_links": true, "checkup_checked": false, "checkup_fixed": false, ... } ] @@ -110,6 +112,102 @@ Each painting includes: Populate biographies with `npm run fetch-artist-bios` (see [data-and-images.md](data-and-images.md)). +Artist objects also include `checkup_checked` and `checkup_fixed` (same semantics as paintings; gold portrait border when reviewed). Run `npm run migrate:artist-checkup-flags` on existing databases. + +--- + +## `PATCH /api/artists/:id/checkup-flags` + +Update artist portrait review flags. Body: `{ "checked"?: boolean, "fixed"?: boolean }` — at least one field required. + +Same rules as painting checkup flags: setting `fixed: true` also sets `checked: true`. + +**Response** + +```json +{ "checked": true, "fixed": false } +``` + +--- + +## `GET /api/artists/:id/debug-portrait-search` + +Portrait image search for debug mode on the artist bio page (Custom Search → Google Arts & Culture → scrape → DuckDuckGo). + +**Response** — same shape as painting debug search (`query`, `imageUrl`, `searchUrl`, `source`, optional `thumbUrl`, `sourceLabel`). + +--- + +## `GET /api/artists/:id/debug-portrait-search/more` + +Up to 20 ranked portrait candidates for the **More** picker modal. + +**Query:** `limit` (int, default 20, max 20) + +**Response** + +```json +{ + "query": "Leonardo da Vinci portrait", + "searchUrl": "https://…", + "source": "google-custom-search", + "results": [ + { "imageUrl": "https://…", "thumbUrl": "https://…", "source": "google-custom-search" } + ] +} +``` + +--- + +## `POST /api/artists/:id/fix-portrait` + +Download a remote URL and replace the artist’s local portrait. Sets `checkup_fixed = true` and `checkup_checked = true`. + +**Body** — same as `POST /api/paintings/:id/fix-image` (`imageUrl` required; optional `searchUrl`, `source`, `thumbUrl`). + +**Response** + +```json +{ + "portraitPath": "portraits/Leonardo_da_Vinci.jpg", + "fixed": true, + "checked": true +} +``` + +--- + +## `POST /api/artists/:id/clear-portrait` + +Delete the portrait file from disk, set `portrait_path = NULL`, and set both checkup flags. Used by debug **Clear**; the bio page shows an empty portrait slot (no placeholder). + +**Response** + +```json +{ + "portraitPath": null, + "fixed": true, + "checked": true +} +``` + +--- + +## `POST /api/artists/:id/upload-portrait` + +Upload a local image (base64 JSON body). Validates with `sharp`, resizes to portrait dimensions, sets checkup flags. + +**Body** + +```json +{ + "imageData": "", + "mimeType": "image/jpeg" +} +``` + +Max size 15 MB. **Response** — same as `fix-portrait`. + --- ## `GET /api/artists/:id/navigation` @@ -212,7 +310,7 @@ 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. +Routes for the **Checkup** page and **Debug mode** on painting detail and artist bio. Register `GET /api/paintings/checkup` **before** `GET /api/paintings/:id` so `"checkup"` is not parsed as a painting id. ### `GET /api/paintings/checkup` @@ -315,6 +413,61 @@ Only `imageUrl` is required; optional fields improve fetch success for hotlinked --- +### `GET /api/paintings/:id/debug-image-search/more` + +Up to 20 ranked painting image candidates for the **More** picker modal. + +**Query:** `limit` (int, default 20, max 20) + +**Response** + +```json +{ + "query": "Andrei Rublev Trinity painting", + "searchUrl": "https://…", + "source": "google-arts", + "results": [ + { "imageUrl": "https://…", "thumbUrl": "https://…", "source": "google-arts" } + ] +} +``` + +--- + +### `POST /api/paintings/:id/clear-image` + +Delete full + thumbnail files from disk, set `image_path` and `thumbnail_path` to `NULL`, and set both checkup flags. Used by debug **Clear**; detail view shows an empty frame (no placeholder, no on-demand refetch). + +**Response** + +```json +{ + "imagePath": null, + "thumbnailPath": null, + "fixed": true, + "checked": true +} +``` + +--- + +### `POST /api/paintings/:id/upload-image` + +Upload a local painting image (base64 JSON body). Validates with `sharp`, writes full file, regenerates thumbnail, sets checkup flags. + +**Body** + +```json +{ + "imageData": "", + "mimeType": "image/jpeg" +} +``` + +Max size 15 MB. **Response** — same shape as `fix-image` (`imagePath`, `thumbnailPath`, `fixed`, `checked`). + +--- + ### `GET /api/debug/image-proxy` Proxy a remote image URL for debug preview (avoids hotlink / CORS blocks in the browser). @@ -341,9 +494,19 @@ The React client wraps these endpoints in `client/src/api/client.ts`: | `imageUrl(path)` | `/images/` or placeholder | | `galleryImageUrl(painting)` | Local thumb/full only (3D) | | `galleryImageUrlWithRevision(painting, revision)` | Local URL with `?v=` cache buster after fix | -| `paintingImageUrl(painting)` | Local file or on-demand API | +| `paintingImageUrl(painting)` | Local file, on-demand API, or `null` when cleared (`checkup_fixed` + no paths) | +| `portraitUrl(path, revision?)` | `/images/` with optional `?v=` cache buster | | `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.getPaintingDebugImageSearchMore(id, limit?)` | `GET /api/paintings/:id/debug-image-search/more` | | `api.fixPaintingImage(id, imageUrl, context?)` | `POST /api/paintings/:id/fix-image` | +| `api.clearPaintingImage(id)` | `POST /api/paintings/:id/clear-image` | +| `api.uploadPaintingImage(id, file)` | `POST /api/paintings/:id/upload-image` | +| `api.updateArtistCheckupFlags(id, flags)` | `PATCH /api/artists/:id/checkup-flags` | +| `api.getArtistDebugPortraitSearch(id)` | `GET /api/artists/:id/debug-portrait-search` | +| `api.getArtistDebugPortraitSearchMore(id, limit?)` | `GET /api/artists/:id/debug-portrait-search/more` | +| `api.fixArtistPortrait(id, imageUrl, context?)` | `POST /api/artists/:id/fix-portrait` | +| `api.clearArtistPortrait(id)` | `POST /api/artists/:id/clear-portrait` | +| `api.uploadArtistPortrait(id, file)` | `POST /api/artists/:id/upload-portrait` | | `debugImageProxyUrl(imageUrl, context?)` | `GET /api/debug/image-proxy?url=…` | diff --git a/Documentation/DB_structure.md b/Documentation/DB_structure.md index 3a99952..15befb4 100644 --- a/Documentation/DB_structure.md +++ b/Documentation/DB_structure.md @@ -66,10 +66,14 @@ Finer-grained styles (Impressionism, Cubism, Suprematism, …). | `name` | VARCHAR(200) | | | `birth_year`, `death_year` | INTEGER | Nullable; used for timeline portrait placement | | `movement_id` | FK → `art_movements` | Primary movement | -| `portrait_path` | VARCHAR(500) | Relative to `data/images/` | +| `portrait_path` | VARCHAR(500) | Relative to `data/images/`; nullable after debug **Clear** | | `bio_short`, `bio_full` | TEXT | Wikipedia lead section (`npm run fetch-artist-bios`) | | `wikipedia_title` | VARCHAR(300) | Source page title | | `century` | INTEGER | Rounded century bucket for seeding limits | +| `checkup_checked` | BOOLEAN NOT NULL DEFAULT false | Portrait reviewed in debug workflow (gold border on bio when true) | +| `checkup_fixed` | BOOLEAN NOT NULL DEFAULT false | Portrait replaced, cleared, or uploaded via debug | + +Applied by `npm run migrate:artist-checkup-flags` (`db/migrate-artist-checkup-flags.sql`). ### `artist_periods` @@ -94,14 +98,14 @@ Phases within an artist’s career (e.g. “Blue Period”, “Roman Period”). | `title` | VARCHAR(300) | | | `year`, `year_end` | INTEGER | Creation date(s) | | `description` | TEXT | | -| `image_path` | VARCHAR(500) | Full-size local file | -| `thumbnail_path` | VARCHAR(500) | Smaller variant for lists / 3D | +| `image_path` | VARCHAR(500) | Full-size local file; nullable after debug **Clear** | +| `thumbnail_path` | VARCHAR(500) | Smaller variant for lists / 3D; nullable after **Clear** | | `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** | +| `checkup_fixed` | BOOLEAN NOT NULL DEFAULT false | Image corrected, cleared, or uploaded via checkup / debug | -When `checkup_fixed` is true, `checkup_checked` is set automatically and cannot be cleared until **Fixed** is off. +When `checkup_fixed` is true, `checkup_checked` is set automatically and cannot be cleared until **Fixed** is off. A cleared painting (`image_path` and `thumbnail_path` both null, `checkup_fixed` true) is shown as an empty frame in detail view and is not refetched on demand. Applied by `npm run migrate:checkup-flags` (`db/migrate-checkup-flags.sql`). @@ -157,6 +161,7 @@ Used by painting detail API (`influencedBy`). Painting-type rows also feed `has_ - `artists(movement_id)`, `artists(century)` - `paintings(artist_id)`, `paintings(period_id)`, `paintings(checkup_checked)`, `paintings(checkup_fixed)` +- `artists(checkup_checked)`, `artists(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 9b5ff46..6f87275 100644 --- a/Documentation/basics.md +++ b/Documentation/basics.md @@ -10,7 +10,7 @@ The app is organised as a **drill-down hierarchy**: 2. **Movement flow** — art movements as curved SVG streams on the same year axis; documented predecessor→successor branches; portrait thumbnails placed along each stream. 3. **3D gallery** — one personal hall per artist; paintings on the walls, open centre, single exit for influence-based navigation. 4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), prev/next catalog browsing, optional fullscreen, link to artist biography. -5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`). +5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`). With debug mode on, the same image-audit panel as painting detail (portrait search, **Checked** / **Fix it** / **More** / **Clear** / **Upload**). All artwork images are stored locally under `data/images/` — the UI never hot-links to Wikipedia or Commons at runtime (except optional on-demand fetch when a file is missing). @@ -33,6 +33,8 @@ Gallery/ │ ├── src/ # Source (components, pages, 3D scene) │ │ ├── components/VirtualGallery.tsx # 3D hall (parquet, frames, museum exit) │ │ ├── components/PaintingDetail.tsx # Detail view + debug panel +│ │ ├── components/ArtistBio.tsx # Biography + portrait debug panel +│ │ ├── components/DebugSearchResultsModal.tsx # “More” search picker (20 results) │ │ ├── components/MovementBands.tsx # Movement flow (SVG streams + branches) │ │ ├── pages/CheckupPage.tsx # Image audit table │ │ ├── data/historical-events.ts # Timeline event markers (UI) @@ -225,7 +227,7 @@ Opened from the 3D hall (click a frame) or from influence thumbnails on another ### Debug mode (developer) -When **Debug mode** is enabled from the home header, painting detail shows a bottom-left panel with image search preview and **Checked** / **Fix it** buttons. See [Developer tools (image audit)](#developer-tools-image-audit). +When **Debug mode** is enabled from the home header, painting detail and artist biography show a bottom-left panel with image search preview and five action buttons. See [Developer tools (image audit)](#developer-tools-image-audit). ## Key design decisions @@ -243,20 +245,23 @@ Optional workflow for curating local image files — not part of the public visi | Feature | Where | Purpose | |---------|--------|---------| -| **Debug mode** | Home header toggle (`client/src/utils/debugMode.ts`) | Persists in `localStorage`; enables debug panel on painting detail | +| **Debug mode** | Home header toggle (`client/src/utils/debugMode.ts`) | Persists in `localStorage`; enables debug panel on painting detail and artist bio | | **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) | Search preview + two action buttons | +| **Debug panel** | Painting detail or artist bio (bottom-left, when debug mode on) | Search preview + five action buttons | -### Debug panel (painting detail) +### Debug panel (painting detail and artist bio) -When debug mode is on, a panel at the bottom-left shows the image search query, a preview when a result is found, and **two buttons**: +When debug mode is on, a panel at the bottom-left shows the image search query, a preview when a result is found, and **five buttons** in two rows: | Button | Action | |--------|--------| -| **Checked** | Sets `checkup_checked` via `PATCH /api/paintings/:id/checkup-flags` (disabled once already reviewed) | -| **Fix it** | Replaces local full + thumbnail from the search result via `POST /api/paintings/:id/fix-image`; sets **Fixed** and **Reviewed** | +| **Checked** | Sets `checkup_checked` via `PATCH …/checkup-flags` (disabled once already reviewed) | +| **Fix it** | Replaces the local image from the top search result (`POST …/fix-image` or `…/fix-portrait`); sets **Fixed** and **Reviewed** | +| **More** | Opens a modal with up to **20** search results; click one to apply the same replace as **Fix it** | +| **Clear** | Deletes the local file(s), clears DB paths, leaves an **empty frame** (no placeholder); sets **Fixed** and **Reviewed** so on-demand fetch does not refill the slot | +| **Upload** | File picker for a local image; saves to disk like **Fix it** (thumbnail generated for paintings; portrait resized for artists) | -After **Fix it**, the detail image, gallery textures, and frame colour (gold if reviewed) update without a full page reload. **Back to Gallery** returns to the live hall session, not a stale snapshot. +After **Fix it**, **More**, **Upload**, or **Clear**, the main view, gallery textures (paintings), and timeline portrait (artists) update without a full page reload. Reviewed portraits show a gold border on the bio page; reviewed paintings use gold frames in the 3D hall. **Back to Gallery** returns to the live hall session, not a stale snapshot. ### Checkup page @@ -264,7 +269,7 @@ After **Fix it**, the detail image, gallery textures, and frame colour (gold if **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. +Run `npm run migrate:checkup-flags` and `npm run migrate:artist-checkup-flags` once on existing databases. After server code changes, restart `npm run dev` so new routes (e.g. clear, upload, portrait debug) are registered. See [API.md](API.md#developer-image-audit) and [data-and-images.md](data-and-images.md#duplicate-paintings). diff --git a/Documentation/data-and-images.md b/Documentation/data-and-images.md index 32d739c..043c36a 100644 --- a/Documentation/data-and-images.md +++ b/Documentation/data-and-images.md @@ -336,19 +336,25 @@ As of a recent audit (~1200 paintings): **52 exact duplicate pairs** (52 removab 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 `downloadImageForFix` → `replacePaintingImageFromUrl` in `server/image-service.js`, regenerates the thumbnail with `sharp`, and sets `checkup_fixed` + `checkup_checked`. +1. **Search** — `GET /api/paintings/:id/debug-image-search` (or `…/debug-portrait-search` for artists) 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` / `searchArtistPortraitFirst` in `scripts/image-fetcher.js`). +2. **More** — `GET …/debug-image-search/more` or `…/debug-portrait-search/more` returns up to 20 ranked candidates (`searchPaintingImagesMany` / `searchArtistPortraitMany`). +3. **Fix** — `POST …/fix-image` or `…/fix-portrait` downloads the chosen URL via `downloadImageForFix` → `replacePaintingImageFromUrl` / `replaceArtistPortraitFromUrl` in `server/image-service.js`, regenerates thumbnails with `sharp`, and sets `checkup_fixed` + `checkup_checked`. +4. **Clear** — `POST …/clear-image` or `…/clear-portrait` deletes local file(s), nulls DB paths, sets both flags. Cleared slots stay empty in the UI (no placeholder; `checkup_fixed` prevents on-demand refetch for paintings). +5. **Upload** — `POST …/upload-image` or `…/upload-portrait` accepts a base64-encoded file (max 15 MB), validates with `sharp`, writes to the standard filename under `data/images/`. -### Painting detail debug panel +### Debug panel (painting detail and artist bio) -With debug mode on, `PaintingDetail.tsx` shows a bottom-left panel with search preview and two buttons: +With debug mode on, `PaintingDetail.tsx` and `ArtistBio.tsx` show a bottom-left panel with search preview and five buttons: -| Button | API | Effect | -|--------|-----|--------| -| **Checked** | `PATCH …/checkup-flags` `{ "checked": true }` | Marks reviewed; 3D frame turns gold | -| **Fix it** | `POST …/fix-image` | Saves image to disk, sets both flags, refreshes detail + gallery | +| Button | API (paintings / portraits) | Effect | +|--------|----------------------------|--------| +| **Checked** | `PATCH …/checkup-flags` `{ "checked": true }` | Marks reviewed; gold frame (paintings) or gold portrait border (artists) | +| **Fix it** | `POST …/fix-image` / `…/fix-portrait` | Saves top search result to disk, sets both flags, refreshes detail + gallery / timeline | +| **More** | `GET …/debug-*-search/more` then fix endpoint | Modal with 20 clickable results; pick one to replace | +| **Clear** | `POST …/clear-image` / `…/clear-portrait` | Removes file(s), empty frame in UI | +| **Upload** | `POST …/upload-image` / `…/upload-portrait` | Local file picker → save like **Fix it** | -The client passes `searchUrl`, `source`, and `thumbUrl` from the search result to improve download reliability. After a fix, `HomePage` updates the gallery session and appends a revision query on 3D texture URLs so replaced files reload even when the path is unchanged. +The client passes `searchUrl`, `source`, and `thumbUrl` from search results to improve download reliability. After a fix, clear, or upload, `HomePage` updates the gallery session and appends a revision query on texture URLs so replaced files reload even when the path is unchanged. Checkup **Search visible** queues search for filtered rows only (3 concurrent); it does not search the full catalog on load. diff --git a/Documentation/setup.md b/Documentation/setup.md index 45d0304..39a9f9c 100644 --- a/Documentation/setup.md +++ b/Documentation/setup.md @@ -65,7 +65,8 @@ 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 migrate:checkup-flags # optional: review/fixed flags for Checkup page (paintings) +npm run migrate:artist-checkup-flags # optional: same flags for artist portraits (bio debug) 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 .. @@ -114,6 +115,7 @@ Open http://localhost:3001 (or your configured `PORT`). | `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 migrate:artist-checkup-flags` | `scripts/migrate-artist-checkup-flags.js` | Add `checkup_checked` / `checkup_fixed` on `artists` (bio debug) | | `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 | @@ -131,7 +133,8 @@ These are checked in and maintained: - `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 +- `migrate-checkup-flags.js` — checkup workflow columns (paintings) +- `migrate-artist-checkup-flags.js` — checkup workflow columns (artist portraits) - `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`. @@ -168,6 +171,7 @@ After clone: copy `.env.example` → `.env`, install dependencies, run `npm run | 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 | +| Debug **More** / **Clear** / **Upload** returns 404 | Same as above | Restart server; routes live in `server/index.js` + `server/image-service.js` | | **Fix it** fails with `read ECONNRESET` | Remote host dropped connection | Restart server; client sends `searchUrl` / `source`; retry or use Commons URL in overrides | | Fixed image not shown in 3D gallery | Stale gallery session or cached texture | Rebuild client; fix updates session + `?v=` revision — use **Back to Gallery** (not browser back) | | Frame still black after **Checked** | Gallery session not synced | Re-enter hall or toggle debug **Checked** from detail with gallery open behind overlay | diff --git a/README.md b/README.md index 93cf122..86602e8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Art Gallery -Interactive virtual art gallery: zoomable historical timeline with era click-to-zoom and major event markers, branching art-movement flow (curved streams, hover-to-reveal artist lifespans), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with prev/next catalog browsing and fullscreen lightbox, debug-mode image audit (**Checked** / **Fix it**), Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies. +Interactive virtual art gallery: zoomable historical timeline with era click-to-zoom and major event markers, branching art-movement flow (curved streams, hover-to-reveal artist lifespans), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with prev/next catalog browsing and fullscreen lightbox, debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**), Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies. ## Documentation @@ -40,7 +40,8 @@ Interactive virtual art gallery: zoomable historical timeline with era click-to- npm run fetch-artist-bios # Wikipedia biographies for all artists npm run expand-catalog # add famous works for artists with thin catalogs npm run update-influences # art-history lineage links between paintings - npm run migrate:checkup-flags # review/fixed flags for Checkup + debug mode + npm run migrate:checkup-flags # review/fixed flags for Checkup + debug mode (paintings) + npm run migrate:artist-checkup-flags # same flags for artist portraits (bio debug) npm run fetch-images -- --limit=50 # random batch of missing images (10s per work) npm run fetch-images -- --artist="Claude Monet" # one artist in catalog order ``` diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 9c0909c..1f10c50 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -20,6 +20,12 @@ export function imageUrl(path: string | null | undefined): string { return `/images/${path}`; } +export function portraitUrl(path: string | null | undefined, revision?: number): string { + const base = imageUrl(path); + if (!revision || base.startsWith('/placeholder')) return base; + return `${base}${base.includes('?') ? '&' : '?'}v=${revision}`; +} + /** Image for 3D gallery — local cached files only (API fetch is too slow for realtime 3D) */ export function galleryImageUrl(painting: { thumbnail_path?: string | null; @@ -47,12 +53,47 @@ export function paintingImageUrl(painting: { id: number; image_path?: string | null; thumbnail_path?: string | null; -}): string { + checkup_fixed?: boolean; +}): string | null { if (painting.image_path) return `/images/${painting.image_path}`; if (painting.thumbnail_path) return `/images/${painting.thumbnail_path}`; + if (painting.checkup_fixed) return null; return `/api/paintings/${painting.id}/image?size=full`; } +async function fileToBase64Payload(file: File): Promise<{ imageData: string; mimeType: string }> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result; + if (typeof result !== 'string') { + reject(new Error('Could not read file')); + return; + } + const comma = result.indexOf(','); + resolve({ + imageData: comma >= 0 ? result.slice(comma + 1) : result, + mimeType: file.type || 'image/jpeg', + }); + }; + reader.onerror = () => reject(new Error('Could not read file')); + reader.readAsDataURL(file); + }); +} + +async function postJsonImageAction(url: string, payload: { imageData: string; mimeType: string }): Promise { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Request failed: ${res.status}`); + } + return res.json() as Promise; +} + export async function preloadArtistImages(artistId: number): Promise<{ fetched: number; total: number }> { const res = await fetch(`${API}/artists/${artistId}/preload-images`, { method: 'POST' }); if (!res.ok) throw new Error('Preload failed'); @@ -60,8 +101,14 @@ export async function preloadArtistImages(artistId: number): Promise<{ fetched: } export interface FixPaintingImageResult { - imagePath: string; - thumbnailPath: string; + imagePath: string | null; + thumbnailPath: string | null; + fixed?: boolean; + checked?: boolean; +} + +export interface FixArtistPortraitResult { + portraitPath: string | null; fixed?: boolean; checked?: boolean; } @@ -75,6 +122,20 @@ export interface DebugImageSearchResult { thumbUrl?: string; } +export interface DebugImageSearchResultItem { + imageUrl: string; + thumbUrl?: string; + source: string; +} + +export interface DebugImageSearchManyResult { + query: string; + searchUrl: string; + source: string; + sourceLabel?: string; + results: DebugImageSearchResultItem[]; +} + export interface PaintingCheckupRow { id: number; title: string; @@ -120,6 +181,9 @@ export const api = { getPaintingDebugImageSearch: (id: number) => fetchJson(`${API}/paintings/${id}/debug-image-search`), + getPaintingDebugImageSearchMore: (id: number, limit = 20) => + fetchJson(`${API}/paintings/${id}/debug-image-search/more?limit=${limit}`), + fixPaintingImage: ( id: number, imageUrl: string, @@ -137,6 +201,20 @@ export const api = { return res.json() as Promise; }), + clearPaintingImage: (id: number) => + fetch(`${API}/paintings/${id}/clear-image`, { method: 'POST' }).then(async (res) => { + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Clear failed: ${res.status}`); + } + return res.json() as Promise; + }), + + uploadPaintingImage: async (id: number, file: File) => { + const payload = await fileToBase64Payload(file); + return postJsonImageAction(`${API}/paintings/${id}/upload-image`, payload); + }, + getPaintingCheckup: () => fetchJson(`${API}/paintings/checkup`), updatePaintingCheckupFlags: ( @@ -154,6 +232,59 @@ export const api = { } return res.json() as Promise<{ checked: boolean; fixed: boolean }>; }), + + getArtistDebugPortraitSearch: (id: number) => + fetchJson(`${API}/artists/${id}/debug-portrait-search`), + + getArtistDebugPortraitSearchMore: (id: number, limit = 20) => + fetchJson(`${API}/artists/${id}/debug-portrait-search/more?limit=${limit}`), + + fixArtistPortrait: ( + id: number, + imageUrl: string, + context?: { searchUrl?: string; source?: string; pageUrl?: string; thumbUrl?: string } + ) => + fetch(`${API}/artists/${id}/fix-portrait`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ imageUrl, ...context }), + }).then(async (res) => { + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Fix failed: ${res.status}`); + } + return res.json() as Promise; + }), + + clearArtistPortrait: (id: number) => + fetch(`${API}/artists/${id}/clear-portrait`, { method: 'POST' }).then(async (res) => { + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Clear failed: ${res.status}`); + } + return res.json() as Promise; + }), + + uploadArtistPortrait: async (id: number, file: File) => { + const payload = await fileToBase64Payload(file); + return postJsonImageAction(`${API}/artists/${id}/upload-portrait`, payload); + }, + + updateArtistCheckupFlags: ( + id: number, + flags: { checked?: boolean; fixed?: boolean } + ) => + fetch(`${API}/artists/${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( diff --git a/client/src/components/ArtistBio.css b/client/src/components/ArtistBio.css index 0d0ffa6..3b1b153 100644 --- a/client/src/components/ArtistBio.css +++ b/client/src/components/ArtistBio.css @@ -58,6 +58,19 @@ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); } +.bio-portrait-checked img { + border-color: #ffd700; + box-shadow: 0 8px 28px rgba(255, 215, 0, 0.25); +} + +.bio-portrait-empty { + width: 240px; + height: 300px; + border: 4px solid rgba(201, 169, 110, 0.35); + border-radius: 4px; + background: transparent; +} + .bio-text { flex: 1; } diff --git a/client/src/components/ArtistBio.tsx b/client/src/components/ArtistBio.tsx index 6bdb539..be276b3 100644 --- a/client/src/components/ArtistBio.tsx +++ b/client/src/components/ArtistBio.tsx @@ -1,14 +1,60 @@ +import { useEffect, useRef, useState, type ChangeEvent } from 'react'; import type { Artist } from '../types'; -import { imageUrl } from '../api/client'; +import { + api, + debugImageProxyUrl, + portraitUrl, + type DebugImageSearchResult, + type DebugImageSearchResultItem, + type FixArtistPortraitResult, +} from '../api/client'; +import DebugSearchResultsModal from './DebugSearchResultsModal'; +import '../components/PaintingDetail.css'; import './ArtistBio.css'; interface Props { artist: Artist & { movement_name?: string }; + debugMode?: boolean; + portraitRevision?: number; onBack: () => void; onEnterGallery: () => void; + onArtistPortraitFixed?: ( + artistId: number, + fixResult: FixArtistPortraitResult + ) => void | Promise; + onArtistCheckupFlagsUpdated?: ( + artistId: number, + flags: { checked: boolean; fixed: boolean } + ) => void | Promise; } -export default function ArtistBio({ artist, onBack, onEnterGallery }: Props) { +export default function ArtistBio({ + artist, + debugMode = false, + portraitRevision = 0, + onBack, + onEnterGallery, + onArtistPortraitFixed, + onArtistCheckupFlagsUpdated, +}: Props) { + const [debugSearch, setDebugSearch] = useState(null); + const [debugLoading, setDebugLoading] = useState(false); + const [debugError, setDebugError] = useState(null); + const [fixing, setFixing] = useState(false); + const [markingChecked, setMarkingChecked] = useState(false); + const [moreOpen, setMoreOpen] = useState(false); + const [moreLoading, setMoreLoading] = useState(false); + const [moreError, setMoreError] = useState(null); + const [moreResults, setMoreResults] = useState> | null>(null); + const [applyingUrl, setApplyingUrl] = useState(null); + const [clearing, setClearing] = useState(false); + const [uploading, setUploading] = useState(false); + const uploadInputRef = useRef(null); + + const portraitCleared = !artist.portrait_path && !!artist.checkup_fixed; + const showPortrait = !!artist.portrait_path || !artist.checkup_fixed; + const portraitSrc = portraitUrl(artist.portrait_path, portraitRevision || undefined); + const lifespan = artist.birth_year && artist.death_year ? `${artist.birth_year} – ${artist.death_year}` @@ -16,6 +62,154 @@ export default function ArtistBio({ artist, onBack, onEnterGallery }: Props) { ? `b. ${artist.birth_year}` : ''; + useEffect(() => { + if (!debugMode) { + setDebugSearch(null); + setDebugError(null); + setMoreOpen(false); + return; + } + + let cancelled = false; + setDebugLoading(true); + setDebugError(null); + setDebugSearch(null); + + api.getArtistDebugPortraitSearch(artist.id) + .then((result) => { + if (!cancelled) setDebugSearch(result); + }) + .catch(() => { + if (!cancelled) setDebugError('Portrait image search failed.'); + }) + .finally(() => { + if (!cancelled) setDebugLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [debugMode, artist.id, artist.name]); + + const applyPortraitUpdate = async (fixResult: FixArtistPortraitResult) => { + if (onArtistPortraitFixed) { + await onArtistPortraitFixed(artist.id, fixResult); + } + }; + + const applyFixFromSearch = async ( + imageUrl: string, + context: { searchUrl: string; source: string; thumbUrl?: string } + ) => { + const fixResult = await api.fixArtistPortrait(artist.id, imageUrl, context); + await applyPortraitUpdate(fixResult); + setDebugSearch((prev) => + prev + ? { ...prev, imageUrl, thumbUrl: context.thumbUrl ?? prev.thumbUrl, source: context.source } + : prev + ); + }; + + const handleFixPortrait = async () => { + if (!debugSearch?.imageUrl || fixing) return; + setFixing(true); + setDebugError(null); + try { + await applyFixFromSearch(debugSearch.imageUrl, { + searchUrl: debugSearch.searchUrl, + source: debugSearch.source, + thumbUrl: debugSearch.thumbUrl, + }); + } catch (err) { + setDebugError(err instanceof Error ? err.message : 'Could not replace portrait.'); + } finally { + setFixing(false); + } + }; + + const handleOpenMore = async () => { + setMoreOpen(true); + setMoreLoading(true); + setMoreError(null); + setMoreResults(null); + try { + const results = await api.getArtistDebugPortraitSearchMore(artist.id); + setMoreResults(results); + } catch { + setMoreError('Could not load search results.'); + } finally { + setMoreLoading(false); + } + }; + + const handleSelectMoreResult = async (item: DebugImageSearchResultItem) => { + if (fixing || applyingUrl) return; + setApplyingUrl(item.imageUrl); + setMoreError(null); + setDebugError(null); + try { + const searchUrl = moreResults?.searchUrl ?? debugSearch?.searchUrl ?? ''; + await applyFixFromSearch(item.imageUrl, { + searchUrl, + source: item.source, + thumbUrl: item.thumbUrl, + }); + setMoreOpen(false); + } catch (err) { + setMoreError(err instanceof Error ? err.message : 'Could not replace portrait.'); + } finally { + setApplyingUrl(null); + } + }; + + const handleMarkChecked = async () => { + if (artist.checkup_checked || markingChecked) return; + setMarkingChecked(true); + setDebugError(null); + try { + const updated = await api.updateArtistCheckupFlags(artist.id, { checked: true }); + await onArtistCheckupFlagsUpdated?.(artist.id, updated); + } catch (err) { + setDebugError(err instanceof Error ? err.message : 'Could not mark as checked.'); + } finally { + setMarkingChecked(false); + } + }; + + const handleClearPortrait = async () => { + if (clearing || fixing || uploading) return; + setClearing(true); + setDebugError(null); + try { + const result = await api.clearArtistPortrait(artist.id); + await applyPortraitUpdate(result); + } catch (err) { + setDebugError(err instanceof Error ? err.message : 'Could not clear portrait.'); + } finally { + setClearing(false); + } + }; + + const handleUploadClick = () => { + uploadInputRef.current?.click(); + }; + + const handleUploadFile = async (e: ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ''; + if (!file || uploading || fixing || clearing) return; + setUploading(true); + setDebugError(null); + try { + const result = await api.uploadArtistPortrait(artist.id, file); + await applyPortraitUpdate(result); + } catch (err) { + setDebugError(err instanceof Error ? err.message : 'Could not upload portrait.'); + } finally { + setUploading(false); + } + }; + return (
@@ -25,14 +219,18 @@ export default function ArtistBio({ artist, onBack, onEnterGallery }: Props) {
-
- {artist.name} { - (e.target as HTMLImageElement).src = '/placeholder-portrait.svg'; - }} - /> +
+ {showPortrait ? ( + {artist.name} { + (e.target as HTMLImageElement).src = '/placeholder-portrait.svg'; + }} + /> + ) : null}
@@ -66,6 +264,95 @@ export default function ArtistBio({ artist, onBack, onEnterGallery }: Props) { )}
+ + {debugMode && ( + + )} + + setMoreOpen(false)} + onSelect={handleSelectMoreResult} + />
); } diff --git a/client/src/components/DebugSearchResultsModal.css b/client/src/components/DebugSearchResultsModal.css new file mode 100644 index 0000000..de2c60b --- /dev/null +++ b/client/src/components/DebugSearchResultsModal.css @@ -0,0 +1,142 @@ +.debug-search-modal-overlay { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(0, 0, 0, 0.72); +} + +.debug-search-modal { + width: min(920px, 100%); + max-height: min(88vh, 900px); + display: flex; + flex-direction: column; + padding: 16px 18px 18px; + border-radius: 10px; + border: 1px solid rgba(232, 160, 64, 0.45); + background: rgba(15, 15, 26, 0.98); + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55); + font-family: ui-monospace, 'Cascadia Code', monospace; + color: #e8d5b5; +} + +.debug-search-modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.debug-search-modal-header h3 { + margin: 0 0 4px; + font-size: 14px; + font-weight: 600; + color: #e8a040; +} + +.debug-search-modal-query { + margin: 0; + font-size: 11px; + line-height: 1.4; + color: rgba(232, 213, 181, 0.75); + word-break: break-word; +} + +.debug-search-modal-close { + flex-shrink: 0; + width: 32px; + height: 32px; + border: 1px solid rgba(201, 169, 110, 0.4); + border-radius: 6px; + background: transparent; + color: #e8d5b5; + font-size: 22px; + line-height: 1; + cursor: pointer; +} + +.debug-search-modal-close:hover { + background: rgba(201, 169, 110, 0.15); +} + +.debug-search-modal-hint, +.debug-search-modal-status { + margin: 0 0 12px; + font-size: 11px; + color: rgba(201, 169, 110, 0.75); +} + +.debug-search-modal-error { + margin: 0 0 12px; + font-size: 11px; + color: #ff8a80; +} + +.debug-search-modal-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 10px; + overflow-y: auto; + padding-right: 4px; + max-height: min(68vh, 720px); +} + +.debug-search-modal-item { + position: relative; + aspect-ratio: 1; + padding: 0; + border: 2px solid rgba(201, 169, 110, 0.35); + border-radius: 6px; + background: #2a1f15; + cursor: pointer; + overflow: hidden; +} + +.debug-search-modal-item:hover:not(:disabled) { + border-color: #e8a040; + box-shadow: 0 0 0 1px rgba(232, 160, 64, 0.35); +} + +.debug-search-modal-item:disabled { + cursor: wait; + opacity: 0.7; +} + +.debug-search-modal-item-busy { + border-color: #ffd700; +} + +.debug-search-modal-item img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.debug-search-modal-item-index { + position: absolute; + top: 4px; + left: 4px; + padding: 2px 6px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.65); + font-size: 10px; + font-weight: 600; + color: #ffd700; +} + +.debug-search-modal-item-busy-label { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.55); + font-size: 11px; + font-weight: 600; + color: #ffd700; +} diff --git a/client/src/components/DebugSearchResultsModal.tsx b/client/src/components/DebugSearchResultsModal.tsx new file mode 100644 index 0000000..e1606e4 --- /dev/null +++ b/client/src/components/DebugSearchResultsModal.tsx @@ -0,0 +1,101 @@ +import { useEffect } from 'react'; +import { debugImageProxyUrl, type DebugImageSearchManyResult, type DebugImageSearchResultItem } from '../api/client'; +import './DebugSearchResultsModal.css'; + +interface Props { + open: boolean; + title: string; + data: DebugImageSearchManyResult | null; + loading: boolean; + error: string | null; + applyingUrl: string | null; + onClose: () => void; + onSelect: (item: DebugImageSearchResultItem) => void; +} + +export default function DebugSearchResultsModal({ + open, + title, + data, + loading, + error, + applyingUrl, + onClose, + onSelect, +}: Props) { + useEffect(() => { + if (!open) return; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [open, onClose]); + + if (!open) return null; + + return ( +
+
e.stopPropagation()}> +
+
+

{title}

+ {data?.query &&

{data.query}

} +
+ +
+ + {loading &&

Loading results…

} + {error &&

{error}

} + + {!loading && !error && data && data.results.length === 0 && ( +

No images found.

+ )} + + {!loading && data && data.results.length > 0 && ( + <> +

Click an image to replace the current one.

+
+ {data.results.map((item, index) => { + const busy = applyingUrl === item.imageUrl; + return ( + + ); + })} +
+ + )} +
+
+ ); +} diff --git a/client/src/components/MovementBands.tsx b/client/src/components/MovementBands.tsx index 6b8c819..6255f25 100644 --- a/client/src/components/MovementBands.tsx +++ b/client/src/components/MovementBands.tsx @@ -1,6 +1,6 @@ import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react'; import type { ArtMovement, Artist } from '../types'; -import { imageUrl } from '../api/client'; +import { portraitUrl } from '../api/client'; import { MOVEMENT_LINEAGE } from '../data/movement-lineage'; import { panTimelineView, zoomTimelineView } from '../utils/timelineView'; import './MovementBands.css'; @@ -8,6 +8,7 @@ import './MovementBands.css'; interface Props { movements: ArtMovement[]; artists: Artist[]; + portraitRevisions?: Record; viewStart: number; viewEnd: number; absoluteMin: number; @@ -325,6 +326,7 @@ function branchTargetOnChild(layout: MovementLayout): { x: number; y: number } { export default function MovementBands({ movements, artists, + portraitRevisions, viewStart, viewEnd, absoluteMin, @@ -770,7 +772,7 @@ export default function MovementBands({ title={`${artist.name} (${birthLabel}–${deathLabel}) · ${layout.movement.name}`} > {artist.name} { (e.target as HTMLImageElement).src = '/placeholder-portrait.svg'; diff --git a/client/src/components/PaintingDetail.css b/client/src/components/PaintingDetail.css index 66ed327..fa326c6 100644 --- a/client/src/components/PaintingDetail.css +++ b/client/src/components/PaintingDetail.css @@ -499,6 +499,84 @@ cursor: wait; } +.debug-more-btn { + flex: 1; + padding: 8px 10px; + border: 1px solid rgba(201, 169, 110, 0.55); + border-radius: 6px; + background: rgba(201, 169, 110, 0.08); + color: #e8d5b5; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.debug-more-btn:hover:not(:disabled) { + background: rgba(201, 169, 110, 0.2); + border-color: #c9a96e; +} + +.debug-more-btn:disabled { + opacity: 0.6; + cursor: wait; +} + +.debug-action-buttons-secondary { + margin-top: 0; +} + +.debug-clear-btn, +.debug-upload-btn { + flex: 1; + padding: 8px 10px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.debug-clear-btn { + border: 1px solid rgba(232, 120, 100, 0.55); + background: rgba(232, 120, 100, 0.1); + color: #e87864; +} + +.debug-clear-btn:hover:not(:disabled) { + background: rgba(232, 120, 100, 0.2); + border-color: #e87864; +} + +.debug-upload-btn { + border: 1px solid rgba(140, 190, 140, 0.55); + background: rgba(140, 190, 140, 0.1); + color: #8cbe8c; +} + +.debug-upload-btn:hover:not(:disabled) { + background: rgba(140, 190, 140, 0.2); + border-color: #8cbe8c; +} + +.debug-clear-btn:disabled, +.debug-upload-btn:disabled { + opacity: 0.6; + cursor: wait; +} + +.painting-frame-empty { + min-height: 280px; + cursor: default; +} + +.painting-frame-empty:hover { + transform: none; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), inset 0 0 0 2px #c9a96e; +} + +.painting-frame-cleared { + background: transparent; +} + .debug-image-status { margin: 0; font-size: 10px; diff --git a/client/src/components/PaintingDetail.tsx b/client/src/components/PaintingDetail.tsx index 9e5c450..a5f0894 100644 --- a/client/src/components/PaintingDetail.tsx +++ b/client/src/components/PaintingDetail.tsx @@ -1,6 +1,7 @@ -import { useEffect, useState, type SyntheticEvent } from 'react'; +import { useEffect, useRef, useState, type ChangeEvent, type SyntheticEvent } from 'react'; import type { InfluenceLink, Painting, PaintingDetail } from '../types'; -import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type FixPaintingImageResult } from '../api/client'; +import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type DebugImageSearchResultItem, type FixPaintingImageResult } from '../api/client'; +import DebugSearchResultsModal from './DebugSearchResultsModal'; import PaintingLightbox from './PaintingLightbox'; import './PaintingDetail.css'; @@ -158,7 +159,7 @@ function InfluenceCard({ title={`View ${inf.title}`} > {inf.title { (e.target as HTMLImageElement).src = '/placeholder-art.svg'; @@ -197,8 +198,20 @@ export default function PaintingDetailView({ const [debugError, setDebugError] = useState(null); const [fixing, setFixing] = useState(false); const [markingChecked, setMarkingChecked] = useState(false); + const [moreOpen, setMoreOpen] = useState(false); + const [moreLoading, setMoreLoading] = useState(false); + const [moreError, setMoreError] = useState(null); + const [moreResults, setMoreResults] = useState> | null>(null); + const [applyingUrl, setApplyingUrl] = useState(null); + const [clearing, setClearing] = useState(false); + const [uploading, setUploading] = useState(false); + const uploadInputRef = useRef(null); - const imageSrc = `${paintingImageUrl(painting)}${paintingImageUrl(painting).includes('?') ? '&' : '?'}v=${imageVersion}`; + const baseImageUrl = paintingImageUrl(painting); + const imageSrc = baseImageUrl + ? `${baseImageUrl}${baseImageUrl.includes('?') ? '&' : '?'}v=${imageVersion}` + : null; + const imageCleared = !baseImageUrl && !!painting.checkup_fixed; const catalogIndex = artistPaintings.findIndex((p) => p.id === painting.id); const previousPainting = catalogIndex > 0 ? artistPaintings[catalogIndex - 1] : null; @@ -211,12 +224,16 @@ export default function PaintingDetailView({ useEffect(() => { setFullscreen(false); setImageVersion(0); + setMoreOpen(false); + setMoreResults(null); + setMoreError(null); }, [painting.id]); useEffect(() => { if (!debugMode) { setDebugSearch(null); setDebugError(null); + setMoreOpen(false); return; } @@ -241,20 +258,36 @@ export default function PaintingDetailView({ }; }, [debugMode, painting.id, painting.title, painting.artist_name]); + const applyImageUpdate = async (fixResult: FixPaintingImageResult) => { + setImageVersion((v) => v + 1); + if (onPaintingImageFixed) { + await onPaintingImageFixed(painting.id, fixResult); + } + }; + + const applyFixFromSearch = async ( + imageUrl: string, + context: { searchUrl: string; source: string; thumbUrl?: string } + ) => { + const fixResult = await api.fixPaintingImage(painting.id, imageUrl, context); + await applyImageUpdate(fixResult); + setDebugSearch((prev) => + prev + ? { ...prev, imageUrl, thumbUrl: context.thumbUrl ?? prev.thumbUrl, source: context.source } + : prev + ); + }; + const handleFixImage = async () => { if (!debugSearch?.imageUrl || fixing) return; setFixing(true); setDebugError(null); try { - const fixResult = await api.fixPaintingImage(painting.id, debugSearch.imageUrl, { + await applyFixFromSearch(debugSearch.imageUrl, { searchUrl: debugSearch.searchUrl, source: debugSearch.source, thumbUrl: debugSearch.thumbUrl, }); - setImageVersion((v) => v + 1); - if (onPaintingImageFixed) { - await onPaintingImageFixed(painting.id, fixResult); - } } catch (err) { setDebugError(err instanceof Error ? err.message : 'Could not replace image.'); } finally { @@ -262,6 +295,41 @@ export default function PaintingDetailView({ } }; + const handleOpenMore = async () => { + setMoreOpen(true); + setMoreLoading(true); + setMoreError(null); + setMoreResults(null); + try { + const results = await api.getPaintingDebugImageSearchMore(painting.id); + setMoreResults(results); + } catch { + setMoreError('Could not load search results.'); + } finally { + setMoreLoading(false); + } + }; + + const handleSelectMoreResult = async (item: DebugImageSearchResultItem) => { + if (fixing || applyingUrl) return; + setApplyingUrl(item.imageUrl); + setMoreError(null); + setDebugError(null); + try { + const searchUrl = moreResults?.searchUrl ?? debugSearch?.searchUrl ?? ''; + await applyFixFromSearch(item.imageUrl, { + searchUrl, + source: item.source, + thumbUrl: item.thumbUrl, + }); + setMoreOpen(false); + } catch (err) { + setMoreError(err instanceof Error ? err.message : 'Could not replace image.'); + } finally { + setApplyingUrl(null); + } + }; + const handleMarkChecked = async () => { if (painting.checkup_checked || markingChecked) return; setMarkingChecked(true); @@ -276,6 +344,40 @@ export default function PaintingDetailView({ } }; + const handleClearImage = async () => { + if (clearing || fixing || uploading) return; + setClearing(true); + setDebugError(null); + try { + const result = await api.clearPaintingImage(painting.id); + await applyImageUpdate(result); + } catch (err) { + setDebugError(err instanceof Error ? err.message : 'Could not clear image.'); + } finally { + setClearing(false); + } + }; + + const handleUploadClick = () => { + uploadInputRef.current?.click(); + }; + + const handleUploadFile = async (e: ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ''; + if (!file || uploading || fixing || clearing) return; + setUploading(true); + setDebugError(null); + try { + const result = await api.uploadPaintingImage(painting.id, file); + await applyImageUpdate(result); + } catch (err) { + setDebugError(err instanceof Error ? err.message : 'Could not upload image.'); + } finally { + setUploading(false); + } + }; + useEffect(() => { if (fullscreen) return; @@ -356,20 +458,26 @@ export default function PaintingDetailView({ )}
setFullscreen(true)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - setFullscreen(true); - } - }} - title="View full screen" - aria-label={`View ${painting.title} full screen`} + className={`painting-frame-large${imageSrc ? ' painting-frame-clickable' : ' painting-frame-empty'}${imageCleared ? ' painting-frame-cleared' : ''}`} + role={imageSrc ? 'button' : undefined} + tabIndex={imageSrc ? 0 : undefined} + onClick={imageSrc ? () => setFullscreen(true) : undefined} + onKeyDown={ + imageSrc + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setFullscreen(true); + } + } + : undefined + } + title={imageSrc ? 'View full screen' : undefined} + aria-label={imageSrc ? `View ${painting.title} full screen` : `${painting.title} (no image)`} > - {painting.title} + {imageSrc ? ( + {painting.title} + ) : null}
{showCatalogNav && ( @@ -452,11 +560,55 @@ export default function PaintingDetailView({ > {fixing ? '…' : 'Fix it'} + +
+
+ + +
)} - {fullscreen && ( + setMoreOpen(false)} + onSelect={handleSelectMoreResult} + /> + + {fullscreen && imageSrc && ( ({ ...prev, [row.id]: (prev[row.id] ?? 0) + 1 })); setRows((list) => list.map((r) => - r.id === row.id + r.id === row.id && updated.imagePath && updated.thumbnailPath ? { ...r, gallery_file: updated.imagePath, diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx index a87f073..e2dd7be 100644 --- a/client/src/pages/HomePage.tsx +++ b/client/src/pages/HomePage.tsx @@ -5,7 +5,7 @@ import VirtualGallery from '../components/VirtualGallery'; import PaintingDetailView from '../components/PaintingDetail'; import ArtistBio from '../components/ArtistBio'; import CheckupPage from '../pages/CheckupPage'; -import { api, type FixPaintingImageResult } from '../api/client'; +import { api, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client'; import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail } from '../types'; import { sortArtistPaintingsChronological } from '../utils/paintingUtils'; import { readDebugMode, writeDebugMode } from '../utils/debugMode'; @@ -29,6 +29,13 @@ function patchPaintingInArtistDetail( }; } +function patchArtistInArtistDetail(detail: ArtistDetail, patch: Partial): ArtistDetail { + return { + ...detail, + artist: { ...detail.artist, ...patch }, + }; +} + export default function HomePage() { const [view, setView] = useState({ type: 'timeline' }); const [gallerySession, setGallerySession] = useState<{ artistId: number; data: ArtistDetail } | null>( @@ -43,6 +50,7 @@ export default function HomePage() { const [error, setError] = useState(null); const [detailArtistPaintings, setDetailArtistPaintings] = useState([]); const [imageRevisions, setImageRevisions] = useState>({}); + const [portraitRevisions, setPortraitRevisions] = useState>({}); const [debugMode, setDebugMode] = useState(readDebugMode); const detailReturnToRef = useRef({ type: 'timeline' }); @@ -103,8 +111,8 @@ export default function HomePage() { const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => { const data = await api.getPainting(paintingId); const patch: Partial = { - image_path: fixResult.imagePath ?? data.painting.image_path, - thumbnail_path: fixResult.thumbnailPath ?? data.painting.thumbnail_path, + image_path: fixResult.imagePath, + thumbnail_path: fixResult.thumbnailPath, checkup_checked: fixResult.checked ?? true, checkup_fixed: fixResult.fixed ?? true, }; @@ -175,6 +183,73 @@ export default function HomePage() { [] ); + const applyArtistPatch = useCallback((artistId: number, patch: Partial) => { + setArtists((list) => list.map((a) => (a.id === artistId ? { ...a, ...patch } : a))); + + setView((current) => { + if (current.type === 'bio' && current.artistId === artistId) { + return { ...current, data: patchArtistInArtistDetail(current.data, patch) }; + } + if (current.type === 'gallery' && current.artistId === artistId) { + return { ...current, data: patchArtistInArtistDetail(current.data, patch) }; + } + if (current.type === 'painting' && current.data.painting.artist_id === artistId) { + return { + ...current, + data: { + ...current.data, + painting: { + ...current.data.painting, + artist_portrait: patch.portrait_path ?? current.data.painting.artist_portrait, + }, + }, + }; + } + return current; + }); + + setGallerySession((session) => + session && session.artistId === artistId + ? { ...session, data: patchArtistInArtistDetail(session.data, patch) } + : session + ); + }, []); + + const handleArtistPortraitFixed = useCallback( + async (artistId: number, fixResult: FixArtistPortraitResult) => { + const data = await api.getArtist(artistId); + const patch: Partial = { + portrait_path: fixResult.portraitPath, + checkup_checked: fixResult.checked ?? true, + checkup_fixed: fixResult.fixed ?? true, + }; + setPortraitRevisions((prev) => ({ ...prev, [artistId]: (prev[artistId] ?? 0) + 1 })); + applyArtistPatch(artistId, patch); + setView((current) => + current.type === 'bio' && current.artistId === artistId + ? { ...current, data: { ...data, artist: { ...data.artist, ...patch } } } + : current + ); + }, + [applyArtistPatch] + ); + + const handleArtistCheckupFlagsUpdated = useCallback( + async (artistId: number, flags: { checked: boolean; fixed: boolean }) => { + const patch: Partial = { + checkup_checked: flags.checked, + checkup_fixed: flags.fixed, + }; + applyArtistPatch(artistId, patch); + setView((current) => + current.type === 'bio' && current.artistId === artistId + ? { ...current, data: patchArtistInArtistDetail(current.data, patch) } + : current + ); + }, + [applyArtistPatch] + ); + const handleArtistClick = async (artistId: number) => { try { const data = await api.getArtist(artistId); @@ -305,10 +380,14 @@ export default function HomePage() {
setView(view.returnTo)} onEnterGallery={() => setView({ type: 'gallery', artistId: view.artistId, data: view.data }) } + onArtistPortraitFixed={handleArtistPortraitFixed} + onArtistCheckupFlagsUpdated={handleArtistCheckupFlagsUpdated} />
)} @@ -328,7 +407,7 @@ export default function HomePage() { type="button" className={`debug-mode-toggle${debugMode ? ' debug-mode-toggle-active' : ''}`} onClick={toggleDebugMode} - title="Toggle developer image audit mode on painting details" + title="Toggle developer image audit mode on painting details and artist bios" > Debug mode{debugMode ? ': ON' : ''} @@ -363,6 +442,7 @@ export default function HomePage() { { - let score = 0; - if (/upload\.wikimedia\.org/i.test(url)) score += 40; - if (/googleusercontent\.com/i.test(url)) score += 30; - if (/\.(jpe?g|png|webp)(\?|$)/i.test(url)) score += 20; - if (/thumb|thumbnail|small|icon|logo|avatar/i.test(url)) score -= 25; - if (/=\s*[sS]\d{2,3}(-c|-rw)?(\||$)/.test(url) || /[?&]w=\d{2,3}(&|$)/.test(url)) score -= 15; - return { url, score }; - }); - scored.sort((a, b) => b.score - a.score); - return scored[0]?.url || unique[0] || null; + return unique + .map((url) => ({ url, score: scoreImageUrl(url) })) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map((item) => item.url); +} + +function pickBestGoogleImageUrl(candidates) { + return rankImageCandidates(candidates, 1)[0] || null; } function extractGoogleImageCandidates(html) { @@ -1346,6 +1354,33 @@ function extractGoogleImageCandidates(html) { return candidates; } +async function searchGoogleCustomSearchImagesMany(query, limit = 20) { + const apiKey = process.env.GOOGLE_CSE_API_KEY; + const cx = process.env.GOOGLE_CSE_CX; + if (!apiKey || !cx) return []; + + const out = []; + for (let start = 1; start <= 91 && out.length < limit; start += 10) { + const num = Math.min(10, limit - out.length); + const url = + `https://www.googleapis.com/customsearch/v1?key=${encodeURIComponent(apiKey)}` + + `&cx=${encodeURIComponent(cx)}&q=${encodeURIComponent(query)}&searchType=image` + + `&num=${num}&start=${start}&safe=active`; + const data = await fetchJson(url); + for (const item of data?.items || []) { + if (!item?.link) continue; + out.push({ + imageUrl: item.link, + thumbUrl: item.image?.thumbnailLink || item.link, + source: 'google-custom-search', + }); + if (out.length >= limit) break; + } + if (!data?.items?.length) break; + } + return out; +} + async function searchGoogleCustomSearchImagesFirst(query) { const apiKey = process.env.GOOGLE_CSE_API_KEY; const cx = process.env.GOOGLE_CSE_CX; @@ -1364,6 +1399,63 @@ async function searchGoogleCustomSearchImagesFirst(query) { }; } +async function searchDuckDuckGoImagesMany(query, limit = 20) { + const html = await fetchHtml( + `https://duckduckgo.com/?q=${encodeURIComponent(query)}&iar=images&iax=images&ia=images`, + { + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }, + } + ); + const vqd = + html.match(/vqd=['"]([^'"]+)['"]/)?.[1] || + html.match(/vqd=([\d-]+)/)?.[1] || + null; + if (!vqd) return []; + + const out = []; + for (let page = 1; page <= 4 && out.length < limit; page++) { + await throttle(); + const jsUrl = `https://duckduckgo.com/i.js?o=json&q=${encodeURIComponent(query)}&l=us-en&vqd=${encodeURIComponent(vqd)}&f=,,,&p=${page}`; + const payload = await new Promise((resolve, reject) => { + https + .get( + jsUrl, + { headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' } }, + (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch (err) { + reject(err); + } + }); + } + ) + .on('error', reject); + }); + + for (const item of payload?.results || []) { + const imageUrl = item?.image || item?.thumbnail; + if (!imageUrl) continue; + out.push({ + imageUrl, + thumbUrl: item?.thumbnail || item?.image, + source: 'duckduckgo-images', + }); + if (out.length >= limit) break; + } + if (!payload?.results?.length) break; + } + return out; +} + async function searchDuckDuckGoImagesFirst(query) { const html = await fetchHtml( `https://duckduckgo.com/?q=${encodeURIComponent(query)}&iar=images&iax=images&ia=images`, @@ -1469,6 +1561,125 @@ async function searchGoogleImagesFirst(artistName, paintingTitle) { return { query, imageUrl: null, searchUrl, source: 'google-images', sourceLabel: 'Google Images' }; } +/** First Google-family portrait result for an artist (developer portrait audit). */ +async function searchArtistPortraitFirst(artistName) { + const query = `${artistName} portrait`.trim(); + const searchUrl = + `https://www.google.com/search?q=${encodeURIComponent(query)}&tbm=isch&hl=en&ijn=0`; + + const custom = await searchGoogleCustomSearchImagesFirst(query).catch(() => null); + if (custom?.imageUrl) { + return { query, searchUrl, ...custom }; + } + + const gac = await getGoogleArtsCultureImages(artistName, artistName).catch(() => null); + if (gac?.fullUrl) { + return { + query, + imageUrl: gac.fullUrl, + thumbUrl: gac.thumbUrl, + searchUrl: `https://artsandculture.google.com/search?q=${encodeURIComponent(query)}`, + source: 'google-arts-culture', + sourceLabel: 'Google Arts & Culture', + }; + } + + try { + const html = await fetchHtml(searchUrl, { + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + Accept: 'text/html,application/xhtml+xml', + 'Accept-Language': 'en-US,en;q=0.9', + }, + }); + const imageUrl = pickBestGoogleImageUrl(extractGoogleImageCandidates(html)); + if (imageUrl) { + return { + query, + imageUrl, + searchUrl, + source: 'google-images', + sourceLabel: 'Google Images', + }; + } + } catch { + // continue to fallback + } + + const ddg = await searchDuckDuckGoImagesFirst(query).catch(() => null); + if (ddg?.imageUrl) { + return { query, searchUrl, ...ddg }; + } + + return { query, imageUrl: null, searchUrl, source: 'google-images', sourceLabel: 'Google Images' }; +} + +const GOOGLE_ISCH_HEADERS = { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + Accept: 'text/html,application/xhtml+xml', + 'Accept-Language': 'en-US,en;q=0.9', +}; + +async function searchDebugImagesMany(query, limit = 20) { + const searchUrl = + `https://www.google.com/search?q=${encodeURIComponent(query)}&tbm=isch&hl=en&ijn=0`; + const merged = []; + const seen = new Set(); + + const push = (item) => { + const url = item?.imageUrl; + if (!url || !isLikelyImageUrl(url) || seen.has(url)) return; + seen.add(url); + merged.push({ + imageUrl: url, + thumbUrl: item.thumbUrl || url, + source: item.source || 'image-search', + }); + }; + + for (const item of await searchGoogleCustomSearchImagesMany(query, limit).catch(() => [])) { + push(item); + } + + if (merged.length < limit) { + try { + const html = await fetchHtml(searchUrl, { headers: GOOGLE_ISCH_HEADERS }); + for (const url of rankImageCandidates(extractGoogleImageCandidates(html), limit - merged.length)) { + push({ imageUrl: url, source: 'google-images' }); + } + } catch { + // continue + } + } + + if (merged.length < limit) { + for (const item of await searchDuckDuckGoImagesMany(query, limit - merged.length).catch(() => [])) { + push(item); + } + } + + return { + query, + searchUrl, + results: merged.slice(0, limit), + source: 'mixed', + sourceLabel: 'Image search results', + }; +} + +async function searchPaintingImagesMany(artistName, paintingTitle, limit = 20) { + const simple = simplifyPaintingTitle(paintingTitle); + const query = `${artistName} ${simple} painting`.trim(); + return searchDebugImagesMany(query, limit); +} + +async function searchArtistPortraitMany(artistName, limit = 20) { + const query = `${artistName} portrait`.trim(); + return searchDebugImagesMany(query, limit); +} + async function generateThumbnailFromFull(fullDest, thumbDest, width = THUMB_WIDTH) { const sharp = require('sharp'); if (!fs.existsSync(fullDest)) return false; @@ -1601,6 +1812,9 @@ module.exports = { searchWikipediaTitle, searchWebForPaintingImages, searchGoogleImagesFirst, + searchArtistPortraitFirst, + searchPaintingImagesMany, + searchArtistPortraitMany, fetchImageBuffer, friendlyImageFetchError, pickExt, diff --git a/scripts/migrate-artist-checkup-flags.js b/scripts/migrate-artist-checkup-flags.js new file mode 100644 index 0000000..831334e --- /dev/null +++ b/scripts/migrate-artist-checkup-flags.js @@ -0,0 +1,25 @@ +require('dotenv').config(); +const fs = require('fs'); +const path = require('path'); +const pool = require('../server/db'); + +async function main() { + const sqlPath = path.join(__dirname, '../db/migrate-artist-checkup-flags.sql'); + await pool.query(fs.readFileSync(sqlPath, 'utf8')); + await pool.query( + `UPDATE artists SET checkup_checked = true WHERE checkup_fixed = true AND NOT checkup_checked` + ); + const { rows } = await pool.query(` + SELECT + COUNT(*) FILTER (WHERE checkup_checked)::int AS checked, + COUNT(*) FILTER (WHERE checkup_fixed)::int AS fixed + FROM artists + `); + console.log(`artist checkup flags ready (${rows[0].checked} checked, ${rows[0].fixed} fixed)`); + await pool.end(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/server/image-service.js b/server/image-service.js index 108adb7..c960dc3 100644 --- a/server/image-service.js +++ b/server/image-service.js @@ -16,6 +16,10 @@ function safePaintingBase(artistName, title) { return `${artistName}_${title}`.replace(/[^a-zA-Z0-9_-]/g, '_'); } +function safeArtistPortraitBase(artistName) { + return artistName.replace(/[^a-zA-Z0-9_-]/g, '_'); +} + function unlinkIfExists(absPath) { if (absPath && fs.existsSync(absPath)) { try { @@ -154,6 +158,183 @@ async function ensurePaintingImages(paintingId, size = 'thumb') { return promise; } +function pickExtFromMime(mimeType) { + const map = { + 'image/jpeg': '.jpg', + 'image/jpg': '.jpg', + 'image/png': '.png', + 'image/webp': '.webp', + 'image/gif': '.gif', + }; + return map[String(mimeType || '').toLowerCase()] || '.jpg'; +} + +function unlinkPaintingFiles(row, safeBase) { + unlinkIfExists(row.image_path ? path.join(IMAGE_DIR, row.image_path) : null); + unlinkIfExists(row.thumbnail_path ? path.join(IMAGE_DIR, row.thumbnail_path) : null); + const paintingsDir = path.join(IMAGE_DIR, 'paintings'); + const thumbsDir = path.join(IMAGE_DIR, 'paintings', 'thumbs'); + for (const ext of ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.JPG']) { + unlinkIfExists(path.join(paintingsDir, safeBase + ext)); + unlinkIfExists(path.join(thumbsDir, safeBase + '_thumb' + ext)); + unlinkIfExists(path.join(thumbsDir, safeBase + '_thumb.jpg')); + } +} + +function unlinkPortraitFiles(row, safeBase) { + unlinkIfExists(row.portrait_path ? path.join(IMAGE_DIR, row.portrait_path) : null); + const portraitsDir = path.join(IMAGE_DIR, 'portraits'); + for (const ext of ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.JPG']) { + unlinkIfExists(path.join(portraitsDir, safeBase + ext)); + } +} + +async function clearPaintingImage(paintingId) { + const result = await pool.query( + `SELECT p.id, p.title, p.image_path, p.thumbnail_path, a.name AS artist_name + FROM paintings p + JOIN artists a ON a.id = p.artist_id + WHERE p.id = $1`, + [paintingId] + ); + if (result.rows.length === 0) { + throw new Error('Painting not found'); + } + + const row = result.rows[0]; + const safeBase = safePaintingBase(row.artist_name, row.title); + unlinkPaintingFiles(row, safeBase); + + await pool.query( + `UPDATE paintings SET image_path = NULL, thumbnail_path = NULL WHERE id = $1`, + [paintingId] + ); + + return { imagePath: null, thumbnailPath: null }; +} + +async function clearArtistPortrait(artistId) { + const result = await pool.query( + `SELECT id, name, portrait_path FROM artists WHERE id = $1`, + [artistId] + ); + if (result.rows.length === 0) { + throw new Error('Artist not found'); + } + + const row = result.rows[0]; + const safeBase = safeArtistPortraitBase(row.name); + unlinkPortraitFiles(row, safeBase); + + await pool.query(`UPDATE artists SET portrait_path = NULL WHERE id = $1`, [artistId]); + + return { portraitPath: null }; +} + +async function replacePaintingImageFromBuffer(paintingId, buffer, mimeType) { + if (!buffer?.length) { + throw new Error('Empty image data'); + } + + const sharp = require('sharp'); + try { + await sharp(buffer).metadata(); + } catch { + throw new Error('Invalid image file'); + } + + const result = await pool.query( + `SELECT p.id, p.title, p.image_path, p.thumbnail_path, a.name AS artist_name + FROM paintings p + JOIN artists a ON a.id = p.artist_id + WHERE p.id = $1`, + [paintingId] + ); + if (result.rows.length === 0) { + throw new Error('Painting not found'); + } + + const row = result.rows[0]; + const safeBase = safePaintingBase(row.artist_name, row.title); + const paintingsDir = path.join(IMAGE_DIR, 'paintings'); + const thumbsDir = path.join(IMAGE_DIR, 'paintings', 'thumbs'); + if (!fs.existsSync(paintingsDir)) fs.mkdirSync(paintingsDir, { recursive: true }); + if (!fs.existsSync(thumbsDir)) fs.mkdirSync(thumbsDir, { recursive: true }); + + const fullExt = pickExtFromMime(mimeType); + const fullDest = path.join(paintingsDir, safeBase + fullExt); + const thumbDest = path.join(thumbsDir, safeBase + '_thumb.jpg'); + + unlinkPaintingFiles(row, safeBase); + fs.writeFileSync(fullDest, buffer); + + let thumbnailPath = null; + try { + await generateThumbnailFromFull(fullDest, thumbDest); + thumbnailPath = path.join('paintings', 'thumbs', safeBase + '_thumb.jpg').replace(/\\/g, '/'); + } catch { + thumbnailPath = path.join('paintings', safeBase + fullExt).replace(/\\/g, '/'); + } + + const imagePath = path.join('paintings', safeBase + fullExt).replace(/\\/g, '/'); + + await pool.query( + `UPDATE paintings SET image_path = $1, thumbnail_path = $2 WHERE id = $3`, + [imagePath, thumbnailPath, paintingId] + ); + + return { imagePath, thumbnailPath }; +} + +async function replaceArtistPortraitFromBuffer(artistId, buffer, mimeType) { + if (!buffer?.length) { + throw new Error('Empty image data'); + } + + const sharp = require('sharp'); + try { + await sharp(buffer).metadata(); + } catch { + throw new Error('Invalid image file'); + } + + const result = await pool.query( + `SELECT id, name, portrait_path FROM artists WHERE id = $1`, + [artistId] + ); + if (result.rows.length === 0) { + throw new Error('Artist not found'); + } + + const row = result.rows[0]; + const safeBase = safeArtistPortraitBase(row.name); + const portraitsDir = path.join(IMAGE_DIR, 'portraits'); + if (!fs.existsSync(portraitsDir)) fs.mkdirSync(portraitsDir, { recursive: true }); + + unlinkPortraitFiles(row, safeBase); + + const jpgDest = path.join(portraitsDir, safeBase + '.jpg'); + try { + await sharp(buffer) + .rotate() + .resize({ width: 900, height: 1100, fit: 'inside', withoutEnlargement: true }) + .jpeg({ quality: 88 }) + .toFile(jpgDest); + } catch { + const fullExt = pickExtFromMime(mimeType); + const fullDest = path.join(portraitsDir, safeBase + fullExt); + fs.writeFileSync(fullDest, buffer); + const portraitPath = path.join('portraits', safeBase + fullExt).replace(/\\/g, '/'); + await pool.query(`UPDATE artists SET portrait_path = $1 WHERE id = $2`, [portraitPath, artistId]); + return { portraitPath }; + } + + const portraitPath = path.join('portraits', safeBase + '.jpg').replace(/\\/g, '/'); + await pool.query(`UPDATE artists SET portrait_path = $1 WHERE id = $2`, [portraitPath, artistId]); + + return { portraitPath }; +} + async function replacePaintingImageFromUrl(paintingId, imageUrl, context = {}) { const result = await pool.query( `SELECT p.id, p.title, p.image_path, p.thumbnail_path, a.name AS artist_name @@ -202,9 +383,61 @@ async function replacePaintingImageFromUrl(paintingId, imageUrl, context = {}) { return { imagePath, thumbnailPath }; } +async function replaceArtistPortraitFromUrl(artistId, imageUrl, context = {}) { + const result = await pool.query( + `SELECT id, name, portrait_path FROM artists WHERE id = $1`, + [artistId] + ); + if (result.rows.length === 0) { + throw new Error('Artist not found'); + } + + const row = result.rows[0]; + const safeBase = safeArtistPortraitBase(row.name); + const portraitsDir = path.join(IMAGE_DIR, 'portraits'); + if (!fs.existsSync(portraitsDir)) fs.mkdirSync(portraitsDir, { recursive: true }); + + const fullExt = pickExt(imageUrl); + const fullDest = path.join(portraitsDir, safeBase + fullExt); + + unlinkIfExists(row.portrait_path ? path.join(IMAGE_DIR, row.portrait_path) : null); + for (const ext of ['.jpg', '.jpeg', '.png', '.webp', '.JPG']) { + unlinkIfExists(path.join(portraitsDir, safeBase + ext)); + } + + await downloadImageForFix(imageUrl, fullDest, context); + + let portraitPath = path.join('portraits', safeBase + fullExt).replace(/\\/g, '/'); + const jpgDest = path.join(portraitsDir, safeBase + '.jpg'); + + try { + const sharp = require('sharp'); + await sharp(fullDest) + .rotate() + .resize({ width: 900, height: 1100, fit: 'inside', withoutEnlargement: true }) + .jpeg({ quality: 88 }) + .toFile(jpgDest); + if (fullDest !== jpgDest && fs.existsSync(fullDest)) { + fs.unlinkSync(fullDest); + } + portraitPath = path.join('portraits', safeBase + '.jpg').replace(/\\/g, '/'); + } catch { + // keep downloaded file as-is + } + + await pool.query(`UPDATE artists SET portrait_path = $1 WHERE id = $2`, [portraitPath, artistId]); + + return { portraitPath }; +} + module.exports = { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, + replaceArtistPortraitFromUrl, + clearPaintingImage, + clearArtistPortrait, + replacePaintingImageFromBuffer, + replaceArtistPortraitFromBuffer, IMAGE_DIR, }; diff --git a/server/index.js b/server/index.js index beed53a..ae9850b 100644 --- a/server/index.js +++ b/server/index.js @@ -5,8 +5,8 @@ const fs = require('fs'); require('dotenv').config(); const pool = require('./db'); -const { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, IMAGE_DIR } = require('./image-service'); -const { searchGoogleImagesFirst, fetchImageBuffer, friendlyImageFetchError, pickExt } = require('../scripts/image-fetcher'); +const { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, IMAGE_DIR } = require('./image-service'); +const { searchGoogleImagesFirst, searchArtistPortraitFirst, searchPaintingImagesMany, searchArtistPortraitMany, fetchImageBuffer, friendlyImageFetchError, pickExt } = require('../scripts/image-fetcher'); const app = express(); const PORT = process.env.PORT || 3001; @@ -234,6 +234,173 @@ app.get('/api/artists/:id/navigation', async (req, res) => { } }); +// Update artist portrait checkup flags (checked / fixed) +app.patch('/api/artists/:id/checkup-flags', async (req, res) => { + try { + const artistId = parseInt(req.params.id, 10); + const { checked, fixed } = req.body ?? {}; + + if (checked === undefined && fixed === undefined) { + return res.status(400).json({ error: 'Provide checked and/or fixed boolean' }); + } + if (checked !== undefined && typeof checked !== 'boolean') { + return res.status(400).json({ error: 'checked must be a boolean' }); + } + if (fixed !== undefined && typeof fixed !== 'boolean') { + return res.status(400).json({ error: 'fixed must be a boolean' }); + } + + const current = await pool.query( + `SELECT checkup_checked, checkup_fixed FROM artists WHERE id = $1`, + [artistId] + ); + if (current.rows.length === 0) { + return res.status(404).json({ error: 'Artist not found' }); + } + + const willBeFixed = fixed !== undefined ? fixed : !!current.rows[0].checkup_fixed; + let nextChecked = checked; + if (willBeFixed) { + nextChecked = true; + } + + const sets = []; + const params = []; + if (fixed !== undefined) { + params.push(fixed); + sets.push(`checkup_fixed = $${params.length}`); + } + if (nextChecked !== undefined) { + params.push(nextChecked); + sets.push(`checkup_checked = $${params.length}`); + } + params.push(artistId); + + const result = await pool.query( + `UPDATE artists SET ${sets.join(', ')} + WHERE id = $${params.length} + RETURNING checkup_checked AS checked, checkup_fixed AS fixed`, + params + ); + + res.json({ + checked: !!result.rows[0].checked, + fixed: !!result.rows[0].fixed, + }); + } catch (err) { + console.error('Artist checkup flags error:', err.message); + res.status(500).json({ error: 'Failed to update checkup flags' }); + } +}); + +// Developer debug: portrait image search for artist bio +app.get('/api/artists/:id/debug-portrait-search/more', async (req, res) => { + try { + const artistId = parseInt(req.params.id, 10); + const limit = Math.min(20, Math.max(1, parseInt(req.query.limit, 10) || 20)); + const result = await pool.query(`SELECT name FROM artists WHERE id = $1`, [artistId]); + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Artist not found' }); + } + + const { name } = result.rows[0]; + const search = await searchArtistPortraitMany(name, limit); + res.json(search); + } catch (err) { + console.error('Debug portrait search (more) error:', err.message); + res.status(500).json({ error: 'Portrait search failed' }); + } +}); + +app.get('/api/artists/:id/debug-portrait-search', async (req, res) => { + try { + const artistId = parseInt(req.params.id, 10); + const result = await pool.query(`SELECT name FROM artists WHERE id = $1`, [artistId]); + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Artist not found' }); + } + + const { name } = result.rows[0]; + const search = await searchArtistPortraitFirst(name); + res.json(search); + } catch (err) { + console.error('Debug portrait search error:', err.message); + res.status(500).json({ error: 'Portrait search failed' }); + } +}); + +// Developer debug: replace artist portrait with a search result URL +app.post('/api/artists/:id/fix-portrait', async (req, res) => { + try { + const artistId = parseInt(req.params.id, 10); + const { imageUrl, searchUrl, source, pageUrl, thumbUrl } = req.body ?? {}; + if (!imageUrl || typeof imageUrl !== 'string' || !/^https?:\/\//i.test(imageUrl)) { + return res.status(400).json({ error: 'Valid imageUrl required' }); + } + + const updated = await replaceArtistPortraitFromUrl(artistId, imageUrl, { + searchUrl: typeof searchUrl === 'string' ? searchUrl : undefined, + source: typeof source === 'string' ? source : undefined, + pageUrl: typeof pageUrl === 'string' ? pageUrl : undefined, + thumbUrl: typeof thumbUrl === 'string' ? thumbUrl : undefined, + }); + await pool.query( + `UPDATE artists SET checkup_fixed = true, checkup_checked = true WHERE id = $1`, + [artistId] + ); + res.json({ ...updated, fixed: true, checked: true }); + } catch (err) { + console.error('Fix portrait error:', err.message); + res.status(500).json({ error: friendlyImageFetchError(err) }); + } +}); + +app.post('/api/artists/:id/clear-portrait', async (req, res) => { + try { + const artistId = parseInt(req.params.id, 10); + const updated = await clearArtistPortrait(artistId); + await pool.query( + `UPDATE artists SET checkup_fixed = true, checkup_checked = true WHERE id = $1`, + [artistId] + ); + res.json({ ...updated, fixed: true, checked: true }); + } catch (err) { + console.error('Clear portrait error:', err.message); + res.status(500).json({ error: err.message || 'Could not clear portrait' }); + } +}); + +app.post('/api/artists/:id/upload-portrait', async (req, res) => { + try { + const artistId = parseInt(req.params.id, 10); + const { imageData, mimeType } = req.body ?? {}; + if (!imageData || typeof imageData !== 'string') { + return res.status(400).json({ error: 'imageData required' }); + } + const buffer = Buffer.from(imageData, 'base64'); + if (!buffer.length) { + return res.status(400).json({ error: 'Empty image data' }); + } + if (buffer.length > 15 * 1024 * 1024) { + return res.status(400).json({ error: 'Image too large (max 15 MB)' }); + } + + const updated = await replaceArtistPortraitFromBuffer( + artistId, + buffer, + typeof mimeType === 'string' ? mimeType : 'image/jpeg' + ); + await pool.query( + `UPDATE artists SET checkup_fixed = true, checkup_checked = true WHERE id = $1`, + [artistId] + ); + res.json({ ...updated, fixed: true, checked: true }); + } catch (err) { + console.error('Upload portrait error:', err.message); + res.status(500).json({ error: err.message || 'Could not upload portrait' }); + } +}); + // Artist detail with periods and paintings app.get('/api/artists/:id', async (req, res) => { try { @@ -452,6 +619,30 @@ app.post('/api/artists/:id/preload-images', async (req, res) => { }); // Developer debug: Google Images first result for image audit +app.get('/api/paintings/:id/debug-image-search/more', async (req, res) => { + try { + const paintingId = parseInt(req.params.id, 10); + const limit = Math.min(20, Math.max(1, parseInt(req.query.limit, 10) || 20)); + const result = await pool.query( + `SELECT p.title, a.name AS artist_name + FROM paintings p + JOIN artists a ON a.id = p.artist_id + WHERE p.id = $1`, + [paintingId] + ); + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Painting not found' }); + } + + const { title, artist_name: artistName } = result.rows[0]; + const search = await searchPaintingImagesMany(artistName, title, limit); + res.json(search); + } catch (err) { + console.error('Debug image search (more) error:', err.message); + res.status(500).json({ error: 'Image search failed' }); + } +}); + app.get('/api/paintings/:id/debug-image-search', async (req, res) => { try { const paintingId = parseInt(req.params.id, 10); @@ -501,6 +692,52 @@ app.post('/api/paintings/:id/fix-image', async (req, res) => { } }); +app.post('/api/paintings/:id/clear-image', async (req, res) => { + try { + const paintingId = parseInt(req.params.id, 10); + const updated = await clearPaintingImage(paintingId); + await pool.query( + `UPDATE paintings SET checkup_fixed = true, checkup_checked = true WHERE id = $1`, + [paintingId] + ); + res.json({ ...updated, fixed: true, checked: true }); + } catch (err) { + console.error('Clear image error:', err.message); + res.status(500).json({ error: err.message || 'Could not clear image' }); + } +}); + +app.post('/api/paintings/:id/upload-image', async (req, res) => { + try { + const paintingId = parseInt(req.params.id, 10); + const { imageData, mimeType } = req.body ?? {}; + if (!imageData || typeof imageData !== 'string') { + return res.status(400).json({ error: 'imageData required' }); + } + const buffer = Buffer.from(imageData, 'base64'); + if (!buffer.length) { + return res.status(400).json({ error: 'Empty image data' }); + } + if (buffer.length > 15 * 1024 * 1024) { + return res.status(400).json({ error: 'Image too large (max 15 MB)' }); + } + + const updated = await replacePaintingImageFromBuffer( + paintingId, + buffer, + typeof mimeType === 'string' ? mimeType : 'image/jpeg' + ); + await pool.query( + `UPDATE paintings SET checkup_fixed = true, checkup_checked = true WHERE id = $1`, + [paintingId] + ); + res.json({ ...updated, fixed: true, checked: true }); + } catch (err) { + console.error('Upload image error:', err.message); + res.status(500).json({ error: err.message || 'Could not upload image' }); + } +}); + // Proxy remote image for debug preview (avoids hotlink / CORS blocks) app.get('/api/debug/image-proxy', async (req, res) => { try {