Add debug More/Clear/Upload tools for paintings and artist portraits.
Extends the debug panel on painting detail and artist bio with a 20-result search picker, local image upload, and clear-to-empty-frame workflow, plus API routes, artist checkup migration, and documentation. Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -93,7 +93,9 @@ Full artist profile for the bio page and 3D gallery entry.
|
|||||||
"portrait_path": "portraits/Claude_Monet.jpg",
|
"portrait_path": "portraits/Claude_Monet.jpg",
|
||||||
"bio_short": "First two sentences from Wikipedia…",
|
"bio_short": "First two sentences from Wikipedia…",
|
||||||
"bio_full": "Full Wikipedia lead section…",
|
"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, ... } ],
|
"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, ... } ]
|
"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)).
|
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": "<base64>",
|
||||||
|
"mimeType": "image/jpeg"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Max size 15 MB. **Response** — same as `fix-portrait`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## `GET /api/artists/:id/navigation`
|
## `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
|
## 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`
|
### `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": "<base64>",
|
||||||
|
"mimeType": "image/jpeg"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Max size 15 MB. **Response** — same shape as `fix-image` (`imagePath`, `thumbnailPath`, `fixed`, `checked`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### `GET /api/debug/image-proxy`
|
### `GET /api/debug/image-proxy`
|
||||||
|
|
||||||
Proxy a remote image URL for debug preview (avoids hotlink / CORS blocks in the browser).
|
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/<path>` or placeholder |
|
| `imageUrl(path)` | `/images/<path>` or placeholder |
|
||||||
| `galleryImageUrl(painting)` | Local thumb/full only (3D) |
|
| `galleryImageUrl(painting)` | Local thumb/full only (3D) |
|
||||||
| `galleryImageUrlWithRevision(painting, revision)` | Local URL with `?v=` cache buster after fix |
|
| `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/<path>` with optional `?v=` cache buster |
|
||||||
| `api.getPaintingCheckup()` | `GET /api/paintings/checkup` |
|
| `api.getPaintingCheckup()` | `GET /api/paintings/checkup` |
|
||||||
| `api.updatePaintingCheckupFlags(id, flags)` | `PATCH /api/paintings/:id/checkup-flags` |
|
| `api.updatePaintingCheckupFlags(id, flags)` | `PATCH /api/paintings/:id/checkup-flags` |
|
||||||
| `api.getPaintingDebugImageSearch(id)` | `GET /api/paintings/:id/debug-image-search` |
|
| `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.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=…` |
|
| `debugImageProxyUrl(imageUrl, context?)` | `GET /api/debug/image-proxy?url=…` |
|
||||||
|
|||||||
@@ -66,10 +66,14 @@ Finer-grained styles (Impressionism, Cubism, Suprematism, …).
|
|||||||
| `name` | VARCHAR(200) | |
|
| `name` | VARCHAR(200) | |
|
||||||
| `birth_year`, `death_year` | INTEGER | Nullable; used for timeline portrait placement |
|
| `birth_year`, `death_year` | INTEGER | Nullable; used for timeline portrait placement |
|
||||||
| `movement_id` | FK → `art_movements` | Primary movement |
|
| `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`) |
|
| `bio_short`, `bio_full` | TEXT | Wikipedia lead section (`npm run fetch-artist-bios`) |
|
||||||
| `wikipedia_title` | VARCHAR(300) | Source page title |
|
| `wikipedia_title` | VARCHAR(300) | Source page title |
|
||||||
| `century` | INTEGER | Rounded century bucket for seeding limits |
|
| `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`
|
### `artist_periods`
|
||||||
|
|
||||||
@@ -94,14 +98,14 @@ Phases within an artist’s career (e.g. “Blue Period”, “Roman Period”).
|
|||||||
| `title` | VARCHAR(300) | |
|
| `title` | VARCHAR(300) | |
|
||||||
| `year`, `year_end` | INTEGER | Creation date(s) |
|
| `year`, `year_end` | INTEGER | Creation date(s) |
|
||||||
| `description` | TEXT | |
|
| `description` | TEXT | |
|
||||||
| `image_path` | VARCHAR(500) | Full-size local file |
|
| `image_path` | VARCHAR(500) | Full-size local file; nullable after debug **Clear** |
|
||||||
| `thumbnail_path` | VARCHAR(500) | Smaller variant for lists / 3D |
|
| `thumbnail_path` | VARCHAR(500) | Smaller variant for lists / 3D; nullable after **Clear** |
|
||||||
| `wikipedia_title` | VARCHAR(300) | Used by image fetcher |
|
| `wikipedia_title` | VARCHAR(300) | Used by image fetcher |
|
||||||
| `sort_order` | INTEGER | |
|
| `sort_order` | INTEGER | |
|
||||||
| `checkup_checked` | BOOLEAN NOT NULL DEFAULT false | Reviewed in image checkup workflow (UI label: **Reviewed**) |
|
| `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`).
|
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)`
|
- `artists(movement_id)`, `artists(century)`
|
||||||
- `paintings(artist_id)`, `paintings(period_id)`, `paintings(checkup_checked)`, `paintings(checkup_fixed)`
|
- `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)`
|
- `art_movements(era_id)`, `art_movements(start_year, end_year)`
|
||||||
- `painting_influences(painting_id)`, `painting_influences(influenced_by_painting_id)`
|
- `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)`
|
- `painting_influence_sources(painting_id)`, `painting_influence_sources(source_artist_id)`, `painting_influence_sources(source_movement_id)`
|
||||||
|
|||||||
@@ -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.
|
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.
|
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.
|
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).
|
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)
|
│ ├── src/ # Source (components, pages, 3D scene)
|
||||||
│ │ ├── components/VirtualGallery.tsx # 3D hall (parquet, frames, museum exit)
|
│ │ ├── components/VirtualGallery.tsx # 3D hall (parquet, frames, museum exit)
|
||||||
│ │ ├── components/PaintingDetail.tsx # Detail view + debug panel
|
│ │ ├── 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)
|
│ │ ├── components/MovementBands.tsx # Movement flow (SVG streams + branches)
|
||||||
│ │ ├── pages/CheckupPage.tsx # Image audit table
|
│ │ ├── pages/CheckupPage.tsx # Image audit table
|
||||||
│ │ ├── data/historical-events.ts # Timeline event markers (UI)
|
│ │ ├── 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)
|
### 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
|
## Key design decisions
|
||||||
|
|
||||||
@@ -243,20 +245,23 @@ Optional workflow for curating local image files — not part of the public visi
|
|||||||
|
|
||||||
| Feature | Where | Purpose |
|
| 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 |
|
| **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 |
|
| Button | Action |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| **Checked** | Sets `checkup_checked` via `PATCH /api/paintings/:id/checkup-flags` (disabled once already reviewed) |
|
| **Checked** | Sets `checkup_checked` via `PATCH …/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** |
|
| **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
|
### 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**.
|
**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).
|
See [API.md](API.md#developer-image-audit) and [data-and-images.md](data-and-images.md#duplicate-paintings).
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
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`).
|
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. **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`.
|
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 |
|
| Button | API (paintings / portraits) | Effect |
|
||||||
|--------|-----|--------|
|
|--------|----------------------------|--------|
|
||||||
| **Checked** | `PATCH …/checkup-flags` `{ "checked": true }` | Marks reviewed; 3D frame turns gold |
|
| **Checked** | `PATCH …/checkup-flags` `{ "checked": true }` | Marks reviewed; gold frame (paintings) or gold portrait border (artists) |
|
||||||
| **Fix it** | `POST …/fix-image` | Saves image to disk, sets both flags, refreshes detail + gallery |
|
| **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.
|
Checkup **Search visible** queues search for filtered rows only (3 concurrent); it does not search the full catalog on load.
|
||||||
|
|
||||||
|
|||||||
@@ -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 fetch-artist-bios # bio_short / bio_full from Wikipedia
|
||||||
npm run expand-catalog # famous works for artists below MIN_PAINTINGS
|
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 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 # random sample; 10s max per painting (default)
|
||||||
npm run fetch-images -- --limit=50 --max-wait=120 # same batch size, longer lookup per work
|
npm run fetch-images -- --limit=50 --max-wait=120 # same batch size, longer lookup per work
|
||||||
cd client && npm run build && cd ..
|
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 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: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: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 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` | `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 -- --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
|
- `influence-discovery.js` + `influence-resolver.js` — web discovery and polymorphic source resolution
|
||||||
- `fetch-missing-images.js` — batch image backfill
|
- `fetch-missing-images.js` — batch image backfill
|
||||||
- `find-duplicate-paintings.js` — duplicate catalog audit
|
- `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`
|
- `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`.
|
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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| **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) |
|
| 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 |
|
| Frame still black after **Checked** | Gallery session not synced | Re-enter hall or toggle debug **Checked** from detail with gallery open behind overlay |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Art Gallery
|
# 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
|
## 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 fetch-artist-bios # Wikipedia biographies for all artists
|
||||||
npm run expand-catalog # add famous works for artists with thin catalogs
|
npm run expand-catalog # add famous works for artists with thin catalogs
|
||||||
npm run update-influences # art-history lineage links between paintings
|
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 -- --limit=50 # random batch of missing images (10s per work)
|
||||||
npm run fetch-images -- --artist="Claude Monet" # one artist in catalog order
|
npm run fetch-images -- --artist="Claude Monet" # one artist in catalog order
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ export function imageUrl(path: string | null | undefined): string {
|
|||||||
return `/images/${path}`;
|
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) */
|
/** Image for 3D gallery — local cached files only (API fetch is too slow for realtime 3D) */
|
||||||
export function galleryImageUrl(painting: {
|
export function galleryImageUrl(painting: {
|
||||||
thumbnail_path?: string | null;
|
thumbnail_path?: string | null;
|
||||||
@@ -47,12 +53,47 @@ export function paintingImageUrl(painting: {
|
|||||||
id: number;
|
id: number;
|
||||||
image_path?: string | null;
|
image_path?: string | null;
|
||||||
thumbnail_path?: string | null;
|
thumbnail_path?: string | null;
|
||||||
}): string {
|
checkup_fixed?: boolean;
|
||||||
|
}): string | null {
|
||||||
if (painting.image_path) return `/images/${painting.image_path}`;
|
if (painting.image_path) return `/images/${painting.image_path}`;
|
||||||
if (painting.thumbnail_path) return `/images/${painting.thumbnail_path}`;
|
if (painting.thumbnail_path) return `/images/${painting.thumbnail_path}`;
|
||||||
|
if (painting.checkup_fixed) return null;
|
||||||
return `/api/paintings/${painting.id}/image?size=full`;
|
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<T>(url: string, payload: { imageData: string; mimeType: string }): Promise<T> {
|
||||||
|
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<T>;
|
||||||
|
}
|
||||||
|
|
||||||
export async function preloadArtistImages(artistId: number): Promise<{ fetched: number; total: number }> {
|
export async function preloadArtistImages(artistId: number): Promise<{ fetched: number; total: number }> {
|
||||||
const res = await fetch(`${API}/artists/${artistId}/preload-images`, { method: 'POST' });
|
const res = await fetch(`${API}/artists/${artistId}/preload-images`, { method: 'POST' });
|
||||||
if (!res.ok) throw new Error('Preload failed');
|
if (!res.ok) throw new Error('Preload failed');
|
||||||
@@ -60,8 +101,14 @@ export async function preloadArtistImages(artistId: number): Promise<{ fetched:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FixPaintingImageResult {
|
export interface FixPaintingImageResult {
|
||||||
imagePath: string;
|
imagePath: string | null;
|
||||||
thumbnailPath: string;
|
thumbnailPath: string | null;
|
||||||
|
fixed?: boolean;
|
||||||
|
checked?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FixArtistPortraitResult {
|
||||||
|
portraitPath: string | null;
|
||||||
fixed?: boolean;
|
fixed?: boolean;
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
}
|
}
|
||||||
@@ -75,6 +122,20 @@ export interface DebugImageSearchResult {
|
|||||||
thumbUrl?: string;
|
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 {
|
export interface PaintingCheckupRow {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -120,6 +181,9 @@ export const api = {
|
|||||||
getPaintingDebugImageSearch: (id: number) =>
|
getPaintingDebugImageSearch: (id: number) =>
|
||||||
fetchJson<DebugImageSearchResult>(`${API}/paintings/${id}/debug-image-search`),
|
fetchJson<DebugImageSearchResult>(`${API}/paintings/${id}/debug-image-search`),
|
||||||
|
|
||||||
|
getPaintingDebugImageSearchMore: (id: number, limit = 20) =>
|
||||||
|
fetchJson<DebugImageSearchManyResult>(`${API}/paintings/${id}/debug-image-search/more?limit=${limit}`),
|
||||||
|
|
||||||
fixPaintingImage: (
|
fixPaintingImage: (
|
||||||
id: number,
|
id: number,
|
||||||
imageUrl: string,
|
imageUrl: string,
|
||||||
@@ -137,6 +201,20 @@ export const api = {
|
|||||||
return res.json() as Promise<FixPaintingImageResult>;
|
return res.json() as Promise<FixPaintingImageResult>;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
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<FixPaintingImageResult>;
|
||||||
|
}),
|
||||||
|
|
||||||
|
uploadPaintingImage: async (id: number, file: File) => {
|
||||||
|
const payload = await fileToBase64Payload(file);
|
||||||
|
return postJsonImageAction<FixPaintingImageResult>(`${API}/paintings/${id}/upload-image`, payload);
|
||||||
|
},
|
||||||
|
|
||||||
getPaintingCheckup: () => fetchJson<PaintingCheckupData>(`${API}/paintings/checkup`),
|
getPaintingCheckup: () => fetchJson<PaintingCheckupData>(`${API}/paintings/checkup`),
|
||||||
|
|
||||||
updatePaintingCheckupFlags: (
|
updatePaintingCheckupFlags: (
|
||||||
@@ -154,6 +232,59 @@ export const api = {
|
|||||||
}
|
}
|
||||||
return res.json() as Promise<{ checked: boolean; fixed: boolean }>;
|
return res.json() as Promise<{ checked: boolean; fixed: boolean }>;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getArtistDebugPortraitSearch: (id: number) =>
|
||||||
|
fetchJson<DebugImageSearchResult>(`${API}/artists/${id}/debug-portrait-search`),
|
||||||
|
|
||||||
|
getArtistDebugPortraitSearchMore: (id: number, limit = 20) =>
|
||||||
|
fetchJson<DebugImageSearchManyResult>(`${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<FixArtistPortraitResult>;
|
||||||
|
}),
|
||||||
|
|
||||||
|
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<FixArtistPortraitResult>;
|
||||||
|
}),
|
||||||
|
|
||||||
|
uploadArtistPortrait: async (id: number, file: File) => {
|
||||||
|
const payload = await fileToBase64Payload(file);
|
||||||
|
return postJsonImageAction<FixArtistPortraitResult>(`${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(
|
export function debugImageProxyUrl(
|
||||||
|
|||||||
@@ -58,6 +58,19 @@
|
|||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
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 {
|
.bio-text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,60 @@
|
|||||||
|
import { useEffect, useRef, useState, type ChangeEvent } from 'react';
|
||||||
import type { Artist } from '../types';
|
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';
|
import './ArtistBio.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
artist: Artist & { movement_name?: string };
|
artist: Artist & { movement_name?: string };
|
||||||
|
debugMode?: boolean;
|
||||||
|
portraitRevision?: number;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onEnterGallery: () => void;
|
onEnterGallery: () => void;
|
||||||
|
onArtistPortraitFixed?: (
|
||||||
|
artistId: number,
|
||||||
|
fixResult: FixArtistPortraitResult
|
||||||
|
) => void | Promise<void>;
|
||||||
|
onArtistCheckupFlagsUpdated?: (
|
||||||
|
artistId: number,
|
||||||
|
flags: { checked: boolean; fixed: boolean }
|
||||||
|
) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
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<DebugImageSearchResult | null>(null);
|
||||||
|
const [debugLoading, setDebugLoading] = useState(false);
|
||||||
|
const [debugError, setDebugError] = useState<string | null>(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<string | null>(null);
|
||||||
|
const [moreResults, setMoreResults] = useState<Awaited<ReturnType<typeof api.getArtistDebugPortraitSearchMore>> | null>(null);
|
||||||
|
const [applyingUrl, setApplyingUrl] = useState<string | null>(null);
|
||||||
|
const [clearing, setClearing] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const uploadInputRef = useRef<HTMLInputElement>(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 =
|
const lifespan =
|
||||||
artist.birth_year && artist.death_year
|
artist.birth_year && artist.death_year
|
||||||
? `${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}`
|
? `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<HTMLInputElement>) => {
|
||||||
|
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 (
|
return (
|
||||||
<div className="artist-bio">
|
<div className="artist-bio">
|
||||||
<header className="bio-header">
|
<header className="bio-header">
|
||||||
@@ -25,14 +219,18 @@ export default function ArtistBio({ artist, onBack, onEnterGallery }: Props) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="bio-content">
|
<div className="bio-content">
|
||||||
<div className="bio-portrait">
|
<div
|
||||||
<img
|
className={`bio-portrait${artist.checkup_checked ? ' bio-portrait-checked' : ''}${portraitCleared ? ' bio-portrait-empty' : ''}`}
|
||||||
src={imageUrl(artist.portrait_path)}
|
>
|
||||||
alt={artist.name}
|
{showPortrait ? (
|
||||||
onError={(e) => {
|
<img
|
||||||
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
|
src={portraitSrc}
|
||||||
}}
|
alt={artist.name}
|
||||||
/>
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bio-text">
|
<div className="bio-text">
|
||||||
@@ -66,6 +264,95 @@ export default function ArtistBio({ artist, onBack, onEnterGallery }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{debugMode && (
|
||||||
|
<aside className="debug-image-panel" aria-label="Debug portrait search">
|
||||||
|
<h4>{debugSearch?.sourceLabel ?? 'Portrait image search'}</h4>
|
||||||
|
<p className="debug-image-query">
|
||||||
|
{debugSearch?.query ?? `${artist.name} portrait`}
|
||||||
|
</p>
|
||||||
|
{debugLoading && <p className="debug-image-status">Searching…</p>}
|
||||||
|
{debugError && <p className="debug-image-error">{debugError}</p>}
|
||||||
|
{!debugLoading && debugSearch?.imageUrl && (
|
||||||
|
<img
|
||||||
|
className="debug-image-preview"
|
||||||
|
src={debugImageProxyUrl(debugSearch.imageUrl, {
|
||||||
|
searchUrl: debugSearch.searchUrl,
|
||||||
|
source: debugSearch.source,
|
||||||
|
})}
|
||||||
|
alt={`Search result for ${debugSearch.query}`}
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
|
||||||
|
<p className="debug-image-status">No portrait image result found.</p>
|
||||||
|
)}
|
||||||
|
<div className="debug-action-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="debug-checked-btn"
|
||||||
|
onClick={handleMarkChecked}
|
||||||
|
disabled={!!artist.checkup_checked || markingChecked}
|
||||||
|
>
|
||||||
|
{markingChecked ? '…' : 'Checked'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="debug-fix-btn"
|
||||||
|
onClick={handleFixPortrait}
|
||||||
|
disabled={fixing || debugLoading || !debugSearch?.imageUrl}
|
||||||
|
>
|
||||||
|
{fixing ? '…' : 'Fix it'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="debug-more-btn"
|
||||||
|
onClick={handleOpenMore}
|
||||||
|
disabled={debugLoading || moreLoading}
|
||||||
|
>
|
||||||
|
{moreLoading ? '…' : 'More'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="debug-action-buttons debug-action-buttons-secondary">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="debug-clear-btn"
|
||||||
|
onClick={handleClearPortrait}
|
||||||
|
disabled={clearing || fixing || uploading || portraitCleared}
|
||||||
|
>
|
||||||
|
{clearing ? '…' : 'Clear'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="debug-upload-btn"
|
||||||
|
onClick={handleUploadClick}
|
||||||
|
disabled={uploading || fixing || clearing}
|
||||||
|
>
|
||||||
|
{uploading ? '…' : 'Upload'}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={uploadInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
hidden
|
||||||
|
onChange={handleUploadFile}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DebugSearchResultsModal
|
||||||
|
open={moreOpen}
|
||||||
|
title="Choose portrait"
|
||||||
|
data={moreResults}
|
||||||
|
loading={moreLoading}
|
||||||
|
error={moreError}
|
||||||
|
applyingUrl={applyingUrl}
|
||||||
|
onClose={() => setMoreOpen(false)}
|
||||||
|
onSelect={handleSelectMoreResult}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="debug-search-modal-overlay"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div className="debug-search-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<header className="debug-search-modal-header">
|
||||||
|
<div>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
{data?.query && <p className="debug-search-modal-query">{data.query}</p>}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="debug-search-modal-close" onClick={onClose} aria-label="Close">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loading && <p className="debug-search-modal-status">Loading results…</p>}
|
||||||
|
{error && <p className="debug-search-modal-error">{error}</p>}
|
||||||
|
|
||||||
|
{!loading && !error && data && data.results.length === 0 && (
|
||||||
|
<p className="debug-search-modal-status">No images found.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && data && data.results.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="debug-search-modal-hint">Click an image to replace the current one.</p>
|
||||||
|
<div className="debug-search-modal-grid">
|
||||||
|
{data.results.map((item, index) => {
|
||||||
|
const busy = applyingUrl === item.imageUrl;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${item.imageUrl}-${index}`}
|
||||||
|
type="button"
|
||||||
|
className={`debug-search-modal-item${busy ? ' debug-search-modal-item-busy' : ''}`}
|
||||||
|
disabled={!!applyingUrl}
|
||||||
|
onClick={() => onSelect(item)}
|
||||||
|
title="Use this image"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={debugImageProxyUrl(item.thumbUrl || item.imageUrl, {
|
||||||
|
searchUrl: data.searchUrl,
|
||||||
|
source: item.source,
|
||||||
|
})}
|
||||||
|
alt={`Result ${index + 1}`}
|
||||||
|
loading="lazy"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="debug-search-modal-item-index">{index + 1}</span>
|
||||||
|
{busy && <span className="debug-search-modal-item-busy-label">Saving…</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react';
|
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react';
|
||||||
import type { ArtMovement, Artist } from '../types';
|
import type { ArtMovement, Artist } from '../types';
|
||||||
import { imageUrl } from '../api/client';
|
import { portraitUrl } from '../api/client';
|
||||||
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
|
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
|
||||||
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
||||||
import './MovementBands.css';
|
import './MovementBands.css';
|
||||||
@@ -8,6 +8,7 @@ import './MovementBands.css';
|
|||||||
interface Props {
|
interface Props {
|
||||||
movements: ArtMovement[];
|
movements: ArtMovement[];
|
||||||
artists: Artist[];
|
artists: Artist[];
|
||||||
|
portraitRevisions?: Record<number, number>;
|
||||||
viewStart: number;
|
viewStart: number;
|
||||||
viewEnd: number;
|
viewEnd: number;
|
||||||
absoluteMin: number;
|
absoluteMin: number;
|
||||||
@@ -325,6 +326,7 @@ function branchTargetOnChild(layout: MovementLayout): { x: number; y: number } {
|
|||||||
export default function MovementBands({
|
export default function MovementBands({
|
||||||
movements,
|
movements,
|
||||||
artists,
|
artists,
|
||||||
|
portraitRevisions,
|
||||||
viewStart,
|
viewStart,
|
||||||
viewEnd,
|
viewEnd,
|
||||||
absoluteMin,
|
absoluteMin,
|
||||||
@@ -770,7 +772,7 @@ export default function MovementBands({
|
|||||||
title={`${artist.name} (${birthLabel}–${deathLabel}) · ${layout.movement.name}`}
|
title={`${artist.name} (${birthLabel}–${deathLabel}) · ${layout.movement.name}`}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={imageUrl(artist.portrait_path)}
|
src={portraitUrl(artist.portrait_path, portraitRevisions?.[artist.id])}
|
||||||
alt={artist.name}
|
alt={artist.name}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
|
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
|
||||||
|
|||||||
@@ -499,6 +499,84 @@
|
|||||||
cursor: wait;
|
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 {
|
.debug-image-status {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
|
|||||||
@@ -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 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 PaintingLightbox from './PaintingLightbox';
|
||||||
import './PaintingDetail.css';
|
import './PaintingDetail.css';
|
||||||
|
|
||||||
@@ -158,7 +159,7 @@ function InfluenceCard({
|
|||||||
title={`View ${inf.title}`}
|
title={`View ${inf.title}`}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={paintingImageUrl({ id: inf.id, image_path: inf.image_path })}
|
src={paintingImageUrl({ id: inf.id, image_path: inf.image_path }) ?? '/placeholder-art.svg'}
|
||||||
alt={inf.title || 'Painting'}
|
alt={inf.title || 'Painting'}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
|
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
|
||||||
@@ -197,8 +198,20 @@ export default function PaintingDetailView({
|
|||||||
const [debugError, setDebugError] = useState<string | null>(null);
|
const [debugError, setDebugError] = useState<string | null>(null);
|
||||||
const [fixing, setFixing] = useState(false);
|
const [fixing, setFixing] = useState(false);
|
||||||
const [markingChecked, setMarkingChecked] = useState(false);
|
const [markingChecked, setMarkingChecked] = useState(false);
|
||||||
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
|
const [moreLoading, setMoreLoading] = useState(false);
|
||||||
|
const [moreError, setMoreError] = useState<string | null>(null);
|
||||||
|
const [moreResults, setMoreResults] = useState<Awaited<ReturnType<typeof api.getPaintingDebugImageSearchMore>> | null>(null);
|
||||||
|
const [applyingUrl, setApplyingUrl] = useState<string | null>(null);
|
||||||
|
const [clearing, setClearing] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const uploadInputRef = useRef<HTMLInputElement>(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 catalogIndex = artistPaintings.findIndex((p) => p.id === painting.id);
|
||||||
const previousPainting = catalogIndex > 0 ? artistPaintings[catalogIndex - 1] : null;
|
const previousPainting = catalogIndex > 0 ? artistPaintings[catalogIndex - 1] : null;
|
||||||
@@ -211,12 +224,16 @@ export default function PaintingDetailView({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFullscreen(false);
|
setFullscreen(false);
|
||||||
setImageVersion(0);
|
setImageVersion(0);
|
||||||
|
setMoreOpen(false);
|
||||||
|
setMoreResults(null);
|
||||||
|
setMoreError(null);
|
||||||
}, [painting.id]);
|
}, [painting.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!debugMode) {
|
if (!debugMode) {
|
||||||
setDebugSearch(null);
|
setDebugSearch(null);
|
||||||
setDebugError(null);
|
setDebugError(null);
|
||||||
|
setMoreOpen(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,20 +258,36 @@ export default function PaintingDetailView({
|
|||||||
};
|
};
|
||||||
}, [debugMode, painting.id, painting.title, painting.artist_name]);
|
}, [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 () => {
|
const handleFixImage = async () => {
|
||||||
if (!debugSearch?.imageUrl || fixing) return;
|
if (!debugSearch?.imageUrl || fixing) return;
|
||||||
setFixing(true);
|
setFixing(true);
|
||||||
setDebugError(null);
|
setDebugError(null);
|
||||||
try {
|
try {
|
||||||
const fixResult = await api.fixPaintingImage(painting.id, debugSearch.imageUrl, {
|
await applyFixFromSearch(debugSearch.imageUrl, {
|
||||||
searchUrl: debugSearch.searchUrl,
|
searchUrl: debugSearch.searchUrl,
|
||||||
source: debugSearch.source,
|
source: debugSearch.source,
|
||||||
thumbUrl: debugSearch.thumbUrl,
|
thumbUrl: debugSearch.thumbUrl,
|
||||||
});
|
});
|
||||||
setImageVersion((v) => v + 1);
|
|
||||||
if (onPaintingImageFixed) {
|
|
||||||
await onPaintingImageFixed(painting.id, fixResult);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDebugError(err instanceof Error ? err.message : 'Could not replace image.');
|
setDebugError(err instanceof Error ? err.message : 'Could not replace image.');
|
||||||
} finally {
|
} 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 () => {
|
const handleMarkChecked = async () => {
|
||||||
if (painting.checkup_checked || markingChecked) return;
|
if (painting.checkup_checked || markingChecked) return;
|
||||||
setMarkingChecked(true);
|
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<HTMLInputElement>) => {
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (fullscreen) return;
|
if (fullscreen) return;
|
||||||
|
|
||||||
@@ -356,20 +458,26 @@ export default function PaintingDetailView({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="painting-frame-large painting-frame-clickable"
|
className={`painting-frame-large${imageSrc ? ' painting-frame-clickable' : ' painting-frame-empty'}${imageCleared ? ' painting-frame-cleared' : ''}`}
|
||||||
role="button"
|
role={imageSrc ? 'button' : undefined}
|
||||||
tabIndex={0}
|
tabIndex={imageSrc ? 0 : undefined}
|
||||||
onClick={() => setFullscreen(true)}
|
onClick={imageSrc ? () => setFullscreen(true) : undefined}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
imageSrc
|
||||||
e.preventDefault();
|
? (e) => {
|
||||||
setFullscreen(true);
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
}
|
e.preventDefault();
|
||||||
}}
|
setFullscreen(true);
|
||||||
title="View full screen"
|
}
|
||||||
aria-label={`View ${painting.title} full screen`}
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
title={imageSrc ? 'View full screen' : undefined}
|
||||||
|
aria-label={imageSrc ? `View ${painting.title} full screen` : `${painting.title} (no image)`}
|
||||||
>
|
>
|
||||||
<img src={imageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
|
{imageSrc ? (
|
||||||
|
<img src={imageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showCatalogNav && (
|
{showCatalogNav && (
|
||||||
@@ -452,11 +560,55 @@ export default function PaintingDetailView({
|
|||||||
>
|
>
|
||||||
{fixing ? '…' : 'Fix it'}
|
{fixing ? '…' : 'Fix it'}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="debug-more-btn"
|
||||||
|
onClick={handleOpenMore}
|
||||||
|
disabled={debugLoading || moreLoading}
|
||||||
|
>
|
||||||
|
{moreLoading ? '…' : 'More'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="debug-action-buttons debug-action-buttons-secondary">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="debug-clear-btn"
|
||||||
|
onClick={handleClearImage}
|
||||||
|
disabled={clearing || fixing || uploading || imageCleared}
|
||||||
|
>
|
||||||
|
{clearing ? '…' : 'Clear'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="debug-upload-btn"
|
||||||
|
onClick={handleUploadClick}
|
||||||
|
disabled={uploading || fixing || clearing}
|
||||||
|
>
|
||||||
|
{uploading ? '…' : 'Upload'}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={uploadInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
hidden
|
||||||
|
onChange={handleUploadFile}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{fullscreen && (
|
<DebugSearchResultsModal
|
||||||
|
open={moreOpen}
|
||||||
|
title="Choose painting image"
|
||||||
|
data={moreResults}
|
||||||
|
loading={moreLoading}
|
||||||
|
error={moreError}
|
||||||
|
applyingUrl={applyingUrl}
|
||||||
|
onClose={() => setMoreOpen(false)}
|
||||||
|
onSelect={handleSelectMoreResult}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{fullscreen && imageSrc && (
|
||||||
<PaintingLightbox
|
<PaintingLightbox
|
||||||
src={imageSrc}
|
src={imageSrc}
|
||||||
alt={painting.title}
|
alt={painting.title}
|
||||||
|
|||||||
@@ -348,7 +348,7 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
|
|||||||
setImageVersionById((prev) => ({ ...prev, [row.id]: (prev[row.id] ?? 0) + 1 }));
|
setImageVersionById((prev) => ({ ...prev, [row.id]: (prev[row.id] ?? 0) + 1 }));
|
||||||
setRows((list) =>
|
setRows((list) =>
|
||||||
list.map((r) =>
|
list.map((r) =>
|
||||||
r.id === row.id
|
r.id === row.id && updated.imagePath && updated.thumbnailPath
|
||||||
? {
|
? {
|
||||||
...r,
|
...r,
|
||||||
gallery_file: updated.imagePath,
|
gallery_file: updated.imagePath,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import VirtualGallery from '../components/VirtualGallery';
|
|||||||
import PaintingDetailView from '../components/PaintingDetail';
|
import PaintingDetailView from '../components/PaintingDetail';
|
||||||
import ArtistBio from '../components/ArtistBio';
|
import ArtistBio from '../components/ArtistBio';
|
||||||
import CheckupPage from '../pages/CheckupPage';
|
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 type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail } from '../types';
|
||||||
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
|
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
|
||||||
import { readDebugMode, writeDebugMode } from '../utils/debugMode';
|
import { readDebugMode, writeDebugMode } from '../utils/debugMode';
|
||||||
@@ -29,6 +29,13 @@ function patchPaintingInArtistDetail(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function patchArtistInArtistDetail(detail: ArtistDetail, patch: Partial<Artist>): ArtistDetail {
|
||||||
|
return {
|
||||||
|
...detail,
|
||||||
|
artist: { ...detail.artist, ...patch },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const [view, setView] = useState<View>({ type: 'timeline' });
|
const [view, setView] = useState<View>({ type: 'timeline' });
|
||||||
const [gallerySession, setGallerySession] = useState<{ artistId: number; data: ArtistDetail } | null>(
|
const [gallerySession, setGallerySession] = useState<{ artistId: number; data: ArtistDetail } | null>(
|
||||||
@@ -43,6 +50,7 @@ export default function HomePage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [detailArtistPaintings, setDetailArtistPaintings] = useState<Painting[]>([]);
|
const [detailArtistPaintings, setDetailArtistPaintings] = useState<Painting[]>([]);
|
||||||
const [imageRevisions, setImageRevisions] = useState<Record<number, number>>({});
|
const [imageRevisions, setImageRevisions] = useState<Record<number, number>>({});
|
||||||
|
const [portraitRevisions, setPortraitRevisions] = useState<Record<number, number>>({});
|
||||||
const [debugMode, setDebugMode] = useState(readDebugMode);
|
const [debugMode, setDebugMode] = useState(readDebugMode);
|
||||||
const detailReturnToRef = useRef<View>({ type: 'timeline' });
|
const detailReturnToRef = useRef<View>({ type: 'timeline' });
|
||||||
|
|
||||||
@@ -103,8 +111,8 @@ export default function HomePage() {
|
|||||||
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
|
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
|
||||||
const data = await api.getPainting(paintingId);
|
const data = await api.getPainting(paintingId);
|
||||||
const patch: Partial<Painting> = {
|
const patch: Partial<Painting> = {
|
||||||
image_path: fixResult.imagePath ?? data.painting.image_path,
|
image_path: fixResult.imagePath,
|
||||||
thumbnail_path: fixResult.thumbnailPath ?? data.painting.thumbnail_path,
|
thumbnail_path: fixResult.thumbnailPath,
|
||||||
checkup_checked: fixResult.checked ?? true,
|
checkup_checked: fixResult.checked ?? true,
|
||||||
checkup_fixed: fixResult.fixed ?? true,
|
checkup_fixed: fixResult.fixed ?? true,
|
||||||
};
|
};
|
||||||
@@ -175,6 +183,73 @@ export default function HomePage() {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const applyArtistPatch = useCallback((artistId: number, patch: Partial<Artist>) => {
|
||||||
|
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<Artist> = {
|
||||||
|
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<Artist> = {
|
||||||
|
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) => {
|
const handleArtistClick = async (artistId: number) => {
|
||||||
try {
|
try {
|
||||||
const data = await api.getArtist(artistId);
|
const data = await api.getArtist(artistId);
|
||||||
@@ -305,10 +380,14 @@ export default function HomePage() {
|
|||||||
<div className="home-overlay">
|
<div className="home-overlay">
|
||||||
<ArtistBio
|
<ArtistBio
|
||||||
artist={view.data.artist}
|
artist={view.data.artist}
|
||||||
|
debugMode={debugMode}
|
||||||
|
portraitRevision={portraitRevisions[view.data.artist.id]}
|
||||||
onBack={() => setView(view.returnTo)}
|
onBack={() => setView(view.returnTo)}
|
||||||
onEnterGallery={() =>
|
onEnterGallery={() =>
|
||||||
setView({ type: 'gallery', artistId: view.artistId, data: view.data })
|
setView({ type: 'gallery', artistId: view.artistId, data: view.data })
|
||||||
}
|
}
|
||||||
|
onArtistPortraitFixed={handleArtistPortraitFixed}
|
||||||
|
onArtistCheckupFlagsUpdated={handleArtistCheckupFlagsUpdated}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -328,7 +407,7 @@ export default function HomePage() {
|
|||||||
type="button"
|
type="button"
|
||||||
className={`debug-mode-toggle${debugMode ? ' debug-mode-toggle-active' : ''}`}
|
className={`debug-mode-toggle${debugMode ? ' debug-mode-toggle-active' : ''}`}
|
||||||
onClick={toggleDebugMode}
|
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' : ''}
|
Debug mode{debugMode ? ': ON' : ''}
|
||||||
</button>
|
</button>
|
||||||
@@ -363,6 +442,7 @@ export default function HomePage() {
|
|||||||
<MovementBands
|
<MovementBands
|
||||||
movements={timelineData.movements}
|
movements={timelineData.movements}
|
||||||
artists={artists}
|
artists={artists}
|
||||||
|
portraitRevisions={portraitRevisions}
|
||||||
viewStart={viewStart}
|
viewStart={viewStart}
|
||||||
viewEnd={viewEnd}
|
viewEnd={viewEnd}
|
||||||
absoluteMin={bounds.min}
|
absoluteMin={bounds.min}
|
||||||
|
|||||||
@@ -30,11 +30,13 @@ export interface Artist {
|
|||||||
movement_id: number;
|
movement_id: number;
|
||||||
movement_name?: string;
|
movement_name?: string;
|
||||||
movement_color?: string;
|
movement_color?: string;
|
||||||
portrait_path: string;
|
portrait_path: string | null;
|
||||||
bio_short: string;
|
bio_short: string;
|
||||||
bio_full: string;
|
bio_full: string;
|
||||||
wikipedia_title: string;
|
wikipedia_title: string;
|
||||||
century: number;
|
century: number;
|
||||||
|
checkup_checked?: boolean;
|
||||||
|
checkup_fixed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ArtistPeriod {
|
export interface ArtistPeriod {
|
||||||
@@ -55,8 +57,8 @@ export interface Painting {
|
|||||||
year: number;
|
year: number;
|
||||||
year_end?: number;
|
year_end?: number;
|
||||||
description: string;
|
description: string;
|
||||||
image_path: string;
|
image_path: string | null;
|
||||||
thumbnail_path?: string;
|
thumbnail_path?: string | null;
|
||||||
wikipedia_title: string;
|
wikipedia_title: string;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
artist_name?: string;
|
artist_name?: string;
|
||||||
@@ -95,7 +97,7 @@ export interface InfluenceLink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PaintingDetail {
|
export interface PaintingDetail {
|
||||||
painting: Painting & { artist_name: string; artist_portrait: string };
|
painting: Painting & { artist_name: string; artist_portrait: string | null };
|
||||||
influencedBy: InfluenceLink[];
|
influencedBy: InfluenceLink[];
|
||||||
influenced: InfluenceLink[];
|
influenced: InfluenceLink[];
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 382 KiB After Width: | Height: | Size: 404 KiB |
|
Before Width: | Height: | Size: 646 KiB After Width: | Height: | Size: 613 KiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 646 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 6.8 MiB After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 4.3 MiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 821 KiB After Width: | Height: | Size: 389 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
-- Review / fixed flags for artist portrait checkup (debug mode on bio page)
|
||||||
|
ALTER TABLE artists ADD COLUMN IF NOT EXISTS checkup_checked BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE artists ADD COLUMN IF NOT EXISTS checkup_fixed BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS artists_checkup_checked_idx ON artists (checkup_checked);
|
||||||
|
CREATE INDEX IF NOT EXISTS artists_checkup_fixed_idx ON artists (checkup_fixed);
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"update-influences": "node scripts/update-influences.js",
|
"update-influences": "node scripts/update-influences.js",
|
||||||
"migrate:influence-sources": "node scripts/migrate-influence-sources.js",
|
"migrate:influence-sources": "node scripts/migrate-influence-sources.js",
|
||||||
"migrate:checkup-flags": "node scripts/migrate-checkup-flags.js",
|
"migrate:checkup-flags": "node scripts/migrate-checkup-flags.js",
|
||||||
|
"migrate:artist-checkup-flags": "node scripts/migrate-artist-checkup-flags.js",
|
||||||
"find-duplicates": "node scripts/find-duplicate-paintings.js",
|
"find-duplicates": "node scripts/find-duplicate-paintings.js",
|
||||||
"discover-influences": "node scripts/update-influences.js --discover-only",
|
"discover-influences": "node scripts/update-influences.js --discover-only",
|
||||||
"server": "node server/index.js",
|
"server": "node server/index.js",
|
||||||
|
|||||||
@@ -1305,19 +1305,27 @@ function isLikelyImageUrl(url) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickBestGoogleImageUrl(candidates) {
|
function scoreImageUrl(url) {
|
||||||
|
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 score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rankImageCandidates(candidates, limit = 20) {
|
||||||
const unique = [...new Set(candidates.filter(isLikelyImageUrl))];
|
const unique = [...new Set(candidates.filter(isLikelyImageUrl))];
|
||||||
const scored = unique.map((url) => {
|
return unique
|
||||||
let score = 0;
|
.map((url) => ({ url, score: scoreImageUrl(url) }))
|
||||||
if (/upload\.wikimedia\.org/i.test(url)) score += 40;
|
.sort((a, b) => b.score - a.score)
|
||||||
if (/googleusercontent\.com/i.test(url)) score += 30;
|
.slice(0, limit)
|
||||||
if (/\.(jpe?g|png|webp)(\?|$)/i.test(url)) score += 20;
|
.map((item) => item.url);
|
||||||
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 };
|
function pickBestGoogleImageUrl(candidates) {
|
||||||
});
|
return rankImageCandidates(candidates, 1)[0] || null;
|
||||||
scored.sort((a, b) => b.score - a.score);
|
|
||||||
return scored[0]?.url || unique[0] || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractGoogleImageCandidates(html) {
|
function extractGoogleImageCandidates(html) {
|
||||||
@@ -1346,6 +1354,33 @@ function extractGoogleImageCandidates(html) {
|
|||||||
return candidates;
|
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) {
|
async function searchGoogleCustomSearchImagesFirst(query) {
|
||||||
const apiKey = process.env.GOOGLE_CSE_API_KEY;
|
const apiKey = process.env.GOOGLE_CSE_API_KEY;
|
||||||
const cx = process.env.GOOGLE_CSE_CX;
|
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) {
|
async function searchDuckDuckGoImagesFirst(query) {
|
||||||
const html = await fetchHtml(
|
const html = await fetchHtml(
|
||||||
`https://duckduckgo.com/?q=${encodeURIComponent(query)}&iar=images&iax=images&ia=images`,
|
`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' };
|
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) {
|
async function generateThumbnailFromFull(fullDest, thumbDest, width = THUMB_WIDTH) {
|
||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
if (!fs.existsSync(fullDest)) return false;
|
if (!fs.existsSync(fullDest)) return false;
|
||||||
@@ -1601,6 +1812,9 @@ module.exports = {
|
|||||||
searchWikipediaTitle,
|
searchWikipediaTitle,
|
||||||
searchWebForPaintingImages,
|
searchWebForPaintingImages,
|
||||||
searchGoogleImagesFirst,
|
searchGoogleImagesFirst,
|
||||||
|
searchArtistPortraitFirst,
|
||||||
|
searchPaintingImagesMany,
|
||||||
|
searchArtistPortraitMany,
|
||||||
fetchImageBuffer,
|
fetchImageBuffer,
|
||||||
friendlyImageFetchError,
|
friendlyImageFetchError,
|
||||||
pickExt,
|
pickExt,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -16,6 +16,10 @@ function safePaintingBase(artistName, title) {
|
|||||||
return `${artistName}_${title}`.replace(/[^a-zA-Z0-9_-]/g, '_');
|
return `${artistName}_${title}`.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function safeArtistPortraitBase(artistName) {
|
||||||
|
return artistName.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
function unlinkIfExists(absPath) {
|
function unlinkIfExists(absPath) {
|
||||||
if (absPath && fs.existsSync(absPath)) {
|
if (absPath && fs.existsSync(absPath)) {
|
||||||
try {
|
try {
|
||||||
@@ -154,6 +158,183 @@ async function ensurePaintingImages(paintingId, size = 'thumb') {
|
|||||||
return promise;
|
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 = {}) {
|
async function replacePaintingImageFromUrl(paintingId, imageUrl, context = {}) {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT p.id, p.title, p.image_path, p.thumbnail_path, a.name AS artist_name
|
`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 };
|
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 = {
|
module.exports = {
|
||||||
ensurePaintingImages,
|
ensurePaintingImages,
|
||||||
preloadArtistImagesLocal,
|
preloadArtistImagesLocal,
|
||||||
replacePaintingImageFromUrl,
|
replacePaintingImageFromUrl,
|
||||||
|
replaceArtistPortraitFromUrl,
|
||||||
|
clearPaintingImage,
|
||||||
|
clearArtistPortrait,
|
||||||
|
replacePaintingImageFromBuffer,
|
||||||
|
replaceArtistPortraitFromBuffer,
|
||||||
IMAGE_DIR,
|
IMAGE_DIR,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ const fs = require('fs');
|
|||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const pool = require('./db');
|
const pool = require('./db');
|
||||||
const { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, IMAGE_DIR } = require('./image-service');
|
const { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, IMAGE_DIR } = require('./image-service');
|
||||||
const { searchGoogleImagesFirst, fetchImageBuffer, friendlyImageFetchError, pickExt } = require('../scripts/image-fetcher');
|
const { searchGoogleImagesFirst, searchArtistPortraitFirst, searchPaintingImagesMany, searchArtistPortraitMany, fetchImageBuffer, friendlyImageFetchError, pickExt } = require('../scripts/image-fetcher');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
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
|
// Artist detail with periods and paintings
|
||||||
app.get('/api/artists/:id', async (req, res) => {
|
app.get('/api/artists/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -452,6 +619,30 @@ app.post('/api/artists/:id/preload-images', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Developer debug: Google Images first result for image audit
|
// 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) => {
|
app.get('/api/paintings/:id/debug-image-search', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const paintingId = parseInt(req.params.id, 10);
|
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)
|
// Proxy remote image for debug preview (avoids hotlink / CORS blocks)
|
||||||
app.get('/api/debug/image-proxy', async (req, res) => {
|
app.get('/api/debug/image-proxy', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||