Add debug Remove entry, Show more auto-picker, and update docs.
DELETE /api/paintings/:id removes works and image files with gallery refresh and catalog navigation; Show more opens the search modal on load; documentation updated for migrate schema and debug workflow. Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -542,6 +542,26 @@ Max decoded size 15 MB (JSON body limit 20 MB on the server). **Response** — s
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### `DELETE /api/paintings/:id`
|
||||||
|
|
||||||
|
Permanently remove a painting (debug **Remove entry** on painting detail). Deletes full + thumbnail files from disk, then deletes the `paintings` row. Related rows in `painting_influence_sources`, `painting_annotations`, and legacy `painting_influences` are removed by `ON DELETE CASCADE`.
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"artistId": 7,
|
||||||
|
"title": "Example Work"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:** `404` if the painting does not exist.
|
||||||
|
|
||||||
|
The client refetches the artist (and movement gallery when applicable), remounts the 3D hall, and opens the next or previous work in the catalog — or returns to the gallery if it was the last work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### `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).
|
||||||
@@ -576,6 +596,7 @@ The React client wraps these endpoints in `client/src/api/client.ts`:
|
|||||||
| `api.getPaintingDebugImageSearchMore(id, limit?)` | `GET /api/paintings/:id/debug-image-search/more` |
|
| `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.clearPaintingImage(id)` | `POST /api/paintings/:id/clear-image` |
|
||||||
|
| `api.deletePainting(id)` | `DELETE /api/paintings/:id` |
|
||||||
| `api.uploadPaintingImage(id, file)` | `POST /api/paintings/:id/upload-image` |
|
| `api.uploadPaintingImage(id, file)` | `POST /api/paintings/:id/upload-image` |
|
||||||
| `api.updateArtistCheckupFlags(id, flags)` | `PATCH /api/artists/:id/checkup-flags` |
|
| `api.updateArtistCheckupFlags(id, flags)` | `PATCH /api/artists/:id/checkup-flags` |
|
||||||
| `api.getArtistDebugPortraitSearch(id)` | `GET /api/artists/:id/debug-portrait-search` |
|
| `api.getArtistDebugPortraitSearch(id)` | `GET /api/artists/:id/debug-portrait-search` |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Art Gallery — database structure
|
# Art Gallery — database structure
|
||||||
|
|
||||||
PostgreSQL schema for the virtual gallery. Canonical DDL is intended to live in `db/schema.sql` when checked in; this document describes the logical model either way.
|
PostgreSQL schema for the virtual gallery. Canonical DDL lives in **`db/schema.sql`**; **`server/migrate.js`** (`npm run migrate`) applies that file plus idempotent incremental scripts in `db/migrate-*.sql`. This document describes the logical model.
|
||||||
|
|
||||||
Connection settings come from `.env` (see [setup.md](setup.md)).
|
Connection settings come from `.env` (see [setup.md](setup.md)).
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ Unique index on `(painting_id, source_type, source_painting_id, source_artist_id
|
|||||||
|
|
||||||
## First-time setup
|
## First-time setup
|
||||||
|
|
||||||
The `gallery` database user needs `CREATE` on schema `public` for migrations. If tables cannot be created, run admin grants as PostgreSQL superuser before `npm run migrate`:
|
The `gallery` database user needs `CREATE` on schema `public` for migrations. If tables cannot be created, run **`db/setup-admin.sql`** as PostgreSQL superuser (or the grants below) before `npm run migrate`:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
GRANT CREATE ON SCHEMA public TO gallery;
|
GRANT CREATE ON SCHEMA public TO gallery;
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ Gallery/
|
|||||||
│ │ ├── data/historical-events.ts # Timeline event markers (UI)
|
│ │ ├── data/historical-events.ts # Timeline event markers (UI)
|
||||||
│ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI)
|
│ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI)
|
||||||
│ │ ├── utils/parquetFloorTexture.ts # Procedural parquet floor
|
│ │ ├── utils/parquetFloorTexture.ts # Procedural parquet floor
|
||||||
│ │ ├── utils/debugMode.ts # Debug mode localStorage toggle
|
│ │ ├── utils/debugMode.ts # Debug mode + “Show more” localStorage prefs
|
||||||
│ │ └── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
|
│ │ └── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
|
||||||
│ └── dist/ # Production build (served by API when present)
|
│ └── dist/ # Production build (served by API when present)
|
||||||
├── scripts/ # Seed, bios, catalog expansion, image fetch, checkup tools
|
├── scripts/ # Seed, bios, catalog expansion, image fetch, checkup tools
|
||||||
@@ -65,7 +65,8 @@ Gallery/
|
|||||||
├── Inputs/ # External datasets (e.g. PainterPalette.csv)
|
├── Inputs/ # External datasets (e.g. PainterPalette.csv)
|
||||||
├── Output/ # Generated exports (e.g. paintings.csv)
|
├── Output/ # Generated exports (e.g. paintings.csv)
|
||||||
├── data/images/ # Local portraits and paintings (+ thumbs/)
|
├── data/images/ # Local portraits and paintings (+ thumbs/)
|
||||||
├── db/ # SQL schema and migrations (when present)
|
├── db/ # schema.sql, setup-admin.sql, migrate-*.sql
|
||||||
|
├── server/migrate.js # npm run migrate — schema + incremental migrations
|
||||||
├── deploy/ # Production nginx + systemd examples
|
├── deploy/ # Production nginx + systemd examples
|
||||||
├── Documentation/ # This folder
|
├── Documentation/ # This folder
|
||||||
└── .env # DB and port config (not committed)
|
└── .env # DB and port config (not committed)
|
||||||
@@ -301,7 +302,9 @@ Opened from the 3D hall (artist or movement wing — click a frame) or from infl
|
|||||||
|
|
||||||
### Debug mode (developer)
|
### Debug mode (developer)
|
||||||
|
|
||||||
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).
|
When **Debug mode** is enabled from the home header, painting detail and artist biography show a bottom-left panel with image search preview and action buttons. See [Developer tools (image audit)](#developer-tools-image-audit).
|
||||||
|
|
||||||
|
Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens the **More** search-results modal automatically whenever you open a painting or artist bio while debug mode is on.
|
||||||
|
|
||||||
## Key design decisions
|
## Key design decisions
|
||||||
|
|
||||||
@@ -321,22 +324,28 @@ 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 and artist bio |
|
| **Debug mode** | Home header toggle (`client/src/utils/debugMode.ts`) | Persists in `localStorage`; enables debug panel on painting detail and artist bio |
|
||||||
|
| **Show more** | Home header checkbox (same util) | When debug mode is on, auto-opens the **More** modal on each painting / bio page load |
|
||||||
| **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 or artist bio (bottom-left, when debug mode on) | Search preview + five action buttons |
|
| **Debug panel** | Painting detail or artist bio (bottom-left, when debug mode on) | Search preview + action buttons (six on painting detail, five on artist bio) |
|
||||||
|
|
||||||
### Debug panel (painting detail and artist bio)
|
### 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 **five buttons** in two rows:
|
When debug mode is on, a panel at the bottom-left shows the image search query, a preview when a result is found, and action buttons in two or three rows:
|
||||||
|
|
||||||
| Button | Action |
|
| Button | Painting detail | Artist bio |
|
||||||
|--------|--------|
|
|--------|-----------------|------------|
|
||||||
| **Checked** | Sets `checkup_checked` via `PATCH …/checkup-flags` (disabled once already reviewed) |
|
| **Checked** | Sets `checkup_checked` via `PATCH …/checkup-flags` | Same for artist portrait flags |
|
||||||
| **Fix it** | Replaces the local image from the top search result (`POST …/fix-image` or `…/fix-portrait`); sets **Fixed** and **Reviewed** |
|
| **Fix it** | Replaces local image from top search result | Replaces portrait |
|
||||||
| **More** | Opens a modal with up to **20** search results (each shows image resolution when available); click one to apply the same replace as **Fix it** |
|
| **More** | Modal with up to **20** results (resolution shown when known) | Same |
|
||||||
| **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 |
|
| **Clear** | Deletes files, clears DB paths, empty frame | Clears portrait slot |
|
||||||
| **Upload** | File picker for a local image; saves to disk like **Fix it** (thumbnail generated for paintings; portrait resized for artists) |
|
| **Upload** | Local file picker → disk + thumbnail | Local file → portrait |
|
||||||
|
| **Remove entry** | **Painting detail only** — deletes row from DB, removes image files, refreshes 3D gallery, navigates to next/previous work in catalog (or back to gallery if last work). No confirmation dialog. | — |
|
||||||
|
|
||||||
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.
|
After **Fix it**, **More**, **Upload**, or **Clear**, the main view, gallery textures (paintings), and timeline portrait (artists) update without a full page reload. **Remove entry** refetches artist (and movement gallery when relevant) from the API and remounts the 3D hall so the deleted frame disappears immediately.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Influence side-panel thumbnails use **letterboxing** (`object-fit: contain`) so full compositions are visible.
|
||||||
|
|
||||||
### Checkup page
|
### Checkup page
|
||||||
|
|
||||||
@@ -344,7 +353,7 @@ After **Fix it**, **More**, **Upload**, or **Clear**, the main view, gallery tex
|
|||||||
|
|
||||||
**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`, `npm run migrate:artist-checkup-flags`, and `npm run migrate:painting-annotations` once on existing databases. Load notes with `npm run update-painting-annotations` (add `--wikipedia` for overview lines from Wikipedia intro text). After server code changes, restart `npm run dev` so new routes (e.g. clear, upload, portrait debug, annotations) are registered. JSON body limit for uploads is **20 MB** (`express.json` in `server/index.js`); individual files are capped at **15 MB** after decode.
|
Run `npm run migrate:checkup-flags`, `npm run migrate:artist-checkup-flags`, and `npm run migrate:painting-annotations` once on existing databases. Load notes with `npm run update-painting-annotations` (add `--wikipedia` for overview lines from Wikipedia intro text). After server code changes, restart `npm run start` (or `npm run dev:server`) so new routes (e.g. clear, upload, delete painting, portrait debug, annotations) are registered. JSON body limit for uploads is **20 MB** (`express.json` in `server/index.js`); individual files are capped at **15 MB** after decode.
|
||||||
|
|
||||||
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).
|
||||||
|
|
||||||
|
|||||||
@@ -407,7 +407,7 @@ Runs `scripts/find-duplicate-paintings.js`, which reports:
|
|||||||
|
|
||||||
As of a recent audit (~1200 paintings): **52 exact duplicate pairs** (52 removable rows), concentrated in **Hieronymus Bosch** (25), **Albrecht Dürer** (14), and **Domenico Ghirlandaio** (13). Duplicate copies typically share the same image file and have **no influence links**, so the higher id in each pair is safe to delete after review.
|
As of a recent audit (~1200 paintings): **52 exact duplicate pairs** (52 removable rows), concentrated in **Hieronymus Bosch** (25), **Albrecht Dürer** (14), and **Domenico Ghirlandaio** (13). Duplicate copies typically share the same image file and have **no influence links**, so the higher id in each pair is safe to delete after review.
|
||||||
|
|
||||||
**Near-duplicates** (different titles, same work) need curator judgment — e.g. Rublev ids 36, 316, 320, 322, 323 all describe the Trinity icon under different Wikipedia labels; keep id **36** (`Trinity`, wiki `Trinity (Andrei Rublev)`).
|
**Near-duplicates** (different titles, same work) need curator judgment — e.g. Rublev ids 36, 316, 320, 322, 323 all describe the Trinity icon under different Wikipedia labels; keep id **36** (`Trinity`, wiki `Trinity (Andrei Rublev)`). For confirmed duplicates, debug **Remove entry** on painting detail is faster than manual SQL; it deletes files and the row in one step.
|
||||||
|
|
||||||
`expand-paintings.js` skips inserts when normalized titles match, but duplicates can still appear if seed and expansion use different title strings or if influence discovery creates works independently.
|
`expand-paintings.js` skips inserts when normalized titles match, but duplicates can still appear if seed and expansion use different title strings or if influence discovery creates works independently.
|
||||||
|
|
||||||
@@ -415,15 +415,18 @@ 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:
|
||||||
|
|
||||||
|
**Show more** (home header checkbox, `client/src/utils/debugMode.ts`) — when debug mode is on, automatically opens the **More** modal on each painting detail or artist bio page load (same as clicking **More**).
|
||||||
|
|
||||||
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`).
|
1. **Search** — `GET /api/paintings/:id/debug-image-search` (or `…/debug-portrait-search` for artists) tries Google Custom Search (if `GOOGLE_CSE_API_KEY` + `GOOGLE_CSE_CX` are set in `.env`), Google Arts & Culture, Google Images scrape, then DuckDuckGo (`searchGoogleImagesFirst` / `searchArtistPortraitFirst` in `scripts/image-fetcher.js`).
|
||||||
2. **More** — `GET …/debug-image-search/more` or `…/debug-portrait-search/more` returns up to 20 ranked candidates (`searchPaintingImagesMany` / `searchArtistPortraitMany`). The modal shows each thumbnail with **resolution** when the search API provides dimensions; otherwise the client probes via `GET /api/debug/image-proxy`.
|
2. **More** — `GET …/debug-image-search/more` or `…/debug-portrait-search/more` returns up to 20 ranked candidates (`searchPaintingImagesMany` / `searchArtistPortraitMany`). The modal shows each thumbnail with **resolution** when the search API provides dimensions; otherwise the client probes via `GET /api/debug/image-proxy`.
|
||||||
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`.
|
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).
|
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 in JSON (Express body limit **20 MB**; decoded image max **15 MB**), validates with `sharp`, writes to the standard filename under `data/images/`.
|
5. **Upload** — `POST …/upload-image` or `…/upload-portrait` accepts a base64-encoded file in JSON (Express body limit **20 MB**; decoded image max **15 MB**), validates with `sharp`, writes to the standard filename under `data/images/`.
|
||||||
|
6. **Remove entry** (painting detail only) — `DELETE /api/paintings/:id` via `deletePainting()` in `server/image-service.js`: deletes image files, removes the DB row (cascade on influence/annotation tables), refetches artist/movement gallery data, remounts the 3D hall, and navigates to the next or previous catalog work with no confirmation dialog.
|
||||||
|
|
||||||
### Debug panel (painting detail and artist bio)
|
### Debug panel (painting detail and artist bio)
|
||||||
|
|
||||||
With debug mode on, `PaintingDetail.tsx` and `ArtistBio.tsx` show a bottom-left panel with search preview and five buttons:
|
With debug mode on, `PaintingDetail.tsx` and `ArtistBio.tsx` show a bottom-left panel with search preview and action buttons:
|
||||||
|
|
||||||
| Button | API (paintings / portraits) | Effect |
|
| Button | API (paintings / portraits) | Effect |
|
||||||
|--------|----------------------------|--------|
|
|--------|----------------------------|--------|
|
||||||
@@ -432,8 +435,9 @@ With debug mode on, `PaintingDetail.tsx` and `ArtistBio.tsx` show a bottom-left
|
|||||||
| **More** | `GET …/debug-*-search/more` then fix endpoint | Modal with 20 clickable results (resolution label under each thumb); pick one to replace |
|
| **More** | `GET …/debug-*-search/more` then fix endpoint | Modal with 20 clickable results (resolution label under each thumb); pick one to replace |
|
||||||
| **Clear** | `POST …/clear-image` / `…/clear-portrait` | Removes file(s), empty frame in UI |
|
| **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** |
|
| **Upload** | `POST …/upload-image` / `…/upload-portrait` | Local file picker → save like **Fix it** |
|
||||||
|
| **Remove entry** | `DELETE /api/paintings/:id` | **Paintings only** — permanent delete + gallery refresh + catalog navigation |
|
||||||
|
|
||||||
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.
|
The client passes `searchUrl`, `source`, and `thumbUrl` from search results to improve download reliability. After a fix, clear, upload, or remove, `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.
|
||||||
|
|
||||||
|
|||||||
@@ -54,10 +54,12 @@ npm run setup
|
|||||||
Or step by step:
|
Or step by step:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run migrate # apply db/schema.sql
|
npm run migrate # db/schema.sql + db/migrate-*.sql via server/migrate.js
|
||||||
npm run seed # eras, movements, artists, paintings, influences
|
npm run seed # eras, movements, artists, paintings, influences
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`npm run migrate` is safe to re-run on existing databases (uses `IF NOT EXISTS` / `ADD COLUMN IF NOT EXISTS`).
|
||||||
|
|
||||||
If migration fails with permission errors, grant schema rights to the app user first (see [DB_structure.md](DB_structure.md)).
|
If migration fails with permission errors, grant schema rights to the app user first (see [DB_structure.md](DB_structure.md)).
|
||||||
|
|
||||||
### Recommended post-seed steps
|
### Recommended post-seed steps
|
||||||
@@ -236,7 +238,8 @@ 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` |
|
| Debug **More** / **Clear** / **Upload** / **Remove entry** returns 404 | Stale server process | Restart `npm run start` or `npm run dev:server`; routes in `server/index.js` + `server/image-service.js` |
|
||||||
|
| Debug **Remove entry** — button stuck or missing on next painting | Stale client build | `cd client && npm run build`; hard-refresh — detail view remounts per painting id |
|
||||||
| Debug **Upload** returns 413 Payload Too Large | Base64 JSON exceeds body limit | Server allows 20 MB JSON / 15 MB decoded image; compress file or resize before upload |
|
| Debug **Upload** returns 413 Payload Too Large | Base64 JSON exceeds body limit | Server allows 20 MB JSON / 15 MB decoded image; compress file or resize before upload |
|
||||||
| No art-history notes on painting detail | Annotations not migrated or loaded | `npm run migrate:painting-annotations` then `npm run update-painting-annotations` |
|
| No art-history notes on painting detail | Annotations not migrated or loaded | `npm run migrate:painting-annotations` then `npm run update-painting-annotations` |
|
||||||
| **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 |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Art Gallery
|
# Art Gallery
|
||||||
|
|
||||||
Interactive virtual art gallery: zoomable historical timeline with era click-to-zoom, major event markers (vertical guides into the movement flow), branching art-movement streams (click a movement name to enter its **3D movement gallery** — photorealistic period interiors with painted walls, stone, and wood textures; chronological wings with up to ~55 works each, side-wall windows, wing navigator), 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 art-history annotations, 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.
|
Interactive virtual art gallery: zoomable historical timeline with era click-to-zoom, major event markers (vertical guides into the movement flow), branching art-movement streams (click a movement name to enter its **3D movement gallery** — photorealistic period interiors with painted walls, stone, and wood textures; chronological wings with up to ~55 works each, side-wall windows, wing navigator), 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 art-history annotations, prev/next catalog browsing and fullscreen lightbox, debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**; painting detail also **Remove entry**), optional **Show more** auto-opens the search picker, Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
|||||||
@@ -215,6 +215,15 @@ export const api = {
|
|||||||
return res.json() as Promise<FixPaintingImageResult>;
|
return res.json() as Promise<FixPaintingImageResult>;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
deletePainting: (id: number) =>
|
||||||
|
fetch(`${API}/paintings/${id}`, { method: 'DELETE' }).then(async (res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `Remove failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<{ id: number; artistId: number; title: string }>;
|
||||||
|
}),
|
||||||
|
|
||||||
uploadPaintingImage: async (id: number, file: File) => {
|
uploadPaintingImage: async (id: number, file: File) => {
|
||||||
const payload = await fileToBase64Payload(file);
|
const payload = await fileToBase64Payload(file);
|
||||||
return postJsonImageAction<FixPaintingImageResult>(`${API}/paintings/${id}/upload-image`, payload);
|
return postJsonImageAction<FixPaintingImageResult>(`${API}/paintings/${id}/upload-image`, payload);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState, type ChangeEvent } from 'react';
|
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
|
||||||
import type { Artist } from '../types';
|
import type { Artist } from '../types';
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
@@ -15,6 +15,7 @@ import './ArtistBio.css';
|
|||||||
interface Props {
|
interface Props {
|
||||||
artist: Artist & { movement_name?: string };
|
artist: Artist & { movement_name?: string };
|
||||||
debugMode?: boolean;
|
debugMode?: boolean;
|
||||||
|
debugShowMore?: boolean;
|
||||||
portraitRevision?: number;
|
portraitRevision?: number;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onEnterGallery: () => void;
|
onEnterGallery: () => void;
|
||||||
@@ -31,6 +32,7 @@ interface Props {
|
|||||||
export default function ArtistBio({
|
export default function ArtistBio({
|
||||||
artist,
|
artist,
|
||||||
debugMode = false,
|
debugMode = false,
|
||||||
|
debugShowMore = false,
|
||||||
portraitRevision = 0,
|
portraitRevision = 0,
|
||||||
onBack,
|
onBack,
|
||||||
onEnterGallery,
|
onEnterGallery,
|
||||||
@@ -127,7 +129,7 @@ export default function ArtistBio({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenMore = async () => {
|
const handleOpenMore = useCallback(async () => {
|
||||||
setMoreOpen(true);
|
setMoreOpen(true);
|
||||||
setMoreLoading(true);
|
setMoreLoading(true);
|
||||||
setMoreError(null);
|
setMoreError(null);
|
||||||
@@ -140,7 +142,12 @@ export default function ArtistBio({
|
|||||||
} finally {
|
} finally {
|
||||||
setMoreLoading(false);
|
setMoreLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [artist.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!debugMode || !debugShowMore) return;
|
||||||
|
void handleOpenMore();
|
||||||
|
}, [debugMode, debugShowMore, artist.id, handleOpenMore]);
|
||||||
|
|
||||||
const handleSelectMoreResult = async (item: DebugImageSearchResultItem) => {
|
const handleSelectMoreResult = async (item: DebugImageSearchResultItem) => {
|
||||||
if (fixing || applyingUrl) return;
|
if (fixing || applyingUrl) return;
|
||||||
|
|||||||
@@ -540,6 +540,10 @@
|
|||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.debug-action-buttons-danger {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.debug-clear-btn,
|
.debug-clear-btn,
|
||||||
.debug-upload-btn {
|
.debug-upload-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -578,6 +582,27 @@
|
|||||||
cursor: wait;
|
cursor: wait;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.debug-remove-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid rgba(200, 60, 60, 0.65);
|
||||||
|
background: rgba(200, 60, 60, 0.12);
|
||||||
|
color: #e05050;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-remove-btn:hover:not(:disabled) {
|
||||||
|
background: rgba(200, 60, 60, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-remove-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
.painting-frame-empty {
|
.painting-frame-empty {
|
||||||
min-height: 280px;
|
min-height: 280px;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState, type ChangeEvent, type SyntheticEvent } from 'react';
|
import { useCallback, 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 DebugImageSearchResultItem, type FixPaintingImageResult } from '../api/client';
|
import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type DebugImageSearchResultItem, type FixPaintingImageResult } from '../api/client';
|
||||||
import DebugSearchResultsModal from './DebugSearchResultsModal';
|
import DebugSearchResultsModal from './DebugSearchResultsModal';
|
||||||
@@ -15,6 +15,7 @@ interface Props {
|
|||||||
onArtistBio: () => void;
|
onArtistBio: () => void;
|
||||||
onInfluenceArtistClick?: (artistId: number) => void;
|
onInfluenceArtistClick?: (artistId: number) => void;
|
||||||
debugMode?: boolean;
|
debugMode?: boolean;
|
||||||
|
debugShowMore?: boolean;
|
||||||
onPaintingImageFixed?: (
|
onPaintingImageFixed?: (
|
||||||
paintingId: number,
|
paintingId: number,
|
||||||
fixResult: FixPaintingImageResult
|
fixResult: FixPaintingImageResult
|
||||||
@@ -23,6 +24,7 @@ interface Props {
|
|||||||
paintingId: number,
|
paintingId: number,
|
||||||
flags: { checked: boolean; fixed: boolean }
|
flags: { checked: boolean; fixed: boolean }
|
||||||
) => void | Promise<void>;
|
) => void | Promise<void>;
|
||||||
|
onPaintingRemoved?: (paintingId: number, artistId: number) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function influenceKey(inf: InfluenceLink, index: number): string {
|
function influenceKey(inf: InfluenceLink, index: number): string {
|
||||||
@@ -188,8 +190,10 @@ export default function PaintingDetailView({
|
|||||||
onArtistBio,
|
onArtistBio,
|
||||||
onInfluenceArtistClick,
|
onInfluenceArtistClick,
|
||||||
debugMode = false,
|
debugMode = false,
|
||||||
|
debugShowMore = false,
|
||||||
onPaintingImageFixed,
|
onPaintingImageFixed,
|
||||||
onPaintingCheckupFlagsUpdated,
|
onPaintingCheckupFlagsUpdated,
|
||||||
|
onPaintingRemoved,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { painting, influencedBy, influenced, annotations = [] } = data;
|
const { painting, influencedBy, influenced, annotations = [] } = data;
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
@@ -206,6 +210,7 @@ export default function PaintingDetailView({
|
|||||||
const [applyingUrl, setApplyingUrl] = useState<string | null>(null);
|
const [applyingUrl, setApplyingUrl] = useState<string | null>(null);
|
||||||
const [clearing, setClearing] = useState(false);
|
const [clearing, setClearing] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [removing, setRemoving] = useState(false);
|
||||||
const [activeAnnotationId, setActiveAnnotationId] = useState<number | null>(null);
|
const [activeAnnotationId, setActiveAnnotationId] = useState<number | null>(null);
|
||||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -230,6 +235,13 @@ export default function PaintingDetailView({
|
|||||||
setMoreResults(null);
|
setMoreResults(null);
|
||||||
setMoreError(null);
|
setMoreError(null);
|
||||||
setActiveAnnotationId(null);
|
setActiveAnnotationId(null);
|
||||||
|
setRemoving(false);
|
||||||
|
setFixing(false);
|
||||||
|
setClearing(false);
|
||||||
|
setUploading(false);
|
||||||
|
setMarkingChecked(false);
|
||||||
|
setApplyingUrl(null);
|
||||||
|
setMoreLoading(false);
|
||||||
}, [painting.id]);
|
}, [painting.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -298,7 +310,7 @@ export default function PaintingDetailView({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenMore = async () => {
|
const handleOpenMore = useCallback(async () => {
|
||||||
setMoreOpen(true);
|
setMoreOpen(true);
|
||||||
setMoreLoading(true);
|
setMoreLoading(true);
|
||||||
setMoreError(null);
|
setMoreError(null);
|
||||||
@@ -311,7 +323,12 @@ export default function PaintingDetailView({
|
|||||||
} finally {
|
} finally {
|
||||||
setMoreLoading(false);
|
setMoreLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [painting.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!debugMode || !debugShowMore) return;
|
||||||
|
void handleOpenMore();
|
||||||
|
}, [debugMode, debugShowMore, painting.id, handleOpenMore]);
|
||||||
|
|
||||||
const handleSelectMoreResult = async (item: DebugImageSearchResultItem) => {
|
const handleSelectMoreResult = async (item: DebugImageSearchResultItem) => {
|
||||||
if (fixing || applyingUrl) return;
|
if (fixing || applyingUrl) return;
|
||||||
@@ -381,6 +398,18 @@ export default function PaintingDetailView({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRemoveEntry = async () => {
|
||||||
|
if (removing || fixing || clearing || uploading || !onPaintingRemoved) return;
|
||||||
|
setRemoving(true);
|
||||||
|
setDebugError(null);
|
||||||
|
try {
|
||||||
|
await onPaintingRemoved(painting.id, painting.artist_id);
|
||||||
|
} catch (err) {
|
||||||
|
setDebugError(err instanceof Error ? err.message : 'Could not remove painting.');
|
||||||
|
setRemoving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fullscreen) return;
|
if (fullscreen) return;
|
||||||
|
|
||||||
@@ -614,6 +643,16 @@ export default function PaintingDetailView({
|
|||||||
onChange={handleUploadFile}
|
onChange={handleUploadFile}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="debug-action-buttons debug-action-buttons-danger">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="debug-remove-btn"
|
||||||
|
onClick={handleRemoveEntry}
|
||||||
|
disabled={removing || fixing || clearing || uploading}
|
||||||
|
>
|
||||||
|
{removing ? '…' : 'Remove entry'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,42 @@
|
|||||||
color: #e8a040;
|
color: #e8a040;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.debug-show-more-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||||
|
background: rgba(15, 15, 26, 0.85);
|
||||||
|
color: rgba(201, 169, 110, 0.75);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-show-more-toggle input {
|
||||||
|
accent-color: #e8a040;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-show-more-toggle:hover {
|
||||||
|
border-color: #c9a96e;
|
||||||
|
color: #e8d5b5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-show-more-toggle-active {
|
||||||
|
border-color: #e8a040;
|
||||||
|
background: rgba(232, 160, 64, 0.1);
|
||||||
|
color: #e8a040;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-show-more-toggle-muted {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
.checkup-link-btn {
|
.checkup-link-btn {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import CheckupPage from '../pages/CheckupPage';
|
|||||||
import { api, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
import { api, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
||||||
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail, MovementGalleryDetail } from '../types';
|
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail, MovementGalleryDetail } from '../types';
|
||||||
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
|
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
|
||||||
import { readDebugMode, writeDebugMode } from '../utils/debugMode';
|
import { readDebugMode, readDebugShowMore, writeDebugMode, writeDebugShowMore } from '../utils/debugMode';
|
||||||
import './HomePage.css';
|
import './HomePage.css';
|
||||||
|
|
||||||
type View =
|
type View =
|
||||||
@@ -53,6 +53,47 @@ function patchArtistInArtistDetail(detail: ArtistDetail, patch: Partial<Artist>)
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function patchReturnToAfterRemove(
|
||||||
|
returnTo: View,
|
||||||
|
freshArtist?: ArtistDetail,
|
||||||
|
freshMovement?: MovementGalleryDetail
|
||||||
|
): View {
|
||||||
|
if (returnTo.type === 'gallery' && freshArtist && returnTo.artistId === freshArtist.artist.id) {
|
||||||
|
return { ...returnTo, data: freshArtist };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
returnTo.type === 'movement-gallery' &&
|
||||||
|
freshMovement &&
|
||||||
|
returnTo.movementId === freshMovement.movement.id
|
||||||
|
) {
|
||||||
|
return { ...returnTo, data: freshMovement };
|
||||||
|
}
|
||||||
|
if (returnTo.type === 'painting') {
|
||||||
|
return { ...returnTo, returnTo: patchReturnToAfterRemove(returnTo.returnTo, freshArtist, freshMovement) };
|
||||||
|
}
|
||||||
|
if (returnTo.type === 'bio') {
|
||||||
|
const data =
|
||||||
|
freshArtist && returnTo.artistId === freshArtist.artist.id ? freshArtist : returnTo.data;
|
||||||
|
return {
|
||||||
|
...returnTo,
|
||||||
|
data,
|
||||||
|
returnTo: patchReturnToAfterRemove(returnTo.returnTo, freshArtist, freshMovement),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return returnTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
function catalogNavigateTarget(
|
||||||
|
sorted: Painting[],
|
||||||
|
removedId: number
|
||||||
|
): number | null {
|
||||||
|
const idx = sorted.findIndex((p) => p.id === removedId);
|
||||||
|
if (idx < 0) return null;
|
||||||
|
const remaining = sorted.filter((p) => p.id !== removedId);
|
||||||
|
if (remaining.length === 0) return null;
|
||||||
|
return idx < remaining.length ? remaining[idx].id : remaining[remaining.length - 1].id;
|
||||||
|
}
|
||||||
|
|
||||||
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<GallerySession | null>(null);
|
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
|
||||||
@@ -67,6 +108,10 @@ export default function HomePage() {
|
|||||||
const [imageRevisions, setImageRevisions] = useState<Record<number, number>>({});
|
const [imageRevisions, setImageRevisions] = useState<Record<number, number>>({});
|
||||||
const [portraitRevisions, setPortraitRevisions] = useState<Record<number, number>>({});
|
const [portraitRevisions, setPortraitRevisions] = useState<Record<number, number>>({});
|
||||||
const [debugMode, setDebugMode] = useState(readDebugMode);
|
const [debugMode, setDebugMode] = useState(readDebugMode);
|
||||||
|
const [debugShowMore, setDebugShowMore] = useState(readDebugShowMore);
|
||||||
|
const [galleryRevision, setGalleryRevision] = useState(0);
|
||||||
|
const viewRef = useRef(view);
|
||||||
|
viewRef.current = view;
|
||||||
const [hoveredLifespan, setHoveredLifespan] = useState<{
|
const [hoveredLifespan, setHoveredLifespan] = useState<{
|
||||||
birthYear: number;
|
birthYear: number;
|
||||||
deathYear: number;
|
deathYear: number;
|
||||||
@@ -130,6 +175,11 @@ export default function HomePage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setDebugShowMoreEnabled = (enabled: boolean) => {
|
||||||
|
setDebugShowMore(enabled);
|
||||||
|
writeDebugShowMore(enabled);
|
||||||
|
};
|
||||||
|
|
||||||
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> = {
|
||||||
@@ -344,6 +394,95 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handlePaintingRemoved = useCallback(
|
||||||
|
async (paintingId: number, artistId: number) => {
|
||||||
|
const currentView = viewRef.current;
|
||||||
|
if (currentView.type !== 'painting' || currentView.paintingId !== paintingId) return;
|
||||||
|
|
||||||
|
const sorted = sortArtistPaintingsChronological(detailArtistPaintings);
|
||||||
|
const nextId = catalogNavigateTarget(sorted, paintingId);
|
||||||
|
const inMovementCatalog =
|
||||||
|
gallerySession?.kind === 'movement' || currentView.returnTo.type === 'movement-gallery';
|
||||||
|
|
||||||
|
await api.deletePainting(paintingId);
|
||||||
|
|
||||||
|
const freshArtist = await api.getArtist(artistId);
|
||||||
|
let freshMovement: MovementGalleryDetail | undefined;
|
||||||
|
if (gallerySession?.kind === 'movement') {
|
||||||
|
freshMovement = await api.getMovementGallery(gallerySession.movementId);
|
||||||
|
} else if (currentView.returnTo.type === 'movement-gallery') {
|
||||||
|
freshMovement = await api.getMovementGallery(currentView.returnTo.movementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const freshCatalog =
|
||||||
|
inMovementCatalog && freshMovement
|
||||||
|
? sortArtistPaintingsChronological(freshMovement.paintings)
|
||||||
|
: sortArtistPaintingsChronological(freshArtist.paintings);
|
||||||
|
|
||||||
|
const removedIdx = sorted.findIndex((p) => p.id === paintingId);
|
||||||
|
const navigateId =
|
||||||
|
nextId && freshCatalog.some((p) => p.id === nextId)
|
||||||
|
? nextId
|
||||||
|
: freshCatalog.length > 0
|
||||||
|
? freshCatalog[Math.min(removedIdx, freshCatalog.length - 1)]?.id ??
|
||||||
|
freshCatalog[0].id
|
||||||
|
: null;
|
||||||
|
|
||||||
|
setDetailArtistPaintings(freshCatalog);
|
||||||
|
|
||||||
|
setGallerySession((session) => {
|
||||||
|
if (!session) return session;
|
||||||
|
if (session.kind === 'artist' && session.artistId === artistId) {
|
||||||
|
return { ...session, data: freshArtist };
|
||||||
|
}
|
||||||
|
if (session.kind === 'movement' && freshMovement) {
|
||||||
|
return { ...session, data: freshMovement };
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
});
|
||||||
|
|
||||||
|
setImageRevisions((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[paintingId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
setGalleryRevision((v) => v + 1);
|
||||||
|
|
||||||
|
const patchedReturnTo = patchReturnToAfterRemove(
|
||||||
|
currentView.returnTo,
|
||||||
|
freshArtist,
|
||||||
|
freshMovement
|
||||||
|
);
|
||||||
|
detailReturnToRef.current = patchedReturnTo;
|
||||||
|
|
||||||
|
setView((current) => {
|
||||||
|
if (current.type === 'gallery' && current.artistId === artistId) {
|
||||||
|
return { ...current, data: freshArtist };
|
||||||
|
}
|
||||||
|
if (current.type === 'movement-gallery' && freshMovement) {
|
||||||
|
return { ...current, data: freshMovement };
|
||||||
|
}
|
||||||
|
if (current.type !== 'painting' || current.paintingId !== paintingId) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
if (navigateId) return current;
|
||||||
|
return patchedReturnTo;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (navigateId) {
|
||||||
|
const data = await api.getPainting(navigateId);
|
||||||
|
setView({
|
||||||
|
type: 'painting',
|
||||||
|
paintingId: navigateId,
|
||||||
|
data,
|
||||||
|
returnTo: patchedReturnTo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[detailArtistPaintings, gallerySession]
|
||||||
|
);
|
||||||
|
|
||||||
const handleBioClick = (artistData: ArtistDetail, returnTo: View) => {
|
const handleBioClick = (artistData: ArtistDetail, returnTo: View) => {
|
||||||
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo });
|
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo });
|
||||||
};
|
};
|
||||||
@@ -393,6 +532,7 @@ export default function HomePage() {
|
|||||||
<div className={galleryActive ? undefined : 'gallery-session-suspended'} aria-hidden={!galleryActive}>
|
<div className={galleryActive ? undefined : 'gallery-session-suspended'} aria-hidden={!galleryActive}>
|
||||||
{gallerySession.kind === 'artist' ? (
|
{gallerySession.kind === 'artist' ? (
|
||||||
<VirtualGallery
|
<VirtualGallery
|
||||||
|
key={`artist-${gallerySession.artistId}-${galleryRevision}`}
|
||||||
mode="artist"
|
mode="artist"
|
||||||
data={gallerySession.data}
|
data={gallerySession.data}
|
||||||
imageRevisions={imageRevisions}
|
imageRevisions={imageRevisions}
|
||||||
@@ -410,6 +550,7 @@ export default function HomePage() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<VirtualGallery
|
<VirtualGallery
|
||||||
|
key={`movement-${gallerySession.movementId}-${galleryRevision}`}
|
||||||
mode="movement"
|
mode="movement"
|
||||||
data={gallerySession.data}
|
data={gallerySession.data}
|
||||||
imageRevisions={imageRevisions}
|
imageRevisions={imageRevisions}
|
||||||
@@ -424,6 +565,7 @@ export default function HomePage() {
|
|||||||
{view.type === 'painting' && (
|
{view.type === 'painting' && (
|
||||||
<div className="home-overlay">
|
<div className="home-overlay">
|
||||||
<PaintingDetailView
|
<PaintingDetailView
|
||||||
|
key={view.paintingId}
|
||||||
data={view.data}
|
data={view.data}
|
||||||
artistPaintings={sortedDetailArtistPaintings}
|
artistPaintings={sortedDetailArtistPaintings}
|
||||||
onBack={() => {
|
onBack={() => {
|
||||||
@@ -460,8 +602,10 @@ export default function HomePage() {
|
|||||||
}}
|
}}
|
||||||
onInfluenceArtistClick={handleArtistClick}
|
onInfluenceArtistClick={handleArtistClick}
|
||||||
debugMode={debugMode}
|
debugMode={debugMode}
|
||||||
|
debugShowMore={debugShowMore}
|
||||||
onPaintingImageFixed={handlePaintingImageFixed}
|
onPaintingImageFixed={handlePaintingImageFixed}
|
||||||
onPaintingCheckupFlagsUpdated={handlePaintingCheckupFlagsUpdated}
|
onPaintingCheckupFlagsUpdated={handlePaintingCheckupFlagsUpdated}
|
||||||
|
onPaintingRemoved={handlePaintingRemoved}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -471,6 +615,7 @@ export default function HomePage() {
|
|||||||
<ArtistBio
|
<ArtistBio
|
||||||
artist={view.data.artist}
|
artist={view.data.artist}
|
||||||
debugMode={debugMode}
|
debugMode={debugMode}
|
||||||
|
debugShowMore={debugShowMore}
|
||||||
portraitRevision={portraitRevisions[view.data.artist.id]}
|
portraitRevision={portraitRevisions[view.data.artist.id]}
|
||||||
onBack={() => setView(view.returnTo)}
|
onBack={() => setView(view.returnTo)}
|
||||||
onEnterGallery={() =>
|
onEnterGallery={() =>
|
||||||
@@ -501,6 +646,17 @@ export default function HomePage() {
|
|||||||
>
|
>
|
||||||
Debug mode{debugMode ? ': ON' : ''}
|
Debug mode{debugMode ? ': ON' : ''}
|
||||||
</button>
|
</button>
|
||||||
|
<label
|
||||||
|
className={`debug-show-more-toggle${debugShowMore ? ' debug-show-more-toggle-active' : ''}${!debugMode ? ' debug-show-more-toggle-muted' : ''}`}
|
||||||
|
title="When debug mode is on, open the More search results popup automatically on each painting or artist page"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={debugShowMore}
|
||||||
|
onChange={(e) => setDebugShowMoreEnabled(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Show more
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="checkup-link-btn"
|
className="checkup-link-btn"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const DEBUG_MODE_KEY = 'gallery-debug-mode';
|
const DEBUG_MODE_KEY = 'gallery-debug-mode';
|
||||||
|
const DEBUG_SHOW_MORE_KEY = 'gallery-debug-show-more';
|
||||||
|
|
||||||
export function readDebugMode(): boolean {
|
export function readDebugMode(): boolean {
|
||||||
try {
|
try {
|
||||||
@@ -15,3 +16,19 @@ export function writeDebugMode(enabled: boolean): void {
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function readDebugShowMore(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(DEBUG_SHOW_MORE_KEY) === '1';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeDebugShowMore(enabled: boolean): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DEBUG_SHOW_MORE_KEY, enabled ? '1' : '0');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.9 MiB After Width: | Height: | Size: 5.8 MiB |
|
Before Width: | Height: | Size: 428 KiB After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 211 KiB |
@@ -189,6 +189,30 @@ function unlinkPortraitFiles(row, safeBase) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deletePainting(paintingId) {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT p.id, p.title, p.image_path, p.thumbnail_path, p.artist_id, 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);
|
||||||
|
|
||||||
|
inflight.delete(`${paintingId}:thumb`);
|
||||||
|
inflight.delete(`${paintingId}:full`);
|
||||||
|
|
||||||
|
await pool.query(`DELETE FROM paintings WHERE id = $1`, [paintingId]);
|
||||||
|
|
||||||
|
return { id: row.id, artistId: row.artist_id, title: row.title };
|
||||||
|
}
|
||||||
|
|
||||||
async function clearPaintingImage(paintingId) {
|
async function clearPaintingImage(paintingId) {
|
||||||
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
|
||||||
@@ -436,6 +460,7 @@ module.exports = {
|
|||||||
replacePaintingImageFromUrl,
|
replacePaintingImageFromUrl,
|
||||||
replaceArtistPortraitFromUrl,
|
replaceArtistPortraitFromUrl,
|
||||||
clearPaintingImage,
|
clearPaintingImage,
|
||||||
|
deletePainting,
|
||||||
clearArtistPortrait,
|
clearArtistPortrait,
|
||||||
replacePaintingImageFromBuffer,
|
replacePaintingImageFromBuffer,
|
||||||
replaceArtistPortraitFromBuffer,
|
replaceArtistPortraitFromBuffer,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const fs = require('fs');
|
|||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const pool = require('./db');
|
const pool = require('./db');
|
||||||
const { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, IMAGE_DIR } = require('./image-service');
|
const { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, deletePainting, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, IMAGE_DIR } = require('./image-service');
|
||||||
const { searchGoogleImagesFirst, searchArtistPortraitFirst, searchPaintingImagesMany, searchArtistPortraitMany, 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();
|
||||||
@@ -733,6 +733,21 @@ app.post('/api/paintings/:id/fix-image', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.delete('/api/paintings/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const paintingId = parseInt(req.params.id, 10);
|
||||||
|
if (!Number.isFinite(paintingId)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid painting id' });
|
||||||
|
}
|
||||||
|
const removed = await deletePainting(paintingId);
|
||||||
|
res.json(removed);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete painting error:', err.message);
|
||||||
|
const status = err.message === 'Painting not found' ? 404 : 500;
|
||||||
|
res.status(status).json({ error: err.message || 'Could not remove painting' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/paintings/:id/clear-image', async (req, res) => {
|
app.post('/api/paintings/:id/clear-image', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const paintingId = parseInt(req.params.id, 10);
|
const paintingId = parseInt(req.params.id, 10);
|
||||||
|
|||||||