Paintings get editable curator notes with brass plates in the 3D hall, and visit order now uses the far/end wall between left and right. Co-authored-by: Cursor <cursoragent@cursor.com>
912 lines
30 KiB
Markdown
912 lines
30 KiB
Markdown
# Art Gallery — REST API
|
||
|
||
Base URL (paths are the same on every host; only the origin changes):
|
||
|
||
| Context | Base URL |
|
||
|---------|----------|
|
||
| **Production (public)** | `https://gallery.mysuperlab.netcraze.pro` |
|
||
| **Production (LAN)** | `http://192.168.10.122:5173` |
|
||
| **Development (public)** | `https://devgallery.mysuperlab.netcraze.pro` |
|
||
| **Development (LAN)** | `http://192.168.10.70:5173` |
|
||
| **Local Vite proxy** | `http://localhost:5173` (proxies `/api` and `/images` to API on `:3451`) |
|
||
| **Local API only** | `http://localhost:3451` (when using `npm run dev:web`) |
|
||
|
||
See [environments.md](environments.md) for Keenetic rules, databases, and deploy.
|
||
|
||
All JSON responses use `Content-Type: application/json`. Errors return `{ "error": "message" }` with an appropriate HTTP status.
|
||
|
||
Static images are served at `/images/<relative-path>` from `IMAGE_DIR`.
|
||
|
||
**Caching:** `/images` responses use `Cache-Control: public, max-age=0, must-revalidate` with `ETag` / `Last-Modified`. Painting and artist JSON payloads include optional **`image_cache_key`** / **`thumbnail_cache_key`** (and **`portrait_cache_key`** / **`portrait_thumb_cache_key`** on artists) — Unix ms from the file’s `mtime` on disk. The client appends `?v=<key>` to image URLs so fix/upload/clear updates show immediately after reload even when the relative path is unchanged.
|
||
|
||
**Quick check:**
|
||
|
||
```powershell
|
||
curl.exe -sk https://gallery.mysuperlab.netcraze.pro/api/bounds
|
||
curl.exe -sk https://devgallery.mysuperlab.netcraze.pro/api/bounds
|
||
```
|
||
|
||
---
|
||
|
||
## Authentication
|
||
|
||
Anonymous visitors have implicit role **`user`** (browse only). **Curator** accounts unlock debug mode, the Checkup page, and all mutating audit routes.
|
||
|
||
Sessions use an HTTP-only cookie (`gallery.sid`). The client sends `credentials: 'include'` on API requests.
|
||
|
||
### `GET /api/auth/me`
|
||
|
||
**Response (anonymous)**
|
||
|
||
```json
|
||
{ "role": "user" }
|
||
```
|
||
|
||
**Response (curator session)**
|
||
|
||
```json
|
||
{ "role": "curator", "username": "curator" }
|
||
```
|
||
|
||
### `POST /api/auth/login`
|
||
|
||
**Body:** `{ "username": "curator", "password": "…" }`
|
||
|
||
**Response:** `{ "role": "curator", "username": "curator" }`
|
||
|
||
**Errors:** `401` invalid credentials, `400` missing fields.
|
||
|
||
### `POST /api/auth/logout`
|
||
|
||
Destroys the session cookie.
|
||
|
||
**Response:** `{ "ok": true }`
|
||
|
||
### Curator-only routes
|
||
|
||
These return **`401`** with `{ "error": "Curator login required" }` without a valid curator session:
|
||
|
||
| Route | Audit action (mutations only) |
|
||
|-------|-------------------------------|
|
||
| `GET /api/paintings/checkup` | — (read) |
|
||
| `GET /api/paintings/:id/debug-image-search` (+ `/more`) | — |
|
||
| `GET /api/artists/:id/debug-portrait-search` (+ `/more`) | — |
|
||
| `GET /api/debug/image-proxy` | — |
|
||
| `PATCH /api/paintings/:id/checkup-flags` | `painting.checkup_flags` |
|
||
| `PATCH /api/paintings/:id/curator-notes` | `painting.update_curator_notes` |
|
||
| `PATCH /api/artists/:id/checkup-flags` | `artist.checkup_flags` |
|
||
| `POST /api/paintings/:id/fix-image` | `painting.fix_image` |
|
||
| `POST /api/paintings/:id/clear-image` | `painting.clear_image` |
|
||
| `POST /api/paintings/:id/upload-image` | `painting.upload_image` |
|
||
| `DELETE /api/paintings/:id` | `painting.delete` |
|
||
| `POST /api/artists/:id/fix-portrait` | `artist.fix_portrait` |
|
||
| `POST /api/artists/:id/clear-portrait` | `artist.clear_portrait` |
|
||
| `POST /api/artists/:id/upload-portrait` | `artist.upload_portrait` |
|
||
|
||
**Public** (no login): all catalog `GET` routes, `POST /api/artists/:id/preload-images` (local file linking for 3D halls), `/images`, SPA static.
|
||
|
||
Curator mutations are recorded in `curator_audit_log` (see [DB_structure.md](DB_structure.md)).
|
||
|
||
---
|
||
|
||
## `GET /api/catalog/bootstrap`
|
||
|
||
**Preferred for timeline first paint.** Returns bounds, eras, movements, and slim artist rows in a single response (replaces the separate `bounds` + `timeline` + `artists?timeline=1` waterfall).
|
||
|
||
**Query**
|
||
|
||
| Param | Type | Default | Description |
|
||
|-------|------|---------|-------------|
|
||
| `start` | int | bounds `min_year` | Window start year |
|
||
| `end` | int | bounds `max_year` | Window end year |
|
||
|
||
**Response**
|
||
|
||
```json
|
||
{
|
||
"bounds": { "min_year": -800, "max_year": 2100 },
|
||
"eras": [ … ],
|
||
"movements": [ … ],
|
||
"artists": [
|
||
{
|
||
"id": 1,
|
||
"name": "Claude Monet",
|
||
"birth_year": 1840,
|
||
"death_year": 1926,
|
||
"movement_id": 12,
|
||
"portrait_path": "portraits/Claude_Monet.jpg",
|
||
"portrait_thumb_path": "portraits/thumbs/Claude_Monet_thumb.jpg",
|
||
"wikipedia_title": "Claude Monet",
|
||
"century": 19,
|
||
"movement_name": "Impressionism",
|
||
"movement_color": "#6B8E9F"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
**Caching:** `Cache-Control: public, max-age=300` with `ETag` (304 when catalog row counts unchanged).
|
||
|
||
The React home page loads this endpoint **once** on mount. Pan and zoom filter movements and portraits **client-side** — no refetch per view change.
|
||
|
||
---
|
||
|
||
## `GET /api/bounds`
|
||
|
||
Returns the overall timeline year range used to initialise the zoomable timeline.
|
||
|
||
**Response**
|
||
|
||
```json
|
||
{
|
||
"min_year": -800,
|
||
"max_year": 2100
|
||
}
|
||
```
|
||
|
||
`min_year` is the earliest art movement start; `max_year` is the latest of era ends, movement ends, and artist death years.
|
||
|
||
---
|
||
|
||
## `GET /api/timeline`
|
||
|
||
Historical eras and art movements overlapping a year window.
|
||
|
||
**Query**
|
||
|
||
| Param | Type | Default | Description |
|
||
|-------|------|---------|-------------|
|
||
| `start` | int | -3000 | Window start year |
|
||
| `end` | int | 2100 | Window end year |
|
||
|
||
**Response**
|
||
|
||
```json
|
||
{
|
||
"eras": [ { "id": 1, "name": "Renaissance", "start_year": 1300, "end_year": 1600, "start_definite": false, "end_definite": false, "description": "...", "sort_order": 3 } ],
|
||
"movements": [ { "id": 5, "name": "Impressionism", "start_year": 1860, "end_year": 1890, "era_id": 6, "era_name": "Modern", "color": "#87CEEB", ... } ]
|
||
}
|
||
```
|
||
|
||
Movements are filtered to those with at least one artist active in the requested range.
|
||
|
||
---
|
||
|
||
## Locale (`?locale=ru`)
|
||
|
||
Public catalog endpoints accept optional **`locale`** query param (`en` default, `ru` supported) or `Accept-Language: ru`.
|
||
|
||
Affected routes: `/api/catalog/bootstrap`, `/api/timeline`, `/api/search`, `/api/artists`, `/api/artists/:id`, `/api/paintings/:id`, `/api/movements/:id/gallery`, `/api/movements/:id/artists`, `/api/artists/:id/navigation`.
|
||
|
||
Responses include `"locale": "ru"` when resolved. Display field names are unchanged; values come from `entity_translations` when `status = published`, else canonical English.
|
||
|
||
Full guide: [i18n-russian.md](i18n-russian.md).
|
||
|
||
---
|
||
|
||
## Translations (curator)
|
||
|
||
Requires curator session. Base path: `/api/translations`.
|
||
|
||
| Method | Path | Purpose |
|
||
|--------|------|---------|
|
||
| `GET` | `/api/translations/coverage?locale=ru` | Coverage stats |
|
||
| `GET` | `/api/translations/worklist/:entityType?locale=ru` | Artists/paintings/movements with translation status |
|
||
| `GET` | `/api/translations/:entityType/:id` | Canonical + all translation rows |
|
||
| `PUT` | `/api/translations/:entityType/:id` | Upsert fields `{ locale, fields, status }` |
|
||
| `POST` | `/api/translations/:entityType/:id/publish` | Publish all draft/reviewed rows for locale |
|
||
|
||
## Influences (curator)
|
||
|
||
Requires curator session. Base path: `/api/influences`. See [influence-import.md](influence-import.md).
|
||
|
||
| Method | Path | Purpose |
|
||
|--------|------|---------|
|
||
| `GET` | `/api/influences` | List edges (`artistId`, `paintingId`, `q`, pagination) |
|
||
| `GET` | `/api/influences/graph?artistId=` | Neighborhood nodes/edges for visualization |
|
||
| `POST` | `/api/influences` | Create edge `{ paintingId, sourceType, sourceArtistId\|sourcePaintingId\|sourceMovementId, … }` |
|
||
| `PATCH` | `/api/influences/:id` | Update notes / remap source |
|
||
| `DELETE` | `/api/influences/:id` | Delete edge |
|
||
| `POST` | `/api/influences/import/parse` | Parse upload `{ filename, contentBase64, sheet? }` — returns `contentHash` / `payloadHash` / `alreadyImported` |
|
||
| `POST` | `/api/influences/import/preview` | Validate `{ rows, mapping, contentHash?, payloadHash? }` |
|
||
| `POST` | `/api/influences/import/commit` | Insert `{ proposals, contentHash?, payloadHash?, force? }` — `409` if duplicate unless `force` |
|
||
|
||
Public painting detail still exposes read-only `influencedBy` / `influenced` (unchanged).
|
||
|
||
---
|
||
|
||
## Tours
|
||
|
||
Base path: `/api/tours`. Full guide: [tours.md](tours.md).
|
||
|
||
| Method | Path | Auth | Purpose |
|
||
|--------|------|------|---------|
|
||
| `GET` | `/api/tours` | public | Published tour summaries (`id`, `title`, `description`, cover thumb, `stopCount`) |
|
||
| `GET` | `/api/tours/:id` | public* | Full tour + ordered `paintings` + `stopBodies` (*draft tours require curator session) |
|
||
| `GET` | `/api/tours/admin` | curator | All tours (any status) |
|
||
| `POST` | `/api/tours` | curator | Create `{ title, description?, status? }` |
|
||
| `PATCH` | `/api/tours/:id` | curator | Update title / description / status / cover |
|
||
| `DELETE` | `/api/tours/:id` | curator | Delete tour + stops |
|
||
| `PUT` | `/api/tours/:id/stops` | curator | Replace ordered stops `{ stops: [{ paintingId, body }] }` |
|
||
|
||
Detail response shape:
|
||
|
||
```json
|
||
{
|
||
"tour": { "id": 1, "title": "…", "status": "published", "stopCount": 5 },
|
||
"paintings": [ /* Painting rows in stop order */ ],
|
||
"stopBodies": { "42": "Tour text for this stop…" }
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## `GET /api/search`
|
||
|
||
Public catalog search over **artists**, **paintings**, and **art movements**. Used by the timeline header search bar (`CatalogSearchBar.tsx`).
|
||
|
||
**Query**
|
||
|
||
| Param | Type | Default | Description |
|
||
|-------|------|---------|-------------|
|
||
| `q` | string | — | Search text (min **2** characters after trim; shorter returns `{ q, results: [] }`) |
|
||
| `limit` | int | 20 | Max results total (capped at **50**) |
|
||
| `types` | string | all | Optional comma list: `artist`, `painting`, `movement` |
|
||
| `locale` | string | `en` | `ru` — search and return published Russian aliases when available |
|
||
|
||
**Matching (case-insensitive `ILIKE`):**
|
||
|
||
| Entity | Fields |
|
||
|--------|--------|
|
||
| Artist | `name`, `wikipedia_title`, movement name |
|
||
| Movement | movement `name`, era name |
|
||
| Painting | `title`, `wikipedia_title`, `year` (as text), artist name, movement name |
|
||
| All (when `locale=ru`) | Published rows in `entity_translations` for `name` / `title` |
|
||
|
||
Prefix matches on primary labels (`name` / `title`) rank before substring matches.
|
||
|
||
**Response**
|
||
|
||
```json
|
||
{
|
||
"q": "monet",
|
||
"results": [
|
||
{
|
||
"type": "artist",
|
||
"id": 19,
|
||
"name": "Claude Monet",
|
||
"birth_year": 1840,
|
||
"death_year": 1926,
|
||
"movement_name": "Impressionism",
|
||
"portrait_path": "portraits/Claude_Monet.jpg",
|
||
"portrait_thumb_path": "portraits/thumbs/Claude_Monet_thumb.jpg"
|
||
},
|
||
{
|
||
"type": "movement",
|
||
"id": 12,
|
||
"name": "Impressionism",
|
||
"color": "#87CEEB",
|
||
"start_year": 1860,
|
||
"end_year": 1890,
|
||
"era_name": "Modern"
|
||
},
|
||
{
|
||
"type": "painting",
|
||
"id": 241,
|
||
"title": "Water Lilies",
|
||
"year": 1919,
|
||
"artist_id": 19,
|
||
"artist_name": "Claude Monet",
|
||
"movement_name": "Impressionism",
|
||
"thumbnail_path": "paintings/thumbs/Claude_Monet_Water_Lilies_thumb.jpg",
|
||
"image_path": "paintings/Claude_Monet_Water_Lilies.jpg"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
**Indexes:** applied by `npm run dev:migrate` (`db/migrate-search.sql`) or standalone `npm run dev:migrate:search`.
|
||
|
||
**Client:** `api.search(q, { limit?, types? })`.
|
||
|
||
**Navigation from search:** choosing a **painting** opens detail with `returnTo: timeline`; the client shows **← Back to Timeline** and calls `goToTimelineHome()` (clears gallery session, resets timeline zoom). Choosing an **artist** or **movement** uses the normal gallery entry handlers.
|
||
|
||
---
|
||
|
||
## `GET /api/artists`
|
||
|
||
Artists for timeline portraits and the movement flow diagram.
|
||
|
||
**Query**
|
||
|
||
| Param | Type | Description |
|
||
|-------|------|-------------|
|
||
| `start` | int | Only artists alive after this year |
|
||
| `end` | int | Only artists born before this year |
|
||
| `movement_id` | int | Filter by movement |
|
||
| `timeline` | bool | When `1` or `true`, return a lightweight row set for the home-page timeline (omits `bio_short`, `bio_full`; includes `portrait_thumb_path`) |
|
||
|
||
**Response** — array of artist objects with joined `movement_name` and `movement_color`.
|
||
|
||
The React home page loads the timeline catalog **once** on mount via `GET /api/catalog/bootstrap` (or legacy: `GET /api/bounds` + `GET /api/timeline` + `GET /api/artists?timeline=1`). Pan and zoom filter movements and portraits **client-side** — no refetch per view change. Rapid pan/zoom is batched with `createViewChangeScheduler()` (one React update per animation frame).
|
||
|
||
---
|
||
|
||
## `GET /api/movements/:id/artists`
|
||
|
||
Artists belonging to a single movement.
|
||
|
||
**Response** — array of `{ id, name, birth_year, death_year, portrait_path, bio_short }`.
|
||
|
||
---
|
||
|
||
## `GET /api/movements/:id/gallery`
|
||
|
||
Full **movement gallery** payload for the 3D movement wings view (opened by clicking a movement name on the home page).
|
||
|
||
**Response**
|
||
|
||
```json
|
||
{
|
||
"movement": {
|
||
"id": 34,
|
||
"name": "Baroque",
|
||
"start_year": 1600,
|
||
"end_year": 1750,
|
||
"era_id": 6,
|
||
"era_name": "Baroque",
|
||
"color": "#8B4513",
|
||
...
|
||
},
|
||
"paintings": [
|
||
{
|
||
"id": 120,
|
||
"title": "...",
|
||
"year": 1640,
|
||
"artist_id": 5,
|
||
"artist_name": "Rembrandt",
|
||
"image_path": "paintings/...",
|
||
"thumbnail_path": "paintings/thumbs/...",
|
||
"checkup_checked": false,
|
||
"checkup_fixed": false,
|
||
"has_influence_links": true,
|
||
...
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
| Field | Meaning |
|
||
|-------|---------|
|
||
| `movement.era_name` | Joined from `historical_eras` — used to pick period interior styling |
|
||
| `paintings[].artist_name` | Artist display name for frame captions (`year · artist`) |
|
||
| `paintings` order | Chronological: `year`, then `sort_order`, artist birth year, title |
|
||
|
||
Includes all paintings whose `artist.movement_id` matches `:id`. Returns **404** if the movement does not exist.
|
||
|
||
**Client:** `api.getMovementGallery(id)` in `client/src/api/client.ts`; rendered by `VirtualGallery` in `mode: 'movement'`.
|
||
|
||
---
|
||
|
||
## `GET /api/artists/:id`
|
||
|
||
Full artist profile for the bio page and 3D gallery entry.
|
||
|
||
**Response**
|
||
|
||
```json
|
||
{
|
||
"artist": {
|
||
"id": 1,
|
||
"name": "...",
|
||
"birth_year": 1840,
|
||
"death_year": 1926,
|
||
"movement_name": "...",
|
||
"portrait_path": "portraits/Claude_Monet.jpg",
|
||
"bio_short": "First two sentences from Wikipedia…",
|
||
"bio_full": "Full Wikipedia lead section…",
|
||
"wikipedia_title": "Claude Monet",
|
||
"checkup_checked": false,
|
||
"checkup_fixed": false,
|
||
"palette_metadata": { "nationality": "French", "styles": "Impressionism, …", "source": "PainterPalette.csv", ... }
|
||
},
|
||
"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, ... } ]
|
||
}
|
||
```
|
||
|
||
Each painting includes:
|
||
|
||
| Field | Meaning |
|
||
|-------|---------|
|
||
| `has_influence_links` | `true` when the work appears in any influence row — 3D gallery shows a golden lamp above the frame |
|
||
| `checkup_checked` | Reviewed in checkup / debug workflow (gold frame in 3D when true) |
|
||
| `checkup_fixed` | Image replaced via **Fix it** |
|
||
|
||
Populate biographies with `npm run dev: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). After `npm run dev:import-painter-palette`, **`palette_metadata`** holds PainterPalette enrichment (nationality, styles, occupations, raw influence fields, etc.). Run `npm run dev:migrate:artist-checkup-flags` and `npm run dev:migrate:artist-palette` 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", "width": 1200, "height": 1600 }
|
||
]
|
||
}
|
||
```
|
||
|
||
Each result may include optional `width` and `height` (pixels). The **More** modal shows these under each thumbnail; when missing, the client probes dimensions via `GET /api/debug/image-proxy`.
|
||
|
||
---
|
||
|
||
## `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 decoded size 15 MB (JSON body limit 20 MB on the server). **Response** — same as `fix-portrait`.
|
||
|
||
---
|
||
|
||
## `GET /api/artists/:id/navigation`
|
||
|
||
Related artists for hall-to-hall navigation at the exit doorway. Derived from **`painting_influence_sources`**:
|
||
|
||
- **Predecessors** — artists whose works (or artist records) influenced this artist’s paintings.
|
||
- **Successors** — artists whose paintings were influenced by this artist’s works (via painting sources), or whose paintings cite this artist as a source (via artist sources).
|
||
|
||
Both lists are grouped by art movement and exclude the current artist. Each artist appears once even when multiple influence edges exist.
|
||
|
||
**Response**
|
||
|
||
```json
|
||
{
|
||
"predecessors": [
|
||
{
|
||
"movement_id": 4,
|
||
"movement_name": "Early Renaissance",
|
||
"movement_color": "#B8860B",
|
||
"artists": [
|
||
{
|
||
"id": 12,
|
||
"name": "Domenico Ghirlandaio",
|
||
"birth_year": 1448,
|
||
"death_year": 1494,
|
||
"portrait_path": "portraits/Domenico_Ghirlandaio.jpg"
|
||
}
|
||
]
|
||
}
|
||
],
|
||
"successors": []
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## `POST /api/artists/:id/preload-images`
|
||
|
||
**Public** — no curator login required.
|
||
|
||
Fast local scan: links paintings to files already on disk. Does **not** download from the internet. The 3D client calls this automatically when entering an **artist** hall.
|
||
|
||
**Response**
|
||
|
||
```json
|
||
{ "fetched": 12, "total": 15 }
|
||
```
|
||
|
||
`fetched` counts paintings with a resolvable local image after the scan.
|
||
|
||
---
|
||
|
||
## `GET /api/paintings/:id`
|
||
|
||
Painting detail with influence graph neighbours.
|
||
|
||
**Response**
|
||
|
||
```json
|
||
{
|
||
"painting": {
|
||
"id": 10,
|
||
"title": "...",
|
||
"artist_name": "...",
|
||
"image_path": "paintings/Artist_Title.jpg",
|
||
"thumbnail_path": "paintings/thumbs/Artist_Title_thumb.jpg",
|
||
"image_cache_key": 1739123456789,
|
||
"thumbnail_cache_key": 1739123456790,
|
||
"checkup_checked": false,
|
||
"checkup_fixed": false,
|
||
"curator_notes": "",
|
||
"has_influence_links": true
|
||
},
|
||
"influencedBy": [
|
||
{
|
||
"source_type": "painting",
|
||
"id": 3, "title": "...", "artist_name": "...", "year": 1885,
|
||
"notes": "...", "aspects": "...", "quote": "...", "source_author": "...", "source_url": "..."
|
||
},
|
||
{
|
||
"source_type": "artist",
|
||
"source_artist_id": 12, "source_artist_name": "Paul Cézanne",
|
||
"artist_portrait": "...", "period_note": "during creation (1907)",
|
||
"period_start_year": 1907, "period_end_year": 1907
|
||
},
|
||
{
|
||
"source_type": "movement",
|
||
"movement_id": 8, "movement_name": "Fauvism", "movement_color": "#c45c26",
|
||
"period_start_year": 1905, "period_end_year": 1907
|
||
}
|
||
],
|
||
"influenced": [ { "source_type": "painting", "id": 20, "title": "...", ... } ],
|
||
"annotations": [
|
||
{
|
||
"id": 1,
|
||
"label": "Central figure",
|
||
"body": "Short art-history note…",
|
||
"category": "subject",
|
||
"pos_x": 42.5,
|
||
"pos_y": 38.0,
|
||
"source_author": "E.H. Gombrich",
|
||
"source": "The Story of Art",
|
||
"source_url": "https://…",
|
||
"sort_order": 0,
|
||
"confidence": "curated"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
`painting.has_influence_links` matches the flag on `GET /api/artists/:id` paintings (see above).
|
||
|
||
Both **`influencedBy`** and **`influenced`** are read from **`painting_influence_sources`** only (one row per edge). Painting-type sources appear in *Influenced By* when they are predecessors; in *Influenced* when this painting is the source of a later work. Artist and movement sources appear only in *Influenced By*. The legacy `painting_influences` table is not merged into these responses — it mirrors painting edges for scripts only.
|
||
|
||
In the UI, side-panel thumbnails use the painting’s local image with **letterboxing** (`object-fit: contain`) so the full composition is visible. Click a thumbnail to open that work’s detail view.
|
||
|
||
---
|
||
|
||
## `GET /api/paintings/:id/image`
|
||
|
||
Serves a painting image file. Resolves and caches from external sources only when no local file exists.
|
||
|
||
**Query**
|
||
|
||
| Param | Values | Default |
|
||
|-------|--------|---------|
|
||
| `size` | `thumb`, `full` | `thumb` |
|
||
|
||
Returns the image bytes with `Cache-Control: public, max-age=86400`, or `404` if unavailable.
|
||
|
||
---
|
||
|
||
## Developer image audit
|
||
|
||
**Curator login required** for every route in this section. See [Authentication](#authentication) above.
|
||
|
||
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`
|
||
|
||
Full-catalog audit table for the Checkup UI.
|
||
|
||
**Response**
|
||
|
||
```json
|
||
{
|
||
"paintings": [
|
||
{
|
||
"id": 36,
|
||
"title": "Trinity",
|
||
"artist_name": "Andrei Rublev",
|
||
"year": 1411,
|
||
"gallery_file": "paintings/Andrei_Rublev_Trinity.jpg",
|
||
"gallery_preview": "paintings/thumbs/Andrei_Rublev_Trinity_thumb.jpg",
|
||
"detail_file": "paintings/Andrei_Rublev_Trinity.jpg",
|
||
"detail_preview": "paintings/thumbs/Andrei_Rublev_Trinity_thumb.jpg",
|
||
"detail_on_demand": false,
|
||
"gallery_file_exists": true,
|
||
"detail_file_exists": true,
|
||
"checked": false,
|
||
"fixed": false
|
||
}
|
||
],
|
||
"total": 1201
|
||
}
|
||
```
|
||
|
||
| Field | Meaning |
|
||
|-------|---------|
|
||
| `checked` | Reviewed in checkup workflow (`checkup_checked`) |
|
||
| `fixed` | Image replaced via Fix (`checkup_fixed`) |
|
||
| `detail_on_demand` | `true` when detail would use `GET /api/paintings/:id/image?size=full` |
|
||
| `gallery_file_exists` / `detail_file_exists` | Disk check under `IMAGE_DIR` |
|
||
|
||
---
|
||
|
||
### `PATCH /api/paintings/:id/curator-notes`
|
||
|
||
Update public curator notes for a painting.
|
||
|
||
**Body**
|
||
|
||
```json
|
||
{ "curatorNotes": "Optional editorial text…" }
|
||
```
|
||
|
||
`curatorNotes` must be a string (trim applied; empty string clears the notes).
|
||
|
||
**Response:** `{ "curatorNotes": "…" }`
|
||
|
||
**Audit:** `painting.update_curator_notes`
|
||
|
||
### `PATCH /api/paintings/:id/checkup-flags`
|
||
|
||
Update review flags. Body: `{ "checked"?: boolean, "fixed"?: boolean }` — at least one field required.
|
||
|
||
Setting `fixed: true` also sets `checked: true`. While `fixed` is true, the API rejects clearing `checked`.
|
||
|
||
**Response**
|
||
|
||
```json
|
||
{ "checked": true, "fixed": false }
|
||
```
|
||
|
||
---
|
||
|
||
### `GET /api/paintings/:id/debug-image-search`
|
||
|
||
Google-family image search for debug / checkup (Custom Search → Google Arts & Culture → scrape → DuckDuckGo). Used by **Search visible** on the Checkup page and the debug panel on painting detail.
|
||
|
||
**Response**
|
||
|
||
```json
|
||
{
|
||
"query": "Andrei Rublev Trinity painting",
|
||
"imageUrl": "https://…",
|
||
"source": "google-arts",
|
||
"pageUrl": "https://…"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### `POST /api/paintings/:id/fix-image`
|
||
|
||
Download a remote URL and replace the painting’s local full image + thumbnail. Sets `checkup_fixed = true` and `checkup_checked = true`.
|
||
|
||
Uses `downloadImageForFix` in `scripts/image-fetcher.js` (browser User-Agent, referer fallbacks, Wikimedia URL upgrades) for reliable downloads from Google Arts, Commons, etc.
|
||
|
||
**Body**
|
||
|
||
```json
|
||
{
|
||
"imageUrl": "https://…",
|
||
"searchUrl": "https://…",
|
||
"source": "google-arts",
|
||
"thumbUrl": "https://…"
|
||
}
|
||
```
|
||
|
||
Only `imageUrl` is required; optional fields improve fetch success for hotlinked URLs.
|
||
|
||
**Response**
|
||
|
||
```json
|
||
{
|
||
"imagePath": "paintings/Artist_Title.jpg",
|
||
"thumbnailPath": "paintings/thumbs/Artist_Title_thumb.jpg",
|
||
"image_cache_key": 1739123456789,
|
||
"thumbnail_cache_key": 1739123456790,
|
||
"fixed": true,
|
||
"checked": true
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### `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", "width": 2400, "height": 1800 }
|
||
]
|
||
}
|
||
```
|
||
|
||
Optional `width` / `height` on each result — see portrait **more** endpoint above.
|
||
|
||
---
|
||
|
||
### `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 decoded size 15 MB (JSON body limit 20 MB on the server). **Response** — same shape as `fix-image` (`imagePath`, `thumbnailPath`, `image_cache_key`, `thumbnail_cache_key`, `fixed`, `checked`).
|
||
|
||
---
|
||
|
||
### `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`
|
||
|
||
Proxy a remote image URL for debug preview (avoids hotlink / CORS blocks in the browser).
|
||
|
||
**Query:** `url` — must be `http://` or `https://`
|
||
|
||
Returns image bytes with appropriate `Content-Type`.
|
||
|
||
---
|
||
|
||
## Frontend helpers
|
||
|
||
The React client wraps these endpoints in `client/src/api/client.ts`. All requests send `credentials: 'include'` for session cookies.
|
||
|
||
| Function | Maps to |
|
||
|----------|---------|
|
||
| `getAuthMe()` | `GET /api/auth/me` |
|
||
| `loginCurator(user, pass)` | `POST /api/auth/login` |
|
||
| `logoutCurator()` | `POST /api/auth/logout` |
|
||
| `api.getBounds()` | `GET /api/bounds` |
|
||
| `api.getTimeline(start, end)` | `GET /api/timeline` |
|
||
| `api.search(q, options?)` | `GET /api/search` |
|
||
| `api.getArtists(...)` | `GET /api/artists` |
|
||
| `api.getTimelineArtists()` | `GET /api/artists?timeline=1` |
|
||
| `api.getArtist(id)` | `GET /api/artists/:id` |
|
||
| `api.getArtistNavigation(id)` | `GET /api/artists/:id/navigation` |
|
||
| `api.getPainting(id)` | `GET /api/paintings/:id` |
|
||
| `preloadArtistImages(id)` | `POST /api/artists/:id/preload-images` |
|
||
| `imageUrl(path, revision?)` | `/images/<path>` or placeholder; optional `?v=` cache buster |
|
||
| `paintingImageRevision(painting, sessionRevision?)` | Prefer API `image_cache_key` / `thumbnail_cache_key`, else in-session counter |
|
||
| `galleryImageUrl(painting, sessionRevision?)` | Local thumb/full only (3D); auto `?v=` from cache keys |
|
||
| `galleryImageUrlWithRevision(painting, sessionRevision?)` | Alias of `galleryImageUrl` |
|
||
| `paintingImageUrl(painting, sessionRevision?)` | Local file, on-demand API, or `null` when cleared (`checkup_fixed` + no paths) |
|
||
| `portraitUrl(path, revision?, artist?)` | `/images/<path>` with `?v=` from revision or artist cache keys |
|
||
| `validateDebugUploadFile(file)` | Client-side size/type check before base64 upload |
|
||
| `api.getPaintingCheckup()` | `GET /api/paintings/checkup` |
|
||
| `api.updatePaintingCheckupFlags(id, flags)` | `PATCH /api/paintings/:id/checkup-flags` |
|
||
| `api.updatePaintingCuratorNotes(id, curatorNotes)` | `PATCH /api/paintings/:id/curator-notes` |
|
||
| `api.getPaintingDebugImageSearch(id)` | `GET /api/paintings/:id/debug-image-search` |
|
||
| `api.getPaintingDebugImageSearchMore(id, limit?)` | `GET /api/paintings/:id/debug-image-search/more` |
|
||
| `api.fixPaintingImage(id, imageUrl, context?)` | `POST /api/paintings/:id/fix-image` |
|
||
| `api.clearPaintingImage(id)` | `POST /api/paintings/:id/clear-image` |
|
||
| `api.deletePainting(id)` | `DELETE /api/paintings/:id` |
|
||
| `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=…` |
|