Public GET /api/search over artists, movements, and paintings with a debounced header bar on the timeline; Back to Timeline resets zoom and gallery session. Co-authored-by: Cursor <cursoragent@cursor.com>
26 KiB
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 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:
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)
{ "role": "user" }
Response (curator session)
{ "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/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).
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
{
"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
{
"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
{
"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.
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 |
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 |
Prefix matches on primary labels (name / title) rank before substring matches.
Response
{
"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
{
"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
{
"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).
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
{ "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
{
"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
{
"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
{
"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
{
"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
{
"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
{ "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
{
"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,
"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 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
{
"paintings": [
{
"id": 36,
"title": "Trinity",
"artist_name": "Andrei Rublev",
"year": 1411,
"gallery_file": "paintings/Andrei_Rublev_Trinity.jpg",
"gallery_preview": "paintings/thumbs/Andrei_Rublev_Trinity_thumb.jpg",
"detail_file": "paintings/Andrei_Rublev_Trinity.jpg",
"detail_preview": "paintings/thumbs/Andrei_Rublev_Trinity_thumb.jpg",
"detail_on_demand": false,
"gallery_file_exists": true,
"detail_file_exists": true,
"checked": false,
"fixed": false
}
],
"total": 1201
}
| Field | Meaning |
|---|---|
checked |
Reviewed in checkup workflow (checkup_checked) |
fixed |
Image replaced via Fix (checkup_fixed) |
detail_on_demand |
true when detail would use GET /api/paintings/:id/image?size=full |
gallery_file_exists / detail_file_exists |
Disk check under IMAGE_DIR |
PATCH /api/paintings/:id/checkup-flags
Update review flags. Body: { "checked"?: boolean, "fixed"?: boolean } — at least one field required.
Setting fixed: true also sets checked: true. While fixed is true, the API rejects clearing checked.
Response
{ "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
{
"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
{
"imageUrl": "https://…",
"searchUrl": "https://…",
"source": "google-arts",
"thumbUrl": "https://…"
}
Only imageUrl is required; optional fields improve fetch success for hotlinked URLs.
Response
{
"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
{
"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
{
"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
{
"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
{
"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.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=… |