Add guided tours and unify left-to-right hall wall hang.

Visitors walk published tours in a 3D hall with stop notes; curators edit drafts via Tour editor. All galleries (artist, movement, tour) place the first work left of the entrance view and the last on the right.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-07-16 20:27:36 +03:00
co-authored by Cursor
parent 48bd17e985
commit 5ddc3fd7f0
31 changed files with 1890 additions and 124 deletions
+26
View File
@@ -214,6 +214,32 @@ Public painting detail still exposes read-only `influencedBy` / `influenced` (un
--- ---
## 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` ## `GET /api/search`
Public catalog search over **artists**, **paintings**, and **art movements**. Used by the timeline header search bar (`CatalogSearchBar.tsx`). Public catalog search over **artists**, **paintings**, and **art movements**. Used by the timeline header search bar (`CatalogSearchBar.tsx`).
+27
View File
@@ -136,6 +136,33 @@ Short art-history notes shown on painting detail (`PaintingAnnotations.tsx`).
Applied by `npm run dev:migrate:painting-annotations` (`db/migrate-painting-annotations.sql`). Load data with `npm run dev:update-painting-annotations` (curated entries in `scripts/painting-annotations-data.js`; add `--wikipedia` for intro sentences from each works `wikipedia_title`). Applied by `npm run dev:migrate:painting-annotations` (`db/migrate-painting-annotations.sql`). Load data with `npm run dev:update-painting-annotations` (curated entries in `scripts/painting-annotations-data.js`; add `--wikipedia` for intro sentences from each works `wikipedia_title`).
### `tours` / `tour_stops`
Curated guided tours. See [tours.md](tours.md).
**`tours`**
| Column | Type | Notes |
|--------|------|-------|
| `id` | SERIAL PK | |
| `title` | VARCHAR(200) | |
| `description` | TEXT | Default `''` |
| `status` | VARCHAR(20) | `draft` \| `published` |
| `cover_painting_id` | FK → `paintings` | ON DELETE SET NULL |
| `created_at` / `updated_at` | TIMESTAMPTZ | `updated_at` via trigger |
**`tour_stops`**
| Column | Type | Notes |
|--------|------|-------|
| `id` | SERIAL PK | |
| `tour_id` | FK → `tours` | ON DELETE CASCADE |
| `painting_id` | FK → `paintings` | ON DELETE CASCADE; UNIQUE with `tour_id` |
| `sort_order` | INTEGER | Visitor / editor order |
| `body` | TEXT | English tour notes for the stop (v1) |
Applied by `npm run dev:migrate` (`db/migrate-tours.sql`).
### `painting_influences` ### `painting_influences`
Directed edges: *this painting* was influenced by *that painting*. Directed edges: *this painting* was influenced by *that painting*.
+1
View File
@@ -359,4 +359,5 @@ Remote: https://gitea.mysuperlab.netcraze.pro/Danilka/Art-gallery
| [setup.md](setup.md) | Install, env vars, troubleshooting | | [setup.md](setup.md) | Install, env vars, troubleshooting |
| [data-and-images.md](data-and-images.md) | Catalog and image pipeline | | [data-and-images.md](data-and-images.md) | Catalog and image pipeline |
| [API.md](API.md) | REST endpoints | | [API.md](API.md) | REST endpoints |
| [tours.md](tours.md) | Guided tours |
| [DB_structure.md](DB_structure.md) | PostgreSQL schema | | [DB_structure.md](DB_structure.md) | PostgreSQL schema |
+1 -1
View File
@@ -6,5 +6,5 @@ this file contains draft for future releases and features
4. ~~tool to sync prod /env resources (both ways), db structure, db data, images, users etc~~ — done for catalog DB + images: `npm run harmonize` (schema dev→prod only; users/audit excluded) — [harmonize-dev-prod.md](harmonize-dev-prod.md) 4. ~~tool to sync prod /env resources (both ways), db structure, db data, images, users etc~~ — done for catalog DB + images: `npm run harmonize` (schema dev→prod only; users/audit excluded) — [harmonize-dev-prod.md](harmonize-dev-prod.md)
5. ~~curator_audit_log should contain log of actions like fixit, checked, upload etc with details for which entity it was made and details what was the action and outcome~~ — done: table + `logCuratorAction` on fix/clear/upload/delete/checkup flags (and translation upsert/publish); see [DB_structure.md](DB_structure.md#curator_audit_log). (UI to browse logs is still item 3.) 5. ~~curator_audit_log should contain log of actions like fixit, checked, upload etc with details for which entity it was made and details what was the action and outcome~~ — done: table + `logCuratorAction` on fix/clear/upload/delete/checkup flags (and translation upsert/publish); see [DB_structure.md](DB_structure.md#curator_audit_log). (UI to browse logs is still item 3.)
6. ~~create search by entity (painting, artist, movement)~~ — done: timeline header + `GET /api/search` 6. ~~create search by entity (painting, artist, movement)~~ — done: timeline header + `GET /api/search`
7. create guided tours (with text/extra infor, set of entities) 7. ~~create guided tours (with text/extra infor, set of entities)~~ — done: `tours` / `tour_stops`, public Tours popup + 3D tour hall, curator Tour editor — [tours.md](tours.md)
+33 -10
View File
@@ -8,7 +8,7 @@ The app is organised as a **drill-down hierarchy**:
1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries. 1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries.
2. **Movement flow** — art movements as curved SVG streams on the same year axis; documented predecessor→successor branches; portrait thumbnails placed along each stream. 2. **Movement flow** — art movements as curved SVG streams on the same year axis; documented predecessor→successor branches; portrait thumbnails placed along each stream.
3. **3D gallery** — one personal hall per artist *or* a **movement gallery** (click a movement name on the flow diagram): period-themed interiors, chronological wings of up to ~55 works, side-wall hang only. 3. **3D gallery** — one personal hall per artist, a **movement gallery** (click a movement name), or a **guided tour** hall (timeline → **Tours**): period-themed or tour wings of up to ~55 works, side-wall hang, visit order left→right.
4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), optional **art-history notes** with image markers, prev/next catalog browsing, optional fullscreen, link to artist biography. 4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), optional **art-history notes** with image markers, prev/next catalog browsing, optional fullscreen, link to artist biography.
5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`). With debug mode on, the same image-audit panel as painting detail (portrait search, **Checked** / **Fix it** / **More** / **Clear** / **Upload**). 5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`). With debug mode on, the same image-audit panel as painting detail (portrait search, **Checked** / **Fix it** / **More** / **Clear** / **Upload**).
@@ -52,6 +52,8 @@ Gallery/
│ │ ├── pages/CheckupPage.tsx # Image audit table │ │ ├── pages/CheckupPage.tsx # Image audit table
│ │ ├── pages/TranslationsPage.tsx # Russian translation review │ │ ├── pages/TranslationsPage.tsx # Russian translation review
│ │ ├── pages/InfluencesPage.tsx # Influence links CRUD + import wizard │ │ ├── pages/InfluencesPage.tsx # Influence links CRUD + import wizard
│ │ ├── pages/ToursPage.tsx # Guided tour editor
│ │ ├── components/ToursPopup.tsx # Public published-tours modal
│ │ ├── i18n/ # react-i18next bootstrap │ │ ├── i18n/ # react-i18next bootstrap
│ │ └── locales/{en,ru}/ # UI chrome strings │ │ └── locales/{en,ru}/ # UI chrome strings
│ │ ├── data/historical-events.ts # Timeline event markers (UI) │ │ ├── data/historical-events.ts # Timeline event markers (UI)
@@ -279,7 +281,9 @@ Only **mousedown** on portraits and movement labels stops propagation (so drag-t
## Virtual gallery (3D halls) ## Virtual gallery (3D halls)
The 3D scene supports two modes in `VirtualGallery.tsx`: **artist halls** (personal catalog) and **movement galleries** (full movement collection, chronological). The 3D scene supports three modes in `VirtualGallery.tsx`: **artist halls** (personal catalog), **movement galleries** (full movement collection, chronological), and **guided tours** (curator-ordered stops — [tours.md](tours.md)).
**Shared wall hang (all modes):** visit order fills the **left wall first**, then the **right**. The **first** work hangs near the entrance on the left (immediately left of the opening view); the **last** hangs near the entrance on the right. Artist halls use chronological order; movement wings use chronological order within each wing; tours use stop order.
### Artist halls ### Artist halls
@@ -289,9 +293,8 @@ Each artist has **exactly one hall**. The hall is a rectangular room sized to fi
|------|----------------| |------|----------------|
| One hall per artist | `VirtualGallery.tsx` builds a single room from that artists paintings | | One hall per artist | `VirtualGallery.tsx` builds a single room from that artists paintings |
| Catalog depth | Most artists target **≥ 6** notable works via `npm run dev:expand-catalog` and `famous-paintings-data.js`; some masters have larger museum dumps | | Catalog depth | Most artists target **≥ 6** notable works via `npm run dev:expand-catalog` and `famous-paintings-data.js`; some masters have larger museum dumps |
| Paintings on walls | Works hang on the **back, left, and right** walls in **one row per wall**; room **depth grows** when the catalog is large | | Paintings on walls | Works hang on the **left and right** walls in **one row per wall**; room **depth grows** when the catalog is large (back wall is for the exit only) |
| Corridor layout | **15+ paintings:** short back wall (up to 8 works), remaining works on extended **left/right** side walls — a long gallery corridor | | Wall order | Chronological visit order: first half on the **left** (entrance → back), second half on the **right** (back → entrance); first work left of the opening view, last on the right |
| Wall order | On each wall, left → right: **later works on the left**, **earlier works on the right**; undated works sort toward the left |
| Floor | Herringbone **parquet** procedural texture (`parquetFloorTexture.ts`) | | Floor | Herringbone **parquet** procedural texture (`parquetFloorTexture.ts`) |
| Wall tint | Gallery walls blend the artists **movement colour** into cream plaster tones | | Wall tint | Gallery walls blend the artists **movement colour** into cream plaster tones |
| Frame finish | **Unchecked** works: black moulding; **Reviewed** (`checkup_checked`): bright gold moulding at **double width** | | Frame finish | **Unchecked** works: black moulding; **Reviewed** (`checkup_checked`): bright gold moulding at **double width** |
@@ -327,7 +330,7 @@ Enter from the home page by clicking a **movement name** on the movement flow (`
| One gallery per movement | All paintings by artists in that movement, sorted chronologically | | One gallery per movement | All paintings by artists in that movement, sorted chronologically |
| Wings | Catalog split into wings of up to **55 works** (`movementHallLayout.ts`); large movements (e.g. Baroque) use multiple wings | | Wings | Catalog split into wings of up to **55 works** (`movementHallLayout.ts`); large movements (e.g. Baroque) use multiple wings |
| Paintings on walls | **Left and right walls only** — back wall reserved for exit, front for passage to the next wing | | Paintings on walls | **Left and right walls only** — back wall reserved for exit, front for passage to the next wing |
| Wall order | Along each side wall: **later works on the left**, **earlier on the right** (same convention as artist halls) | | Wall order | Same shared hang as artist halls: first half left (entrance → back), second half right (back → entrance) |
| Frame captions | **Year · artist** label below each frame | | Frame captions | **Year · artist** label below each frame |
| Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, NYC loft, white cube, etc.) | | Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, NYC loft, white cube, etc.) |
| Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco — plus **single-colour painted walls** (`painted-lime`, `painted-oil-matte`, `painted-oil-satin`, `painted-emulsion`, `painted-flat`) tinted per movement for Renaissance salons through modern white cubes | | Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco — plus **single-colour painted walls** (`painted-lime`, `painted-oil-matte`, `painted-oil-satin`, `painted-emulsion`, `painted-flat`) tinted per movement for Renaissance salons through modern white cubes |
@@ -351,6 +354,20 @@ Enter from the home page by clicking a **movement name** on the movement flow (`
Movement galleries do **not** use the predecessor/successor influence picker — that remains artist-hall only. Movement galleries do **not** use the predecessor/successor influence picker — that remains artist-hall only.
### Guided tour halls
Enter from the home page **Tours** popup (`GET /api/tours/:id`). Layout reuses the movement winged hall (`mode: 'tour'` in `VirtualGallery.tsx`).
| Rule | Implementation |
|------|----------------|
| Visit order | Curator `sort_order` on `tour_stops` (not chronological) |
| Wings | Same ~55-per-wing split as movements; order preserved across wings |
| Wall hang | Same left-then-right rule as other halls |
| Detail | Tour stop text panel; walks tour stops |
| Exit | Wing navigator / **Exit to Timeline** (no influence picker) |
Full guide: [tours.md](tours.md).
### Shared 3D behaviour ### Shared 3D behaviour
**3D images** use locally cached files only (`galleryImageUrl` in `client/src/api/client.ts`). Remote fetches are too slow for realtime WebGL textures; the client calls `POST /api/artists/:id/preload-images` automatically when entering an **artist** hall (public route — links disk files only). Movement galleries load painting lists from the API without a separate preload step. While a texture is loading, the frame shows the canvas cover instead of a white placeholder. The center of the hall shows **“Loading gallery…”** until the WebGL canvas is ready, then **“Loading paintings…”** until every wall texture has resolved (tracked through `GalleryTextureLoadContext`). The 3D hall stays mounted while painting detail or bio overlays are open; returning remounts the canvas when the hall becomes active again. **3D images** use locally cached files only (`galleryImageUrl` in `client/src/api/client.ts`). Remote fetches are too slow for realtime WebGL textures; the client calls `POST /api/artists/:id/preload-images` automatically when entering an **artist** hall (public route — links disk files only). Movement galleries load painting lists from the API without a separate preload step. While a texture is loading, the frame shows the canvas cover instead of a white placeholder. The center of the hall shows **“Loading gallery…”** until the WebGL canvas is ready, then **“Loading paintings…”** until every wall texture has resolved (tracked through `GalleryTextureLoadContext`). The 3D hall stays mounted while painting detail or bio overlays are open; returning remounts the canvas when the hall becomes active again.
@@ -359,11 +376,12 @@ Movement galleries do **not** use the predecessor/successor influence picker —
## Painting detail view ## Painting detail view
Opened from the 3D hall (artist or movement wing — click a frame) or from influence thumbnails on another works detail page. Opened from the 3D hall (artist, movement, or tour wing — click a frame) or from influence thumbnails on another works detail page.
| Layer | What you see | | Layer | What you see |
|-------|----------------| |-------|----------------|
| **Detail** | Centre image, *Influenced By* (left) and *Influenced* (right) — painting thumbnails (full work visible, letterboxed), artist portraits, or movement swatches — position in catalog (e.g. `3 of 12`) | | **Detail** | Centre image, *Influenced By* (left) and *Influenced* (right) — painting thumbnails (full work visible, letterboxed), artist portraits, or movement swatches — position in catalog (e.g. `3 of 12`) |
| **Tour notes** | When opened from a guided tour: stop text panel under the image (English body from `tour_stops`) |
| **Art history notes** | Numbered markers on the image (when positioned) plus a note list below — short citations from Gombrich, museum catalogs, Wikipedia, etc. (`painting_annotations` table) | | **Art history notes** | Numbered markers on the image (when positioned) plus a note list below — short citations from Gombrich, museum catalogs, Wikipedia, etc. (`painting_annotations` table) |
| **Fullscreen** | Click the centre image; `Escape` or click anywhere to return to detail only | | **Fullscreen** | Click the centre image; `Escape` or click anywhere to return to detail only |
@@ -371,7 +389,7 @@ Opened from the 3D hall (artist or movement wing — click a frame) or from infl
| Input | Action | | Input | Action |
|-------|--------| |-------|--------|
| `` / `` beside image | Previous / next work by the **same artist** (chronological order) | | `` / `` beside image | Previous / next in the **current catalog** — artist chronology, movement chronology, or **tour stop order** |
| `←` / `→` | Same as prev / next (disabled while fullscreen is open) | | `←` / `→` | Same as prev / next (disabled while fullscreen is open) |
| Click centre image | Open fullscreen lightbox | | Click centre image | Open fullscreen lightbox |
| Click influence thumbnail | Open that works detail (different artist allowed) | | Click influence thumbnail | Open that works detail (different artist allowed) |
@@ -382,7 +400,7 @@ Opened from the 3D hall (artist or movement wing — click a frame) or from infl
**Navigation rules:** **Navigation rules:**
- **Catalog browsing** ( / arrow keys) walks the current artists works earliest → latest. It does **not** change the back target: after browsing several works, **Back to Gallery** still returns directly to the hall. - **Catalog browsing** ( / arrow keys) walks the active catalog (artist / movement chronology, or tour stops). It does **not** change the back target: after browsing several works, **Back to Gallery** still returns directly to the hall.
- **Influence links** push a new detail layer; **Back** from an influenced work returns to the painting you came from (and from there back to the gallery if applicable). - **Influence links** push a new detail layer; **Back** from an influenced work returns to the painting you came from (and from there back to the gallery if applicable).
- Side-panel influence images use **`object-fit: contain`** so tall or wide works are not cropped (dark letterbox background). - Side-panel influence images use **`object-fit: contain`** so tall or wide works are not cropped (dark letterbox background).
- The 3D hall stays mounted in the background while detail is open so nothing is lost on return. - The 3D hall stays mounted in the background while detail is open so nothing is lost on return.
@@ -404,6 +422,8 @@ Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens
- **Movement lineage** (`movement-lineage.ts`) documents art-historical predecessor→successor links for the flow diagram; extend that file to add or correct branches. - **Movement lineage** (`movement-lineage.ts`) documents art-historical predecessor→successor links for the flow diagram; extend that file to add or correct branches.
- **One hall per artist** keeps navigation predictable: enter from the timeline or bio, leave via the single exit or back button. - **One hall per artist** keeps navigation predictable: enter from the timeline or bio, leave via the single exit or back button.
- **Movement galleries** complement artist halls: full movement corpus in period-themed wings, entered from the flow diagram. - **Movement galleries** complement artist halls: full movement corpus in period-themed wings, entered from the flow diagram.
- **Guided tours** add curator-ordered winged halls with stop text on painting detail — [tours.md](tours.md).
- **Wall hang** is shared: first work on the left at the entrance, last on the right.
- **Influence-based hall links** connect artists through documented painting relationships, grouped by movement at the exit. - **Influence-based hall links** connect artists through documented painting relationships, grouped by movement at the exit.
- **3D gallery images** use locally cached files only; slow remote fetches would break realtime rendering. The client calls `POST /api/artists/:id/preload-images` automatically when entering an **artist** hall (public route — links disk files only). - **3D gallery images** use locally cached files only; slow remote fetches would break realtime rendering. The client calls `POST /api/artists/:id/preload-images` automatically when entering an **artist** hall (public route — links disk files only).
- **3D gallery session** stays mounted while painting detail or bio overlays are open; returning to the hall remounts the WebGL canvas when it becomes active again. - **3D gallery session** stays mounted while painting detail or bio overlays are open; returning to the hall remounts the WebGL canvas when it becomes active again.
@@ -416,7 +436,7 @@ Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens
| Role | Who | Can do | | Role | Who | Can do |
|------|-----|--------| |------|-----|--------|
| **`user`** | Anonymous visitor (default) | Browse timeline, movement flow, 3D artist/movement halls, painting detail, artist bios, images | | **`user`** | Anonymous visitor (default) | Browse timeline, movement flow, 3D artist/movement halls, painting detail, artist bios, images |
| **`curator`** | Named account (`users` table) | Everything above + **Debug mode**, **Checkup**, **Translations**, **Influences**, debug API mutations | | **`curator`** | Named account (`users` table) | Everything above + **Debug mode**, **Checkup**, **Translations**, **Influences**, **Tour editor**, debug API mutations |
Curators sign in via **Curator login** in the site header. Sessions use an HTTP-only cookie (`gallery.sid`). The UI hides debug controls from guests; the server enforces the same rules on debug/checkup API routes (`401` without a valid session). Curators sign in via **Curator login** in the site header. Sessions use an HTTP-only cookie (`gallery.sid`). The UI hides debug controls from guests; the server enforces the same rules on debug/checkup API routes (`401` without a valid session).
@@ -435,6 +455,8 @@ Curator-only workflow for reviewing and fixing local image files — not part of
| **Checkup page** | Home header → **Checkup** (curators only) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags | | **Checkup page** | Home header → **Checkup** (curators only) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags |
| **Translations** | Home header → **Translations** (curators only) | Review/publish Russian `entity_translations` | | **Translations** | Home header → **Translations** (curators only) | Review/publish Russian `entity_translations` |
| **Influences** | Home header → **Influences** (curators only) | List/CRUD influence edges, CSV/JSON/XLSX import wizard, neighborhood graph — [influence-import.md](influence-import.md) | | **Influences** | Home header → **Influences** (curators only) | List/CRUD influence edges, CSV/JSON/XLSX import wizard, neighborhood graph — [influence-import.md](influence-import.md) |
| **Tour editor** | Home header → **Tour editor** (curators only) | Create/publish guided tours and stop text — [tours.md](tours.md) |
| **Tours** | Home header → **Tours** (everyone) | Open published tours in a 3D hall — [tours.md](tours.md) |
| **Logout** | Home header (curators) | Ends session; hides debug tools | | **Logout** | Home header (curators) | Ends session; hides debug tools |
| **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 or artist bio (bottom-left, when debug mode on) | Search preview + action buttons (six on painting detail, five on artist bio) |
@@ -481,5 +503,6 @@ See [API.md](API.md#authentication) and [data-and-images.md](data-and-images.md#
| [DB_structure.md](DB_structure.md) | Tables and relationships | | [DB_structure.md](DB_structure.md) | Tables and relationships |
| [API.md](API.md) | REST endpoints | | [API.md](API.md) | REST endpoints |
| [influence-import.md](influence-import.md) | Curator Influences tool — import wizard, CRUD, graph | | [influence-import.md](influence-import.md) | Curator Influences tool — import wizard, CRUD, graph |
| [tours.md](tours.md) | Guided tours — editor, public popup, 3D tour hall |
| [i18n-russian.md](i18n-russian.md) | Russian UI + entity_translations | | [i18n-russian.md](i18n-russian.md) | Russian UI + entity_translations |
| [data-and-images.md](data-and-images.md) | Image pipeline and seeding | | [data-and-images.md](data-and-images.md) | Image pipeline and seeding |
+1 -1
View File
@@ -265,7 +265,7 @@ To add or fix a branch, edit `MOVEMENT_LINEAGE` in that file and rebuild the cli
Separate from movement lineage layout, `client/src/data/movement-interior-styles.ts` defines a **unique 3D interior** for each seeded art movement (26 styles): wall/floor/ceiling textures, trim colours, window style, and architectural details (columns, coffered ceilings, etc.). Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts`. Separate from movement lineage layout, `client/src/data/movement-interior-styles.ts` defines a **unique 3D interior** for each seeded art movement (26 styles): wall/floor/ceiling textures, trim colours, window style, and architectural details (columns, coffered ceilings, etc.). Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts`.
Wing layout (up to 55 works per wing, side-wall-only hang, window gap placement) lives in `client/src/utils/movementHallLayout.ts`. To change a movements look, edit its entry in `movement-interior-styles.ts` and rebuild the client. Wing layout (up to 55 works per wing, side-wall-only hang, window gap placement) lives in `client/src/utils/movementHallLayout.ts`. Visit order fills the **left wall** (first work at the entrance), then the **right** (last work at the entrance). The same hang applies to artist halls and guided tours. To change a movements look, edit its entry in `movement-interior-styles.ts` and rebuild the client.
## Historical event markers (frontend timeline) ## Historical event markers (frontend timeline)
+1 -1
View File
@@ -72,7 +72,7 @@ If migration fails with permission errors, grant schema rights to the app user f
`npm run dev:migrate` applies `db/migrate-auth.sql` (`users`, `curator_audit_log`, `session` tables). When the `users` table is empty and `CURATOR_USERNAME` / `CURATOR_PASSWORD` are set in `.env`, the first curator account is created automatically. `npm run dev:migrate` applies `db/migrate-auth.sql` (`users`, `curator_audit_log`, `session` tables). When the `users` table is empty and `CURATOR_USERNAME` / `CURATOR_PASSWORD` are set in `.env`, the first curator account is created automatically.
After migrate, sign in from the site header (**Curator login**). Debug mode, Checkup, Translations, Influences, and all mutating debug APIs require an active curator session. Anonymous visitors browse the timeline and 3D halls without logging in. After migrate, sign in from the site header (**Curator login**). Debug mode, Checkup, Translations, Influences, Tour editor, and all mutating debug APIs require an active curator session. Anonymous visitors browse the timeline, published Tours, and 3D halls without logging in.
See [API.md — Authentication](API.md#authentication) and [basics.md — Developer tools](basics.md#developer-tools-image-audit). See [API.md — Authentication](API.md#authentication) and [basics.md — Developer tools](basics.md#developer-tools-image-audit).
+47
View File
@@ -0,0 +1,47 @@
# Guided tours
Curated walkthroughs of selected paintings. Visitors open **Tours** on the timeline, pick a published tour, and walk a 3D hall (`VirtualGallery` mode `tour`). Opening a frame shows stop notes on painting detail; prev/next follow tour order.
## Status
| Status | Who sees it |
|--------|-------------|
| `draft` | Curators only (Tour editor + `GET /api/tours/:id` when signed in) |
| `published` | Everyone via Tours popup + public API |
Tour stop **body** text is English-only in v1 (stored on `tour_stops.body`). UI chrome is EN/RU via i18n.
## Data model
Migration: `db/migrate-tours.sql` (applied by `npm run dev:migrate`).
| Table | Role |
|-------|------|
| `tours` | Title, description, `status`, optional `cover_painting_id` |
| `tour_stops` | Ordered stops: `tour_id`, `painting_id`, `sort_order`, `body` (unique per tour+painting) |
Included in `scripts/harmonize-db.js` catalog sync.
## Curator editor
Home header → **Tour editor** (curators). Create tours, set draft/published, search-add paintings, reorder, edit stop text, save.
## Visitor flow
1. Timeline → **Tours** → modal of published tours
2. Select tour → `GET /api/tours/:id` → winged 3D hall (left wall first → right wall last, stop order preserved)
3. Click frame → painting detail with tour notes + stop-ordered navigation
4. Back / hall exit → timeline
Hall hang rules match artist and movement galleries — see [basics.md — Virtual gallery](basics.md#virtual-gallery-3d-halls).
## API
See [API.md](API.md#tours). Audit actions: `tour.create`, `tour.update`, `tour.delete`, `tour.stops`.
## Out of scope (v1)
- Russian tour body translations
- Auto-generated / AI tours
- Audio or timed slideshow
- Editing tours from painting detail
+81
View File
@@ -5,9 +5,12 @@ import type {
Artist, Artist,
ArtistDetail, ArtistDetail,
MovementGalleryDetail, MovementGalleryDetail,
Painting,
PaintingDetail, PaintingDetail,
ArtistNavigation, ArtistNavigation,
CatalogSearchResponse, CatalogSearchResponse,
TourSummary,
TourGalleryDetail,
} from '../types'; } from '../types';
import { readStoredLocale, type AppLocale } from '../utils/localeStorage'; import { readStoredLocale, type AppLocale } from '../utils/localeStorage';
@@ -686,6 +689,82 @@ export const api = {
return res.json() as Promise<{ inserted: number; skipped: number; attempted: number }>; return res.json() as Promise<{ inserted: number; skipped: number; attempted: number }>;
}), }),
listPublishedTours: () =>
fetchJson<{ tours: TourSummary[] }>(`${API}/tours`),
listAdminTours: () =>
fetchJson<{ tours: TourSummary[] }>(`${API}/tours/admin`),
getTour: (id: number) =>
fetchJson<TourGalleryDetail & { locale?: string }>(`${API}/tours/${id}`),
createTour: (payload: { title: string; description?: string; status?: 'draft' | 'published' }) =>
fetch(`${API}/tours`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Create failed: ${res.status}`);
}
return res.json() as Promise<{ tour: TourSummary }>;
}),
updateTour: (
id: number,
payload: Partial<{
title: string;
description: string;
status: 'draft' | 'published';
coverPaintingId: number | null;
}>,
) =>
fetch(`${API}/tours/${id}`, {
...fetchCredentials,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Update failed: ${res.status}`);
}
return res.json() as Promise<{ tour: TourSummary }>;
}),
deleteTour: (id: number) =>
fetch(`${API}/tours/${id}`, {
...fetchCredentials,
method: 'DELETE',
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Delete failed: ${res.status}`);
}
return res.json() as Promise<{ ok: boolean }>;
}),
saveTourStops: (id: number, stops: Array<{ paintingId: number; body: string }>) =>
fetch(`${API}/tours/${id}/stops`, {
...fetchCredentials,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stops }),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Save stops failed: ${res.status}`);
}
return res.json() as Promise<{
ok: boolean;
stopCount: number;
paintings: Painting[];
stopBodies: Record<number, string>;
}>;
}),
preloadArtistImages, preloadArtistImages,
}; };
@@ -750,6 +829,8 @@ export interface InfluencePriorImport {
match: 'file' | 'data' | 'unknown'; match: 'file' | 'data' | 'unknown';
} }
export type { TourSummary, TourGalleryDetail };
export interface InfluenceImportParseResult { export interface InfluenceImportParseResult {
filename: string; filename: string;
format: string; format: string;
+34
View File
@@ -407,6 +407,40 @@
display: block; display: block;
} }
.tour-stop-panel {
max-width: 700px;
width: 100%;
margin-top: 20px;
padding: 16px 18px;
background: rgba(201, 169, 110, 0.12);
border-radius: 6px;
border: 1px solid rgba(201, 169, 110, 0.35);
}
.tour-stop-panel h3 {
margin: 0 0 10px;
font-family: Georgia, 'Times New Roman', serif;
font-size: 15px;
font-weight: 600;
color: #c9a96e;
}
.tour-stop-body {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: 14px;
line-height: 1.7;
color: rgba(232, 213, 181, 0.92);
white-space: pre-wrap;
}
.tour-stop-empty {
margin: 0;
font-size: 13px;
color: rgba(201, 169, 110, 0.55);
font-style: italic;
}
.painting-description { .painting-description {
max-width: 700px; max-width: 700px;
margin-top: 20px; margin-top: 20px;
+18 -1
View File
@@ -14,6 +14,8 @@ interface Props {
data: PaintingDetail; data: PaintingDetail;
artistPaintings?: Painting[]; artistPaintings?: Painting[];
backLabel?: string; backLabel?: string;
tourTitle?: string | null;
tourText?: string | null;
onBack: () => void; onBack: () => void;
onPaintingClick: (paintingId: number) => void; onPaintingClick: (paintingId: number) => void;
onCatalogNavigate: (paintingId: number) => void; onCatalogNavigate: (paintingId: number) => void;
@@ -194,6 +196,8 @@ export default function PaintingDetailView({
data, data,
artistPaintings = [], artistPaintings = [],
backLabel = '← Back to Gallery', backLabel = '← Back to Gallery',
tourTitle = null,
tourText = null,
onBack, onBack,
onPaintingClick, onPaintingClick,
onCatalogNavigate, onCatalogNavigate,
@@ -207,6 +211,7 @@ export default function PaintingDetailView({
}: Props) { }: Props) {
const { t } = useTranslation('painting'); const { t } = useTranslation('painting');
const { painting, influencedBy, influenced, annotations = [] } = data; const { painting, influencedBy, influenced, annotations = [] } = data;
const inTour = tourText != null;
const [fullscreen, setFullscreen] = useState(false); const [fullscreen, setFullscreen] = useState(false);
const [imageVersion, setImageVersion] = useState(0); const [imageVersion, setImageVersion] = useState(0);
const [debugSearch, setDebugSearch] = useState<DebugImageSearchResult | null>(null); const [debugSearch, setDebugSearch] = useState<DebugImageSearchResult | null>(null);
@@ -470,7 +475,9 @@ export default function PaintingDetailView({
{showCatalogNav && ( {showCatalogNav && (
<span className="painting-catalog-position"> <span className="painting-catalog-position">
{' · '} {' · '}
{catalogIndex + 1} of {artistPaintings.length} {inTour
? t('tourStopPosition', { current: catalogIndex + 1, total: artistPaintings.length })
: `${catalogIndex + 1} of ${artistPaintings.length}`}
</span> </span>
)} )}
</p> </p>
@@ -569,6 +576,16 @@ export default function PaintingDetailView({
</button> </button>
)} )}
</div> </div>
{inTour && (
<aside className="tour-stop-panel" aria-label={t('tourNotes')}>
<h3>{tourTitle ? t('tourNotesFor', { title: tourTitle }) : t('tourNotes')}</h3>
{tourText.trim() ? (
<p className="tour-stop-body">{tourText}</p>
) : (
<p className="tour-stop-empty">{t('tourNotesEmpty')}</p>
)}
</aside>
)}
{painting.description && ( {painting.description && (
<div className="painting-description"> <div className="painting-description">
<p>{painting.description}</p> <p>{painting.description}</p>
+144
View File
@@ -0,0 +1,144 @@
.tours-popup-backdrop {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.65);
padding: 16px;
}
.tours-popup-modal {
width: min(100%, 520px);
max-height: min(85vh, 640px);
overflow: auto;
padding: 20px 22px 24px;
border-radius: 10px;
border: 1px solid rgba(201, 169, 110, 0.35);
background: linear-gradient(180deg, #1a1a2e 0%, #12121f 100%);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
color: #e8d5b5;
}
.tours-popup-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.tours-popup-header h2 {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.25rem;
color: #e8d5b5;
}
.tours-popup-close {
border: none;
background: transparent;
color: rgba(201, 169, 110, 0.85);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
padding: 0 4px;
}
.tours-popup-hint {
margin: 0 0 14px;
font-size: 0.85rem;
color: rgba(201, 169, 110, 0.7);
line-height: 1.45;
}
.tours-popup-muted {
margin: 0;
color: rgba(201, 169, 110, 0.55);
font-size: 0.9rem;
}
.tours-popup-error {
margin: 0 0 10px;
color: #ffaaaa;
font-size: 0.9rem;
}
.tours-popup-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.tours-popup-card {
display: flex;
gap: 12px;
width: 100%;
text-align: left;
padding: 10px;
border-radius: 8px;
border: 1px solid rgba(201, 169, 110, 0.28);
background: rgba(0, 0, 0, 0.28);
color: inherit;
cursor: pointer;
}
.tours-popup-card:hover {
border-color: rgba(201, 169, 110, 0.55);
background: rgba(201, 169, 110, 0.08);
}
.tours-popup-cover {
flex: 0 0 72px;
width: 72px;
height: 72px;
border-radius: 4px;
overflow: hidden;
background: rgba(0, 0, 0, 0.35);
}
.tours-popup-cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.tours-popup-cover-empty {
display: block;
width: 100%;
height: 100%;
background: linear-gradient(135deg, rgba(201, 169, 110, 0.15), rgba(0, 0, 0, 0.4));
}
.tours-popup-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.tours-popup-body strong {
font-size: 0.95rem;
}
.tours-popup-body p {
margin: 0;
font-size: 0.8rem;
line-height: 1.4;
color: rgba(232, 213, 181, 0.75);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.tours-popup-meta {
font-size: 0.75rem;
color: rgba(201, 169, 110, 0.65);
}
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { api, imageUrl } from '../api/client';
import type { TourSummary } from '../types';
import './ToursPopup.css';
interface Props {
open: boolean;
onClose: () => void;
onSelectTour: (tourId: number) => void;
}
export default function ToursPopup({ open, onClose, onSelectTour }: Props) {
const { t } = useTranslation('tours');
const [tours, setTours] = useState<TourSummary[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
setError(null);
api
.listPublishedTours()
.then((data) => {
if (!cancelled) setTours(data.tours);
})
.catch((err) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : t('loadFailed'));
setTours([]);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, t]);
if (!open) return null;
return (
<div className="tours-popup-backdrop" role="presentation" onClick={onClose}>
<div
className="tours-popup-modal"
role="dialog"
aria-modal="true"
aria-labelledby="tours-popup-title"
onClick={(e) => e.stopPropagation()}
>
<header className="tours-popup-header">
<h2 id="tours-popup-title">{t('popupTitle')}</h2>
<button type="button" className="tours-popup-close" onClick={onClose} aria-label={t('close')}>
×
</button>
</header>
<p className="tours-popup-hint">{t('popupHint')}</p>
{loading && <p className="tours-popup-muted">{t('loading')}</p>}
{error && <p className="tours-popup-error">{error}</p>}
{!loading && !error && tours.length === 0 && (
<p className="tours-popup-muted">{t('noPublished')}</p>
)}
<ul className="tours-popup-list">
{tours.map((tour) => {
const cover = tour.coverThumbnailPath || tour.coverImagePath;
return (
<li key={tour.id}>
<button
type="button"
className="tours-popup-card"
onClick={() => onSelectTour(tour.id)}
>
<div className="tours-popup-cover">
{cover ? (
<img src={imageUrl(cover)} alt="" />
) : (
<span className="tours-popup-cover-empty" aria-hidden />
)}
</div>
<div className="tours-popup-body">
<strong>{tour.title}</strong>
{tour.description ? <p>{tour.description}</p> : null}
<span className="tours-popup-meta">
{t('stopCount', { count: tour.stopCount })}
</span>
</div>
</button>
</li>
);
})}
</ul>
</div>
</div>
);
}
+80 -64
View File
@@ -10,6 +10,7 @@ import type {
ArtistPeriod, ArtistPeriod,
ArtistNavigation, ArtistNavigation,
MovementArtistGroup, MovementArtistGroup,
TourGalleryDetail,
} from '../types'; } from '../types';
import { galleryImageUrlWithRevision, imageUrl, api } from '../api/client'; import { galleryImageUrlWithRevision, imageUrl, api } from '../api/client';
import { comparePaintingsChronological, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils'; import { comparePaintingsChronological, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
@@ -75,7 +76,12 @@ interface MovementGalleryProps extends BaseGalleryProps {
data: MovementGalleryDetail; data: MovementGalleryDetail;
} }
type Props = ArtistGalleryProps | MovementGalleryProps; interface TourGalleryProps extends BaseGalleryProps {
mode: 'tour';
data: TourGalleryDetail;
}
type Props = ArtistGalleryProps | MovementGalleryProps | TourGalleryProps;
const WALL_HEIGHT = 4.2; const WALL_HEIGHT = 4.2;
const WALL_THICKNESS = 0.18; const WALL_THICKNESS = 0.18;
@@ -97,9 +103,6 @@ const MAX_FRAME_H = 1.35;
const MIN_HALL_SIZE = 9; const MIN_HALL_SIZE = 9;
const ROW_GAP = 0.2; const ROW_GAP = 0.2;
const WALL_PADDING = 1.4; const WALL_PADDING = 1.4;
/** Above this count, use a long corridor (short back wall, extended side walls). */
const CORRIDOR_CATALOG_THRESHOLD = 15;
const BACK_WALL_MAX_PAINTINGS = 8;
/** Every wall shows at most one row; side-wall depth grows to fit the catalog. */ /** Every wall shows at most one row; side-wall depth grows to fit the catalog. */
const MAX_WALL_ROWS = 1; const MAX_WALL_ROWS = 1;
const DOOR_WIDTH = 2.4; const DOOR_WIDTH = 2.4;
@@ -290,36 +293,18 @@ function minSpanForWall(paintings: Painting[], maxRows: number = paintings.lengt
return Math.max(MIN_HALL_SIZE, best); return Math.max(MIN_HALL_SIZE, best);
} }
/** Left → right on each wall: later works on the left, earlier works on the right. */ /**
function orderPaintingsForWallDisplay(paintings: Painting[]) { * Visit order along the side walls: first half on the left (starting at the
return [...paintings].sort(comparePaintingsChronological).reverse(); * entrance, left of the opening view), second half on the right (ending at the
} * entrance). Back wall stays empty so first/last always sit on left/right.
*/
function distributePaintingsAcrossWalls(paintings: Painting[]) { function distributePaintingsAcrossWalls(paintings: Painting[]) {
const sorted = [...paintings].sort(comparePaintingsChronological); const ordered = [...paintings].sort(comparePaintingsChronological);
const back: Painting[] = []; const mid = Math.ceil(ordered.length / 2);
const left: Painting[] = [];
const right: Painting[] = [];
if (sorted.length <= CORRIDOR_CATALOG_THRESHOLD) {
sorted.forEach((p, i) => {
if (i % 3 === 0) back.push(p);
else if (i % 3 === 1) left.push(p);
else right.push(p);
});
} else {
const backCount = Math.min(BACK_WALL_MAX_PAINTINGS, Math.max(4, Math.ceil(sorted.length * 0.12)));
back.push(...sorted.slice(0, backCount));
sorted.slice(backCount).forEach((p, i) => {
if (i % 2 === 0) left.push(p);
else right.push(p);
});
}
return [ return [
orderPaintingsForWallDisplay(back), [] as Painting[],
orderPaintingsForWallDisplay(left), ordered.slice(0, mid),
orderPaintingsForWallDisplay(right), ordered.slice(mid),
]; ];
} }
@@ -377,12 +362,13 @@ function layoutWallSlots(
position: [s.offset, y, -halfD + inset + WALL_STANDOFF + BACK_WALL_EXTRA], position: [s.offset, y, -halfD + inset + WALL_STANDOFF + BACK_WALL_EXTRA],
}); });
} else if (side === 'left') { } else if (side === 'left') {
// Flip along-wall offset so index 0 is at the entrance (+Z), left of view.
slots.push({ slots.push({
maxW: s.maxW, maxW: s.maxW,
maxH: s.maxH, maxH: s.maxH,
rotationY: Math.PI / 2, rotationY: Math.PI / 2,
side, side,
position: [-halfW + inset + WALL_STANDOFF, y, s.offset], position: [-halfW + inset + WALL_STANDOFF, y, -s.offset],
}); });
} else { } else {
slots.push({ slots.push({
@@ -1690,16 +1676,38 @@ export default function VirtualGallery(props: Props) {
} = props; } = props;
const isMovement = props.mode === 'movement'; const isMovement = props.mode === 'movement';
const hallKey = isMovement ? props.data.movement.id : props.data.artist.id; const isTour = props.mode === 'tour';
const hallTitle = isMovement ? props.data.movement.name : props.data.artist.name; const isWingedHall = isMovement || isTour;
const movementColor = isMovement ? props.data.movement.color : props.data.artist.movement_color; const hallKey = isTour
? props.data.tour.id
: isMovement
? props.data.movement.id
: props.data.artist.id;
const hallTitle = isTour
? props.data.tour.title
: isMovement
? props.data.movement.name
: props.data.artist.name;
const movementColor = isTour
? DEFAULT_MOVEMENT_COLOR
: isMovement
? props.data.movement.color
: props.data.artist.movement_color;
const initialPaintings = useMemo(() => { const initialPaintings = useMemo(() => {
if (props.mode === 'movement') { if (props.mode === 'movement') {
return [...props.data.paintings].sort(comparePaintingsChronological); return [...props.data.paintings].sort(comparePaintingsChronological);
} }
return props.data.paintings; return props.data.paintings;
}, [props.mode, props.mode === 'movement' ? props.data.movement.id : props.data.artist.id, props.data.paintings]); }, [
const initialPeriods = isMovement ? [] : props.data.periods; props.mode,
props.mode === 'tour'
? props.data.tour.id
: props.mode === 'movement'
? props.data.movement.id
: props.data.artist.id,
props.data.paintings,
]);
const initialPeriods = props.mode === 'artist' ? props.data.periods : [];
const [paintings, setPaintings] = useState(initialPaintings); const [paintings, setPaintings] = useState(initialPaintings);
const [periods, setPeriods] = useState(initialPeriods); const [periods, setPeriods] = useState(initialPeriods);
@@ -1779,8 +1787,8 @@ export default function VirtualGallery(props: Props) {
); );
const movementHalls = useMemo( const movementHalls = useMemo(
() => (isMovement ? buildAllMovementHallLayouts(paintings) : []), () => (isWingedHall ? buildAllMovementHallLayouts(paintings) : []),
[isMovement, paintings] [isWingedHall, paintings]
); );
const [hallIndex, setHallIndex] = useState(0); const [hallIndex, setHallIndex] = useState(0);
@@ -1790,11 +1798,11 @@ export default function VirtualGallery(props: Props) {
}, [hallKey]); }, [hallKey]);
const layout = useMemo(() => { const layout = useMemo(() => {
if (isMovement && movementHalls.length > 0) { if (isWingedHall && movementHalls.length > 0) {
return movementHalls[Math.min(hallIndex, movementHalls.length - 1)]; return movementHalls[Math.min(hallIndex, movementHalls.length - 1)];
} }
return buildHallLayout(paintings, periods); return buildHallLayout(paintings, periods);
}, [isMovement, movementHalls, hallIndex, paintings, periods]); }, [isWingedHall, movementHalls, hallIndex, paintings, periods]);
const gallerySceneLoading = active && !glLost && (!canvasReady || texturesPending > 0); const gallerySceneLoading = active && !glLost && (!canvasReady || texturesPending > 0);
@@ -1809,7 +1817,7 @@ export default function VirtualGallery(props: Props) {
return computeSideWallWindows(layout as MovementHallLayout, interiorStyle); return computeSideWallWindows(layout as MovementHallLayout, interiorStyle);
}, [isMovement, interiorStyle, layout]); }, [isMovement, interiorStyle, layout]);
const hasNextHall = isMovement && movementHalls.length > 1 && hallIndex < movementHalls.length - 1; const hasNextHall = isWingedHall && movementHalls.length > 1 && hallIndex < movementHalls.length - 1;
const halfW = layout.width / 2 - 0.55; const halfW = layout.width / 2 - 0.55;
const halfD = layout.depth / 2 - 0.35; const halfD = layout.depth / 2 - 0.35;
@@ -1869,7 +1877,7 @@ export default function VirtualGallery(props: Props) {
}, [hallKey, initialPos, initialTarget]); }, [hallKey, initialPos, initialTarget]);
const openExitNav = useCallback(async () => { const openExitNav = useCallback(async () => {
if (isMovement) { if (isWingedHall) {
setShowExitNav(true); setShowExitNav(true);
return; return;
} }
@@ -1883,9 +1891,9 @@ export default function VirtualGallery(props: Props) {
} finally { } finally {
setNavLoading(false); setNavLoading(false);
} }
}, [isMovement, onBack, isMovement ? undefined : props.data.artist.id]); }, [isWingedHall, onBack, !isWingedHall ? props.data.artist.id : undefined]);
const backExitZ = isMovement ? -layout.depth / 2 + 0.55 : layout.depth / 2 - 0.55; const backExitZ = isWingedHall ? -layout.depth / 2 + 0.55 : layout.depth / 2 - 0.55;
const frontPassageZ = layout.depth / 2 - 0.55; const frontPassageZ = layout.depth / 2 - 0.55;
const moveCamera = useCallback( const moveCamera = useCallback(
@@ -1910,7 +1918,7 @@ export default function VirtualGallery(props: Props) {
pos.x = Math.max(-halfW, Math.min(halfW, pos.x)); pos.x = Math.max(-halfW, Math.min(halfW, pos.x));
target.x = Math.max(-halfW, Math.min(halfW, target.x)); target.x = Math.max(-halfW, Math.min(halfW, target.x));
if (isMovement) { if (isWingedHall) {
pos.z = Math.max(-halfD, Math.min(halfD, pos.z)); pos.z = Math.max(-halfD, Math.min(halfD, pos.z));
target.z = Math.max(-halfD, Math.min(halfD, target.z)); target.z = Math.max(-halfD, Math.min(halfD, target.z));
const atBackExit = pos.z < backExitZ + 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6; const atBackExit = pos.z < backExitZ + 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
@@ -1930,7 +1938,7 @@ export default function VirtualGallery(props: Props) {
setCamPos(pos); setCamPos(pos);
setCamTarget(target); setCamTarget(target);
}, },
[halfW, halfD, exitZ, isMovement, backExitZ, frontPassageZ, hasNextHall] [halfW, halfD, exitZ, isWingedHall, backExitZ, frontPassageZ, hasNextHall]
); );
useEffect(() => { useEffect(() => {
@@ -1947,7 +1955,7 @@ export default function VirtualGallery(props: Props) {
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
keysPressed.current.add(e.key); keysPressed.current.add(e.key);
if ((e.key === 'e' || e.key === 'E') && !showExitNav) { if ((e.key === 'e' || e.key === 'E') && !showExitNav) {
if (isMovement && nearPassage && hasNextHall) { if (isWingedHall && nearPassage && hasNextHall) {
goToNextHall(); goToNextHall();
} else { } else {
openExitNav(); openExitNav();
@@ -1975,15 +1983,19 @@ export default function VirtualGallery(props: Props) {
window.removeEventListener('keyup', onKeyUp); window.removeEventListener('keyup', onKeyUp);
clearInterval(interval); clearInterval(interval);
}; };
}, [active, moveCamera, showExitNav, openExitNav, isMovement, nearPassage, hasNextHall, goToNextHall]); }, [active, moveCamera, showExitNav, openExitNav, isWingedHall, nearPassage, hasNextHall, goToNextHall]);
const handleNavigate = (artistId: number) => { const handleNavigate = (artistId: number) => {
if (isMovement) return; if (isWingedHall) return;
setShowExitNav(false); setShowExitNav(false);
props.onNavigateArtist(artistId); props.onNavigateArtist(artistId);
}; };
const subtitle = isMovement const subtitle = isTour
? `Guided tour · ${paintings.length} works${
movementHalls.length > 1 ? ` · Wing ${hallIndex + 1}/${movementHalls.length}` : ''
}`
: isMovement
? interiorStyle ? interiorStyle
? `${interiorStyle.subtitle} · ${paintings.length} works${ ? `${interiorStyle.subtitle} · ${paintings.length} works${
movementHalls.length > 1 movementHalls.length > 1
@@ -2029,7 +2041,7 @@ export default function VirtualGallery(props: Props) {
} }
}; };
const exitHint = isMovement ? ( const exitHint = isWingedHall ? (
<> <>
Back wall: <kbd>E</kbd> for wing navigator · Front arch: next wing Back wall: <kbd>E</kbd> for wing navigator · Front arch: next wing
{movementHalls.length > 1 ? ` (${hallIndex + 1}/${movementHalls.length})` : ''} {movementHalls.length > 1 ? ` (${hallIndex + 1}/${movementHalls.length})` : ''}
@@ -2039,11 +2051,15 @@ export default function VirtualGallery(props: Props) {
); );
const hallSubtitle = const hallSubtitle =
isMovement && 'yearLabel' in layout isWingedHall && 'yearLabel' in layout
? `Wing ${hallIndex + 1} of ${movementHalls.length} · ${(layout as MovementHallLayout).yearLabel}` ? `Wing ${hallIndex + 1} of ${movementHalls.length}${
isMovement ? ` · ${(layout as MovementHallLayout).yearLabel}` : ''
}`
: undefined; : undefined;
const instructionsTitle = isMovement const instructionsTitle = isTour
? `${hallTitle} · Guided tour`
: isMovement
? interiorStyle ? interiorStyle
? `${hallTitle} · ${interiorStyle.label}` ? `${hallTitle} · ${interiorStyle.label}`
: `${hallTitle} Gallery` : `${hallTitle} Gallery`
@@ -2059,9 +2075,9 @@ export default function VirtualGallery(props: Props) {
</div> </div>
<div className="gallery-header-meta"> <div className="gallery-header-meta">
<button type="button" className="gallery-exit-btn" onClick={openExitNav}> <button type="button" className="gallery-exit-btn" onClick={openExitNav}>
{isMovement ? 'Wings / Exit →' : 'Exit →'} {isWingedHall ? 'Wings / Exit →' : 'Exit →'}
</button> </button>
{!isMovement && ( {!isWingedHall && (
<button className="gallery-bio-btn" onClick={props.onBioClick}>Biography</button> <button className="gallery-bio-btn" onClick={props.onBioClick}>Biography</button>
)} )}
</div> </div>
@@ -2124,11 +2140,11 @@ export default function VirtualGallery(props: Props) {
movementColor={movementColor} movementColor={movementColor}
interiorStyle={interiorStyle} interiorStyle={interiorStyle}
imageRevisions={imageRevisions} imageRevisions={imageRevisions}
showCaptions={isMovement} showCaptions={isWingedHall}
onPaintingClick={onPaintingClick} onPaintingClick={onPaintingClick}
onExitActivate={openExitNav} onExitActivate={openExitNav}
nearExit={nearExit} nearExit={nearExit}
movementMode={isMovement} movementMode={isWingedHall}
computedWindows={computedWindows} computedWindows={computedWindows}
hasNextHall={hasNextHall} hasNextHall={hasNextHall}
onNextHall={goToNextHall} onNextHall={goToNextHall}
@@ -2139,7 +2155,7 @@ export default function VirtualGallery(props: Props) {
</Canvas> </Canvas>
</div> </div>
{!isMovement && showExitNav && ( {!isWingedHall && showExitNav && (
<NavigationPanel <NavigationPanel
navigation={navigation} navigation={navigation}
loading={navLoading} loading={navLoading}
@@ -2148,7 +2164,7 @@ export default function VirtualGallery(props: Props) {
/> />
)} )}
{isMovement && showExitNav && ( {isWingedHall && showExitNav && (
<MovementHallNavPanel <MovementHallNavPanel
movementName={hallTitle} movementName={hallTitle}
halls={movementHalls} halls={movementHalls}
@@ -2176,8 +2192,8 @@ export default function VirtualGallery(props: Props) {
<li><kbd>A</kbd> / <kbd></kbd> / <kbd>Q</kbd> Turn left</li> <li><kbd>A</kbd> / <kbd></kbd> / <kbd>Q</kbd> Turn left</li>
<li><kbd>D</kbd> / <kbd></kbd> Turn right</li> <li><kbd>D</kbd> / <kbd></kbd> Turn right</li>
<li>Drag on the view to look around</li> <li>Drag on the view to look around</li>
<li>Click a painting to view details and influences</li> <li>Click a painting to view details{isTour ? ' and tour notes' : ' and influences'}</li>
{isMovement ? ( {isWingedHall ? (
<> <>
<li>Date and artist labels appear below each frame</li> <li>Date and artist labels appear below each frame</li>
<li>Works hang on left &amp; right walls up to ~55 per wing</li> <li>Works hang on left &amp; right walls up to ~55 per wing</li>
+5 -1
View File
@@ -12,6 +12,7 @@ import enAnnotations from '../locales/en/annotations.json';
import enDebug from '../locales/en/debug.json'; import enDebug from '../locales/en/debug.json';
import enTranslations from '../locales/en/translations.json'; import enTranslations from '../locales/en/translations.json';
import enInfluences from '../locales/en/influences.json'; import enInfluences from '../locales/en/influences.json';
import enTours from '../locales/en/tours.json';
import ruCommon from '../locales/ru/common.json'; import ruCommon from '../locales/ru/common.json';
import ruHome from '../locales/ru/home.json'; import ruHome from '../locales/ru/home.json';
@@ -23,6 +24,7 @@ import ruAnnotations from '../locales/ru/annotations.json';
import ruDebug from '../locales/ru/debug.json'; import ruDebug from '../locales/ru/debug.json';
import ruTranslations from '../locales/ru/translations.json'; import ruTranslations from '../locales/ru/translations.json';
import ruInfluences from '../locales/ru/influences.json'; import ruInfluences from '../locales/ru/influences.json';
import ruTours from '../locales/ru/tours.json';
const initialLocale = readStoredLocale(); const initialLocale = readStoredLocale();
writeStoredLocale(initialLocale); writeStoredLocale(initialLocale);
@@ -31,7 +33,7 @@ void i18n.use(initReactI18next).init({
lng: initialLocale, lng: initialLocale,
fallbackLng: 'en', fallbackLng: 'en',
supportedLngs: ['en', 'ru'], supportedLngs: ['en', 'ru'],
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences'], ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences', 'tours'],
defaultNS: 'common', defaultNS: 'common',
resources: { resources: {
en: { en: {
@@ -45,6 +47,7 @@ void i18n.use(initReactI18next).init({
debug: enDebug, debug: enDebug,
translations: enTranslations, translations: enTranslations,
influences: enInfluences, influences: enInfluences,
tours: enTours,
}, },
ru: { ru: {
common: ruCommon, common: ruCommon,
@@ -57,6 +60,7 @@ void i18n.use(initReactI18next).init({
debug: ruDebug, debug: ruDebug,
translations: ruTranslations, translations: ruTranslations,
influences: ruInfluences, influences: ruInfluences,
tours: ruTours,
}, },
}, },
interpolation: { escapeValue: false }, interpolation: { escapeValue: false },
+5
View File
@@ -15,6 +15,11 @@
"checkup": "Checkup", "checkup": "Checkup",
"translations": "Translations", "translations": "Translations",
"influences": "Influences", "influences": "Influences",
"tours": "Tours",
"toursEditor": "Tour editor",
"openingTourGallery": "Opening guided tour…",
"tourEmpty": "This tour has no paintings yet.",
"tourLoadFailed": "Failed to load the tour.",
"curatorRequiredTitle": "Curator access required", "curatorRequiredTitle": "Curator access required",
"curatorRequiredBody": "Sign in as a curator to use this tool.", "curatorRequiredBody": "Sign in as a curator to use this tool.",
"backToGalleryBtn": "Back to gallery" "backToGalleryBtn": "Back to gallery"
+5 -1
View File
@@ -7,5 +7,9 @@
"catalogPosition": "Catalog position", "catalogPosition": "Catalog position",
"lightboxHint": "Click anywhere to close", "lightboxHint": "Click anywhere to close",
"prevPainting": "Previous painting", "prevPainting": "Previous painting",
"nextPainting": "Next painting" "nextPainting": "Next painting",
"tourNotes": "Tour notes",
"tourNotesFor": "Tour notes · {{title}}",
"tourNotesEmpty": "No notes for this stop.",
"tourStopPosition": "Stop {{current}} of {{total}}"
} }
+28
View File
@@ -0,0 +1,28 @@
{
"title": "Guided tours",
"popupTitle": "Guided tours",
"popupHint": "Choose a curated walkthrough of selected works.",
"close": "Close",
"loading": "Loading…",
"loadFailed": "Failed to load tours",
"noPublished": "No published tours yet.",
"noTours": "No tours yet. Create one to get started.",
"stopCount": "{{count}} stops",
"back": "← Back to gallery",
"create": "Create",
"newTourPlaceholder": "New tour title…",
"selectTour": "Select a tour to edit.",
"tourTitle": "Title",
"tourDescription": "Description",
"status": "Status",
"draft": "Draft",
"published": "Published",
"saveMeta": "Save details",
"delete": "Delete tour",
"confirmDelete": "Delete this tour and all its stops?",
"searchPainting": "Search paintings to add…",
"saveStops": "Save stops",
"remove": "Remove",
"stopBodyPlaceholder": "Tour notes for this stop (English)…",
"noStops": "No stops yet. Search and add paintings."
}
+5
View File
@@ -15,6 +15,11 @@
"checkup": "Проверка", "checkup": "Проверка",
"translations": "Переводы", "translations": "Переводы",
"influences": "Влияния", "influences": "Влияния",
"tours": "Экскурсии",
"toursEditor": "Редактор экскурсий",
"openingTourGallery": "Открытие экскурсии…",
"tourEmpty": "В этой экскурсии пока нет картин.",
"tourLoadFailed": "Не удалось загрузить экскурсию.",
"curatorRequiredTitle": "Требуется доступ куратора", "curatorRequiredTitle": "Требуется доступ куратора",
"curatorRequiredBody": "Войдите как куратор, чтобы использовать этот инструмент.", "curatorRequiredBody": "Войдите как куратор, чтобы использовать этот инструмент.",
"backToGalleryBtn": "Вернуться в галерею" "backToGalleryBtn": "Вернуться в галерею"
+5 -1
View File
@@ -7,5 +7,9 @@
"catalogPosition": "Позиция в каталоге", "catalogPosition": "Позиция в каталоге",
"lightboxHint": "Нажмите в любом месте, чтобы закрыть", "lightboxHint": "Нажмите в любом месте, чтобы закрыть",
"prevPainting": "Предыдущая картина", "prevPainting": "Предыдущая картина",
"nextPainting": "Следующая картина" "nextPainting": "Следующая картина",
"tourNotes": "Текст экскурсии",
"tourNotesFor": "Экскурсия · {{title}}",
"tourNotesEmpty": "Для этой остановки нет текста.",
"tourStopPosition": "Остановка {{current}} из {{total}}"
} }
+28
View File
@@ -0,0 +1,28 @@
{
"title": "Экскурсии",
"popupTitle": "Экскурсии",
"popupHint": "Выберите кураторскую подборку произведений.",
"close": "Закрыть",
"loading": "Загрузка…",
"loadFailed": "Не удалось загрузить экскурсии",
"noPublished": "Пока нет опубликованных экскурсий.",
"noTours": "Экскурсий пока нет. Создайте первую.",
"stopCount": "{{count}} остановок",
"back": "← В галерею",
"create": "Создать",
"newTourPlaceholder": "Название новой экскурсии…",
"selectTour": "Выберите экскурсию для редактирования.",
"tourTitle": "Название",
"tourDescription": "Описание",
"status": "Статус",
"draft": "Черновик",
"published": "Опубликовано",
"saveMeta": "Сохранить сведения",
"delete": "Удалить экскурсию",
"confirmDelete": "Удалить эту экскурсию и все остановки?",
"searchPainting": "Поиск картин для добавления…",
"saveStops": "Сохранить остановки",
"remove": "Убрать",
"stopBodyPlaceholder": "Текст экскурсии для этой остановки (на английском)…",
"noStops": "Остановок пока нет. Найдите и добавьте картины."
}
+225 -17
View File
@@ -9,18 +9,30 @@ import ArtistBio from '../components/ArtistBio';
import CheckupPage from '../pages/CheckupPage'; import CheckupPage from '../pages/CheckupPage';
import TranslationsPage from '../pages/TranslationsPage'; import TranslationsPage from '../pages/TranslationsPage';
import InfluencesPage from '../pages/InfluencesPage'; import InfluencesPage from '../pages/InfluencesPage';
import ToursPage from '../pages/ToursPage';
import CuratorLoginModal from '../components/CuratorLoginModal'; import CuratorLoginModal from '../components/CuratorLoginModal';
import ToursPopup from '../components/ToursPopup';
import CatalogSearchBar from '../components/CatalogSearchBar'; import CatalogSearchBar from '../components/CatalogSearchBar';
import LocaleSwitcher from '../components/LocaleSwitcher'; import LocaleSwitcher from '../components/LocaleSwitcher';
import GalleryLoadingMarker from '../components/GalleryLoadingMarker'; import GalleryLoadingMarker from '../components/GalleryLoadingMarker';
import '../components/CatalogSearchBar.css'; import '../components/CatalogSearchBar.css';
import '../components/CuratorLoginModal.css'; import '../components/CuratorLoginModal.css';
import '../components/ToursPopup.css';
import '../components/LocaleSwitcher.css'; import '../components/LocaleSwitcher.css';
import '../pages/TranslationsPage.css'; import '../pages/TranslationsPage.css';
import '../pages/InfluencesPage.css'; import '../pages/InfluencesPage.css';
import '../pages/ToursPage.css';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client'; import { api, setApiLocale, 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,
TourGalleryDetail,
} from '../types';
import { createViewChangeScheduler } from '../utils/timelineView'; import { createViewChangeScheduler } from '../utils/timelineView';
import { sortArtistPaintingsChronological } from '../utils/paintingUtils'; import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
import { readDebugMode, readDebugShowMore, writeDebugMode, writeDebugShowMore } from '../utils/debugMode'; import { readDebugMode, readDebugShowMore, writeDebugMode, writeDebugShowMore } from '../utils/debugMode';
@@ -31,14 +43,19 @@ type View =
| { type: 'checkup' } | { type: 'checkup' }
| { type: 'translations' } | { type: 'translations' }
| { type: 'influences' } | { type: 'influences' }
| { type: 'tours' }
| { type: 'gallery'; artistId: number; data: ArtistDetail } | { type: 'gallery'; artistId: number; data: ArtistDetail }
| { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail } | { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail }
| { type: 'tour-gallery'; tourId: number; data: TourGalleryDetail }
| { type: 'painting'; paintingId: number; data: PaintingDetail; returnTo: View } | { type: 'painting'; paintingId: number; data: PaintingDetail; returnTo: View }
| { type: 'bio'; artistId: number; data: ArtistDetail; returnTo: View }; | { type: 'bio'; artistId: number; data: ArtistDetail; returnTo: View };
type GallerySession = type GallerySession =
| { kind: 'artist'; artistId: number; data: ArtistDetail } | { kind: 'artist'; artistId: number; data: ArtistDetail }
| { kind: 'movement'; movementId: number; data: MovementGalleryDetail }; | { kind: 'movement'; movementId: number; data: MovementGalleryDetail }
| { kind: 'tour'; tourId: number; data: TourGalleryDetail };
type CuratorLoginRedirect = 'checkup' | 'translations' | 'influences' | 'tours' | null;
function patchPaintingInMovementDetail( function patchPaintingInMovementDetail(
detail: MovementGalleryDetail, detail: MovementGalleryDetail,
@@ -51,6 +68,17 @@ function patchPaintingInMovementDetail(
}; };
} }
function patchPaintingInTourDetail(
detail: TourGalleryDetail,
paintingId: number,
patch: Partial<Painting>
): TourGalleryDetail {
return {
...detail,
paintings: detail.paintings.map((p) => (p.id === paintingId ? { ...p, ...patch } : p)),
};
}
function patchPaintingInArtistDetail( function patchPaintingInArtistDetail(
detail: ArtistDetail, detail: ArtistDetail,
paintingId: number, paintingId: number,
@@ -72,7 +100,8 @@ function patchArtistInArtistDetail(detail: ArtistDetail, patch: Partial<Artist>)
function patchReturnToAfterRemove( function patchReturnToAfterRemove(
returnTo: View, returnTo: View,
freshArtist?: ArtistDetail, freshArtist?: ArtistDetail,
freshMovement?: MovementGalleryDetail freshMovement?: MovementGalleryDetail,
freshTour?: TourGalleryDetail
): View { ): View {
if (returnTo.type === 'gallery' && freshArtist && returnTo.artistId === freshArtist.artist.id) { if (returnTo.type === 'gallery' && freshArtist && returnTo.artistId === freshArtist.artist.id) {
return { ...returnTo, data: freshArtist }; return { ...returnTo, data: freshArtist };
@@ -84,8 +113,14 @@ function patchReturnToAfterRemove(
) { ) {
return { ...returnTo, data: freshMovement }; return { ...returnTo, data: freshMovement };
} }
if (returnTo.type === 'tour-gallery' && freshTour && returnTo.tourId === freshTour.tour.id) {
return { ...returnTo, data: freshTour };
}
if (returnTo.type === 'painting') { if (returnTo.type === 'painting') {
return { ...returnTo, returnTo: patchReturnToAfterRemove(returnTo.returnTo, freshArtist, freshMovement) }; return {
...returnTo,
returnTo: patchReturnToAfterRemove(returnTo.returnTo, freshArtist, freshMovement, freshTour),
};
} }
if (returnTo.type === 'bio') { if (returnTo.type === 'bio') {
const data = const data =
@@ -93,7 +128,7 @@ function patchReturnToAfterRemove(
return { return {
...returnTo, ...returnTo,
data, data,
returnTo: patchReturnToAfterRemove(returnTo.returnTo, freshArtist, freshMovement), returnTo: patchReturnToAfterRemove(returnTo.returnTo, freshArtist, freshMovement, freshTour),
}; };
} }
return returnTo; return returnTo;
@@ -131,7 +166,8 @@ export default function HomePage() {
const [debugMode, setDebugMode] = useState(readDebugMode); const [debugMode, setDebugMode] = useState(readDebugMode);
const [debugShowMore, setDebugShowMore] = useState(readDebugShowMore); const [debugShowMore, setDebugShowMore] = useState(readDebugShowMore);
const [loginOpen, setLoginOpen] = useState(false); const [loginOpen, setLoginOpen] = useState(false);
const [loginRedirect, setLoginRedirect] = useState<'checkup' | 'translations' | 'influences' | null>(null); const [loginRedirect, setLoginRedirect] = useState<CuratorLoginRedirect>(null);
const [toursPopupOpen, setToursPopupOpen] = useState(false);
const effectiveDebugMode = debugMode && isCurator; const effectiveDebugMode = debugMode && isCurator;
const [galleryRevision, setGalleryRevision] = useState(0); const [galleryRevision, setGalleryRevision] = useState(0);
const viewRef = useRef(view); const viewRef = useRef(view);
@@ -148,6 +184,8 @@ export default function HomePage() {
setGallerySession({ kind: 'artist', artistId: view.artistId, data: view.data }); setGallerySession({ kind: 'artist', artistId: view.artistId, data: view.data });
} else if (view.type === 'movement-gallery') { } else if (view.type === 'movement-gallery') {
setGallerySession({ kind: 'movement', movementId: view.movementId, data: view.data }); setGallerySession({ kind: 'movement', movementId: view.movementId, data: view.data });
} else if (view.type === 'tour-gallery') {
setGallerySession({ kind: 'tour', tourId: view.tourId, data: view.data });
} else if (view.type === 'timeline') { } else if (view.type === 'timeline') {
setGallerySession(null); setGallerySession(null);
} }
@@ -225,7 +263,7 @@ export default function HomePage() {
writeDebugShowMore(enabled); writeDebugShowMore(enabled);
}; };
const openCuratorLogin = (redirect: 'checkup' | 'translations' | 'influences' | null = null) => { const openCuratorLogin = (redirect: CuratorLoginRedirect = null) => {
setLoginRedirect(redirect); setLoginRedirect(redirect);
setLoginOpen(true); setLoginOpen(true);
}; };
@@ -239,6 +277,8 @@ export default function HomePage() {
setView({ type: 'translations' }); setView({ type: 'translations' });
} else if (loginRedirect === 'influences') { } else if (loginRedirect === 'influences') {
setView({ type: 'influences' }); setView({ type: 'influences' });
} else if (loginRedirect === 'tours') {
setView({ type: 'tours' });
} }
setLoginRedirect(null); setLoginRedirect(null);
}; };
@@ -247,7 +287,12 @@ export default function HomePage() {
await logout(); await logout();
writeDebugMode(false); writeDebugMode(false);
setDebugMode(false); setDebugMode(false);
if (view.type === 'checkup' || view.type === 'translations' || view.type === 'influences') { if (
view.type === 'checkup' ||
view.type === 'translations' ||
view.type === 'influences' ||
view.type === 'tours'
) {
goToTimelineHome(); goToTimelineHome();
} }
}; };
@@ -276,6 +321,14 @@ export default function HomePage() {
setView({ type: 'influences' }); setView({ type: 'influences' });
}; };
const openToursEditor = () => {
if (!isCurator) {
openCuratorLogin('tours');
return;
}
setView({ type: 'tours' });
};
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> = {
@@ -308,6 +361,12 @@ export default function HomePage() {
data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch), data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch),
}; };
} }
if (returnTo.type === 'tour-gallery') {
returnTo = {
...returnTo,
data: patchPaintingInTourDetail(returnTo.data, paintingId, patch),
};
}
return { ...current, data: updatedData, returnTo }; return { ...current, data: updatedData, returnTo };
}); });
@@ -322,6 +381,9 @@ export default function HomePage() {
if (session?.kind === 'movement') { if (session?.kind === 'movement') {
return { ...session, data: patchPaintingInMovementDetail(session.data, paintingId, patch) }; return { ...session, data: patchPaintingInMovementDetail(session.data, paintingId, patch) };
} }
if (session?.kind === 'tour') {
return { ...session, data: patchPaintingInTourDetail(session.data, paintingId, patch) };
}
return session; return session;
}); });
}, []); }, []);
@@ -353,6 +415,12 @@ export default function HomePage() {
data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch), data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch),
}; };
} }
if (returnTo.type === 'tour-gallery') {
returnTo = {
...returnTo,
data: patchPaintingInTourDetail(returnTo.data, paintingId, patch),
};
}
return { ...current, data: updatedData, returnTo }; return { ...current, data: updatedData, returnTo };
}); });
@@ -367,6 +435,9 @@ export default function HomePage() {
if (session?.kind === 'movement') { if (session?.kind === 'movement') {
return { ...session, data: patchPaintingInMovementDetail(session.data, paintingId, patch) }; return { ...session, data: patchPaintingInMovementDetail(session.data, paintingId, patch) };
} }
if (session?.kind === 'tour') {
return { ...session, data: patchPaintingInTourDetail(session.data, paintingId, patch) };
}
return session; return session;
}); });
}, },
@@ -455,6 +526,32 @@ export default function HomePage() {
setView({ type: 'movement-gallery', movementId, data }); setView({ type: 'movement-gallery', movementId, data });
}, []); }, []);
const openTourGallery = useCallback((tourId: number, data: TourGalleryDetail) => {
const session: GallerySession = { kind: 'tour', tourId, data };
setGallerySession(session);
setView({ type: 'tour-gallery', tourId, data });
}, []);
const handleSelectPublishedTour = useCallback(
async (tourId: number) => {
setToursPopupOpen(false);
setGalleryEntryLoading(t('openingTourGallery'));
try {
const data = await api.getTour(tourId);
if (!data.paintings.length) {
setError(t('tourEmpty'));
return;
}
openTourGallery(tourId, data);
} catch {
setError(t('tourLoadFailed'));
} finally {
setGalleryEntryLoading(null);
}
},
[openTourGallery, t]
);
const handleArtistClick = async (artistId: number) => { const handleArtistClick = async (artistId: number) => {
setGalleryEntryLoading('Opening artist gallery…'); setGalleryEntryLoading('Opening artist gallery…');
try { try {
@@ -517,23 +614,36 @@ export default function HomePage() {
const currentView = viewRef.current; const currentView = viewRef.current;
if (currentView.type !== 'painting' || currentView.paintingId !== paintingId) return; if (currentView.type !== 'painting' || currentView.paintingId !== paintingId) return;
const sorted = sortArtistPaintingsChronological(detailArtistPaintings); const sorted =
gallerySession?.kind === 'tour' || currentView.returnTo.type === 'tour-gallery'
? detailArtistPaintings
: sortArtistPaintingsChronological(detailArtistPaintings);
const nextId = catalogNavigateTarget(sorted, paintingId); const nextId = catalogNavigateTarget(sorted, paintingId);
const inMovementCatalog = const inMovementCatalog =
gallerySession?.kind === 'movement' || currentView.returnTo.type === 'movement-gallery'; gallerySession?.kind === 'movement' || currentView.returnTo.type === 'movement-gallery';
const inTourCatalog =
gallerySession?.kind === 'tour' || currentView.returnTo.type === 'tour-gallery';
await api.deletePainting(paintingId); await api.deletePainting(paintingId);
const freshArtist = await api.getArtist(artistId); const freshArtist = await api.getArtist(artistId);
let freshMovement: MovementGalleryDetail | undefined; let freshMovement: MovementGalleryDetail | undefined;
let freshTour: TourGalleryDetail | undefined;
if (gallerySession?.kind === 'movement') { if (gallerySession?.kind === 'movement') {
freshMovement = await api.getMovementGallery(gallerySession.movementId); freshMovement = await api.getMovementGallery(gallerySession.movementId);
} else if (currentView.returnTo.type === 'movement-gallery') { } else if (currentView.returnTo.type === 'movement-gallery') {
freshMovement = await api.getMovementGallery(currentView.returnTo.movementId); freshMovement = await api.getMovementGallery(currentView.returnTo.movementId);
} }
if (gallerySession?.kind === 'tour') {
freshTour = await api.getTour(gallerySession.tourId);
} else if (currentView.returnTo.type === 'tour-gallery') {
freshTour = await api.getTour(currentView.returnTo.tourId);
}
const freshCatalog = const freshCatalog =
inMovementCatalog && freshMovement inTourCatalog && freshTour
? freshTour.paintings
: inMovementCatalog && freshMovement
? sortArtistPaintingsChronological(freshMovement.paintings) ? sortArtistPaintingsChronological(freshMovement.paintings)
: sortArtistPaintingsChronological(freshArtist.paintings); : sortArtistPaintingsChronological(freshArtist.paintings);
@@ -556,6 +666,9 @@ export default function HomePage() {
if (session.kind === 'movement' && freshMovement) { if (session.kind === 'movement' && freshMovement) {
return { ...session, data: freshMovement }; return { ...session, data: freshMovement };
} }
if (session.kind === 'tour' && freshTour) {
return { ...session, data: freshTour };
}
return session; return session;
}); });
@@ -570,7 +683,8 @@ export default function HomePage() {
const patchedReturnTo = patchReturnToAfterRemove( const patchedReturnTo = patchReturnToAfterRemove(
currentView.returnTo, currentView.returnTo,
freshArtist, freshArtist,
freshMovement freshMovement,
freshTour
); );
detailReturnToRef.current = patchedReturnTo; detailReturnToRef.current = patchedReturnTo;
@@ -581,6 +695,9 @@ export default function HomePage() {
if (current.type === 'movement-gallery' && freshMovement) { if (current.type === 'movement-gallery' && freshMovement) {
return { ...current, data: freshMovement }; return { ...current, data: freshMovement };
} }
if (current.type === 'tour-gallery' && freshTour) {
return { ...current, data: freshTour };
}
if (current.type !== 'painting' || current.paintingId !== paintingId) { if (current.type !== 'painting' || current.paintingId !== paintingId) {
return current; return current;
} }
@@ -612,12 +729,20 @@ export default function HomePage() {
} }
const artistId = view.data.painting.artist_id; const artistId = view.data.painting.artist_id;
if (gallerySession?.kind === 'tour') {
setDetailArtistPaintings(gallerySession.data.paintings);
return;
}
if (gallerySession?.kind === 'artist' && gallerySession.artistId === artistId) { if (gallerySession?.kind === 'artist' && gallerySession.artistId === artistId) {
setDetailArtistPaintings(gallerySession.data.paintings); setDetailArtistPaintings(gallerySession.data.paintings);
return; return;
} }
const returnTo = detailReturnToRef.current; const returnTo = detailReturnToRef.current;
if (returnTo.type === 'tour-gallery') {
setDetailArtistPaintings(returnTo.data.paintings);
return;
}
if (returnTo.type === 'movement-gallery') { if (returnTo.type === 'movement-gallery') {
setDetailArtistPaintings(sortArtistPaintingsChronological(returnTo.data.paintings)); setDetailArtistPaintings(sortArtistPaintingsChronological(returnTo.data.paintings));
return; return;
@@ -637,12 +762,31 @@ export default function HomePage() {
}; };
}, [view, gallerySession]); }, [view, gallerySession]);
const sortedDetailArtistPaintings = useMemo( const sortedDetailArtistPaintings = useMemo(() => {
() => sortArtistPaintingsChronological(detailArtistPaintings), const fromTourSession = gallerySession?.kind === 'tour';
[detailArtistPaintings] const fromTourReturn =
); view.type === 'painting' && view.returnTo.type === 'tour-gallery';
if (fromTourSession || fromTourReturn) {
return detailArtistPaintings;
}
return sortArtistPaintingsChronological(detailArtistPaintings);
}, [detailArtistPaintings, gallerySession, view]);
const galleryActive = view.type === 'gallery' || view.type === 'movement-gallery'; const tourOverlay =
view.type === 'painting' && gallerySession?.kind === 'tour'
? {
title: gallerySession.data.tour.title,
text: gallerySession.data.stopBodies[view.paintingId] ?? '',
}
: view.type === 'painting' && view.returnTo.type === 'tour-gallery'
? {
title: view.returnTo.data.tour.title,
text: view.returnTo.data.stopBodies[view.paintingId] ?? '',
}
: null;
const galleryActive =
view.type === 'gallery' || view.type === 'movement-gallery' || view.type === 'tour-gallery';
const displayGallery = useMemo((): GallerySession | null => { const displayGallery = useMemo((): GallerySession | null => {
if (view.type === 'gallery') { if (view.type === 'gallery') {
@@ -651,6 +795,9 @@ export default function HomePage() {
if (view.type === 'movement-gallery') { if (view.type === 'movement-gallery') {
return { kind: 'movement', movementId: view.movementId, data: view.data }; return { kind: 'movement', movementId: view.movementId, data: view.data };
} }
if (view.type === 'tour-gallery') {
return { kind: 'tour', tourId: view.tourId, data: view.data };
}
return gallerySession; return gallerySession;
}, [view, gallerySession]); }, [view, gallerySession]);
@@ -679,7 +826,7 @@ export default function HomePage() {
}) })
} }
/> />
) : ( ) : displayGallery.kind === 'movement' ? (
<VirtualGallery <VirtualGallery
key={`movement-${displayGallery.movementId}-${galleryRevision}`} key={`movement-${displayGallery.movementId}-${galleryRevision}`}
mode="movement" mode="movement"
@@ -689,6 +836,16 @@ export default function HomePage() {
onPaintingClick={handlePaintingClick} onPaintingClick={handlePaintingClick}
onBack={goToTimelineHome} onBack={goToTimelineHome}
/> />
) : (
<VirtualGallery
key={`tour-${displayGallery.tourId}-${galleryRevision}`}
mode="tour"
data={displayGallery.data}
imageRevisions={imageRevisions}
active={galleryActive}
onPaintingClick={handlePaintingClick}
onBack={goToTimelineHome}
/>
)} )}
</div> </div>
)} )}
@@ -700,6 +857,8 @@ export default function HomePage() {
data={view.data} data={view.data}
artistPaintings={sortedDetailArtistPaintings} artistPaintings={sortedDetailArtistPaintings}
backLabel={view.returnTo.type === 'timeline' ? t('backToTimeline') : t('backToGallery')} backLabel={view.returnTo.type === 'timeline' ? t('backToTimeline') : t('backToGallery')}
tourTitle={tourOverlay?.title ?? null}
tourText={tourOverlay ? tourOverlay.text : null}
onBack={() => { onBack={() => {
if (view.returnTo.type === 'timeline') { if (view.returnTo.type === 'timeline') {
goToTimelineHome(); goToTimelineHome();
@@ -718,10 +877,18 @@ export default function HomePage() {
gallerySession.movementId === returnTo.movementId gallerySession.movementId === returnTo.movementId
) { ) {
openMovementGallery(gallerySession.movementId, gallerySession.data); openMovementGallery(gallerySession.movementId, gallerySession.data);
} else if (
returnTo.type === 'tour-gallery' &&
gallerySession?.kind === 'tour' &&
gallerySession.tourId === returnTo.tourId
) {
openTourGallery(gallerySession.tourId, gallerySession.data);
} else if (returnTo.type === 'gallery') { } else if (returnTo.type === 'gallery') {
openArtistGallery(returnTo.artistId, returnTo.data); openArtistGallery(returnTo.artistId, returnTo.data);
} else if (returnTo.type === 'movement-gallery') { } else if (returnTo.type === 'movement-gallery') {
openMovementGallery(returnTo.movementId, returnTo.data); openMovementGallery(returnTo.movementId, returnTo.data);
} else if (returnTo.type === 'tour-gallery') {
openTourGallery(returnTo.tourId, returnTo.data);
} else { } else {
setView(returnTo); setView(returnTo);
} }
@@ -778,6 +945,25 @@ export default function HomePage() {
) )
)} )}
{view.type === 'tours' && (
isCurator ? (
<ToursPage onBack={goToTimelineHome} />
) : (
<div className="curator-login-gate">
<h2>{t('curatorRequiredTitle')}</h2>
<p>{t('curatorRequiredBody')}</p>
<div className="curator-login-gate-actions">
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('tours')}>
{t('curatorLogin')}
</button>
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
{t('backToGalleryBtn')}
</button>
</div>
</div>
)
)}
{view.type === 'translations' && ( {view.type === 'translations' && (
isCurator ? ( isCurator ? (
<TranslationsPage onBack={goToTimelineHome} /> <TranslationsPage onBack={goToTimelineHome} />
@@ -828,6 +1014,12 @@ export default function HomePage() {
onLogin={handleCuratorLogin} onLogin={handleCuratorLogin}
/> />
<ToursPopup
open={toursPopupOpen}
onClose={() => setToursPopupOpen(false)}
onSelectTour={(tourId) => void handleSelectPublishedTour(tourId)}
/>
{view.type === 'timeline' && ( {view.type === 'timeline' && (
<div className="home-page"> <div className="home-page">
<header className="site-header"> <header className="site-header">
@@ -864,6 +1056,14 @@ export default function HomePage() {
> >
{t('influences')} {t('influences')}
</button> </button>
<button
type="button"
className="checkup-link-btn"
onClick={openToursEditor}
title="Create and edit guided tours"
>
{t('toursEditor')}
</button>
<button <button
type="button" type="button"
className="checkup-link-btn" className="checkup-link-btn"
@@ -899,6 +1099,14 @@ export default function HomePage() {
{t('curatorLogin')} {t('curatorLogin')}
</button> </button>
)} )}
<button
type="button"
className="checkup-link-btn"
onClick={() => setToursPopupOpen(true)}
title={t('tours')}
>
{t('tours')}
</button>
<LocaleSwitcher onLocaleChange={handleLocaleChange} /> <LocaleSwitcher onLocaleChange={handleLocaleChange} />
</div> </div>
<h1>{t('title')}</h1> <h1>{t('title')}</h1>
+180
View File
@@ -0,0 +1,180 @@
.tours-page {
max-width: 1200px;
margin: 0 auto;
padding: 1.25rem 1.5rem 3rem;
color: #e8d5b5;
min-height: 100vh;
}
.tours-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.tours-header h1 {
margin: 0;
font-size: 1.5rem;
color: #e8d5b5;
}
.tours-back {
border: 1px solid rgba(201, 169, 110, 0.35);
background: rgba(15, 15, 26, 0.85);
color: #c9a96e;
border-radius: 6px;
padding: 0.4rem 0.75rem;
cursor: pointer;
}
.tours-error {
background: rgba(139, 0, 0, 0.3);
border: 1px solid rgba(255, 170, 170, 0.4);
color: #ffaaaa;
padding: 0.65rem 0.85rem;
border-radius: 6px;
margin-bottom: 1rem;
}
.tours-layout {
display: grid;
grid-template-columns: 280px 1fr;
gap: 1rem;
}
.tours-list-panel,
.tours-editor {
border: 1px solid rgba(201, 169, 110, 0.25);
border-radius: 8px;
padding: 0.85rem;
background: rgba(15, 15, 26, 0.45);
}
.tours-create {
display: flex;
gap: 0.4rem;
margin-bottom: 0.75rem;
}
.tours-create input,
.tours-meta input,
.tours-meta textarea,
.tours-meta select,
.tours-stops-toolbar input,
.tours-stops textarea {
border: 1px solid rgba(201, 169, 110, 0.35);
border-radius: 6px;
padding: 0.45rem 0.65rem;
background: rgba(15, 15, 26, 0.85);
color: #e8d5b5;
font: inherit;
width: 100%;
}
.tours-create button,
.tours-meta-actions button,
.tours-stops-toolbar button,
.tours-stop-move button,
.tours-hits button,
.tours-list button {
border: 1px solid rgba(201, 169, 110, 0.35);
background: rgba(15, 15, 26, 0.85);
color: #c9a96e;
border-radius: 6px;
padding: 0.4rem 0.65rem;
cursor: pointer;
font: inherit;
}
.tours-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.35rem;
max-height: 70vh;
overflow: auto;
}
.tours-list button {
width: 100%;
text-align: left;
display: grid;
gap: 0.15rem;
}
.tours-list button.active {
border-color: #e8a040;
color: #e8d5b5;
background: rgba(232, 160, 64, 0.15);
}
.tours-meta {
display: grid;
gap: 0.65rem;
margin-bottom: 1rem;
}
.tours-meta label {
display: grid;
gap: 0.3rem;
font-size: 0.9rem;
}
.tours-meta-actions {
display: flex;
gap: 0.5rem;
}
.tours-stops-toolbar {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.tours-hits {
list-style: none;
margin: 0 0 0.75rem;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.tours-stops {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.85rem;
}
.tours-stop-head {
display: flex;
justify-content: space-between;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.35rem;
}
.tours-stop-move {
display: flex;
gap: 0.25rem;
}
.muted {
color: rgba(201, 169, 110, 0.65);
font-size: 0.85rem;
}
.danger {
color: #ffaaaa !important;
border-color: rgba(255, 170, 170, 0.4) !important;
}
@media (max-width: 900px) {
.tours-layout {
grid-template-columns: 1fr;
}
}
+363
View File
@@ -0,0 +1,363 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { api, type TourSummary } from '../api/client';
import type { Painting } from '../types';
import './ToursPage.css';
interface Props {
onBack: () => void;
}
interface StopDraft {
paintingId: number;
title: string;
artistName: string;
year: number | null;
thumbnailPath: string | null;
body: string;
}
export default function ToursPage({ onBack }: Props) {
const { t } = useTranslation('tours');
const [tours, setTours] = useState<TourSummary[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [status, setStatus] = useState<'draft' | 'published'>('draft');
const [stops, setStops] = useState<StopDraft[]>([]);
const [searchQ, setSearchQ] = useState('');
const [searchHits, setSearchHits] = useState<
Array<{ id: number; title: string; artistName: string; year: number | null; thumbnailPath: string | null }>
>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [newTitle, setNewTitle] = useState('');
const loadList = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await api.listAdminTours();
setTours(data.tours);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
void loadList();
}, [loadList]);
const openTour = async (id: number) => {
setSelectedId(id);
setError(null);
try {
const data = await api.getTour(id);
setTitle(data.tour.title);
setDescription(data.tour.description || '');
setStatus(data.tour.status);
setStops(
data.paintings.map((p: Painting) => ({
paintingId: p.id,
title: p.title,
artistName: p.artist_name || '',
year: p.year ?? null,
thumbnailPath: p.thumbnail_path || null,
body: data.stopBodies[p.id] || '',
})),
);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
}
};
useEffect(() => {
const handle = setTimeout(() => {
void (async () => {
if (searchQ.trim().length < 2) {
setSearchHits([]);
return;
}
try {
const data = await api.search(searchQ.trim(), { types: 'painting', limit: 12 });
setSearchHits(
data.results
.filter((r) => r.type === 'painting')
.map((r) => ({
id: r.id,
title: r.title,
artistName: r.artist_name,
year: r.year,
thumbnailPath: r.thumbnail_path,
})),
);
} catch {
setSearchHits([]);
}
})();
}, 250);
return () => clearTimeout(handle);
}, [searchQ]);
const createTour = async () => {
const name = newTitle.trim();
if (!name) return;
setSaving(true);
setError(null);
try {
const { tour } = await api.createTour({ title: name, status: 'draft' });
setNewTitle('');
await loadList();
await openTour(tour.id);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setSaving(false);
}
};
const saveMeta = async () => {
if (!selectedId) return;
setSaving(true);
setError(null);
try {
await api.updateTour(selectedId, {
title: title.trim(),
description,
status,
coverPaintingId: stops[0]?.paintingId ?? null,
});
await loadList();
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setSaving(false);
}
};
const saveStops = async () => {
if (!selectedId) return;
setSaving(true);
setError(null);
try {
await api.saveTourStops(
selectedId,
stops.map((s) => ({ paintingId: s.paintingId, body: s.body })),
);
await api.updateTour(selectedId, {
coverPaintingId: stops[0]?.paintingId ?? null,
});
await loadList();
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setSaving(false);
}
};
const deleteTour = async () => {
if (!selectedId) return;
if (!window.confirm(t('confirmDelete'))) return;
setSaving(true);
try {
await api.deleteTour(selectedId);
setSelectedId(null);
setStops([]);
await loadList();
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setSaving(false);
}
};
const addStop = (hit: {
id: number;
title: string;
artistName: string;
year: number | null;
thumbnailPath: string | null;
}) => {
if (stops.some((s) => s.paintingId === hit.id)) return;
setStops((prev) => [
...prev,
{
paintingId: hit.id,
title: hit.title,
artistName: hit.artistName,
year: hit.year,
thumbnailPath: hit.thumbnailPath,
body: '',
},
]);
setSearchQ('');
setSearchHits([]);
};
const moveStop = (index: number, dir: -1 | 1) => {
const next = index + dir;
if (next < 0 || next >= stops.length) return;
setStops((prev) => {
const copy = [...prev];
const tmp = copy[index];
copy[index] = copy[next];
copy[next] = tmp;
return copy;
});
};
return (
<div className="tours-page">
<header className="tours-header">
<button type="button" className="tours-back" onClick={onBack}>
{t('back')}
</button>
<h1>{t('title')}</h1>
</header>
{error && <div className="tours-error">{error}</div>}
<div className="tours-layout">
<aside className="tours-list-panel">
<div className="tours-create">
<input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder={t('newTourPlaceholder')}
/>
<button type="button" disabled={saving || !newTitle.trim()} onClick={() => void createTour()}>
{t('create')}
</button>
</div>
{loading ? (
<p className="muted">{t('loading')}</p>
) : (
<ul className="tours-list">
{tours.map((tour) => (
<li key={tour.id}>
<button
type="button"
className={selectedId === tour.id ? 'active' : ''}
onClick={() => void openTour(tour.id)}
>
<strong>{tour.title}</strong>
<span className="muted">
{tour.status} · {t('stopCount', { count: tour.stopCount })}
</span>
</button>
</li>
))}
{tours.length === 0 && <li className="muted">{t('noTours')}</li>}
</ul>
)}
</aside>
<section className="tours-editor">
{!selectedId ? (
<p className="muted">{t('selectTour')}</p>
) : (
<>
<div className="tours-meta">
<label>
{t('tourTitle')}
<input value={title} onChange={(e) => setTitle(e.target.value)} />
</label>
<label>
{t('tourDescription')}
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
</label>
<label>
{t('status')}
<select
value={status}
onChange={(e) => setStatus(e.target.value as 'draft' | 'published')}
>
<option value="draft">{t('draft')}</option>
<option value="published">{t('published')}</option>
</select>
</label>
<div className="tours-meta-actions">
<button type="button" disabled={saving} onClick={() => void saveMeta()}>
{t('saveMeta')}
</button>
<button type="button" className="danger" disabled={saving} onClick={() => void deleteTour()}>
{t('delete')}
</button>
</div>
</div>
<div className="tours-stops-toolbar">
<input
type="search"
value={searchQ}
onChange={(e) => setSearchQ(e.target.value)}
placeholder={t('searchPainting')}
/>
<button type="button" disabled={saving} onClick={() => void saveStops()}>
{t('saveStops')}
</button>
</div>
{searchHits.length > 0 && (
<ul className="tours-hits">
{searchHits.map((h) => (
<li key={h.id}>
<button type="button" onClick={() => addStop(h)}>
{h.artistName} {h.title}
</button>
</li>
))}
</ul>
)}
<ol className="tours-stops">
{stops.map((stop, index) => (
<li key={stop.paintingId}>
<div className="tours-stop-head">
<span>
{index + 1}. {stop.artistName} {stop.title}
{stop.year != null ? ` (${stop.year})` : ''}
</span>
<div className="tours-stop-move">
<button type="button" onClick={() => moveStop(index, -1)} disabled={index === 0}>
</button>
<button
type="button"
onClick={() => moveStop(index, 1)}
disabled={index === stops.length - 1}
>
</button>
<button
type="button"
className="danger"
onClick={() => setStops((prev) => prev.filter((_, i) => i !== index))}
>
{t('remove')}
</button>
</div>
</div>
<textarea
value={stop.body}
onChange={(e) =>
setStops((prev) =>
prev.map((s, i) => (i === index ? { ...s, body: e.target.value } : s)),
)
}
rows={4}
placeholder={t('stopBodyPlaceholder')}
/>
</li>
))}
{stops.length === 0 && <li className="muted">{t('noStops')}</li>}
</ol>
</>
)}
</section>
</div>
</div>
);
}
+19
View File
@@ -133,6 +133,25 @@ export interface MovementGalleryDetail {
paintings: Painting[]; paintings: Painting[];
} }
export interface TourSummary {
id: number;
title: string;
description: string;
status: 'draft' | 'published';
coverPaintingId: number | null;
coverThumbnailPath: string | null;
coverImagePath: string | null;
stopCount: number;
createdAt?: string;
updatedAt?: string;
}
export interface TourGalleryDetail {
tour: TourSummary;
paintings: Painting[];
stopBodies: Record<number, string>;
}
export interface TimelineData { export interface TimelineData {
eras: HistoricalEra[]; eras: HistoricalEra[];
movements: ArtMovement[]; movements: ArtMovement[];
+23 -18
View File
@@ -1,6 +1,5 @@
import type { Painting } from '../types'; import type { Painting } from '../types';
import type { GalleryWindowSpec, MovementInteriorStyle } from '../data/movement-interior-styles'; import type { GalleryWindowSpec, MovementInteriorStyle } from '../data/movement-interior-styles';
import { comparePaintingsChronological } from './paintingUtils';
/** Target capacity per movement wing (5060 works). */ /** Target capacity per movement wing (5060 works). */
export const MOVEMENT_PAINTINGS_PER_HALL = 55; export const MOVEMENT_PAINTINGS_PER_HALL = 55;
@@ -92,10 +91,6 @@ function layoutRow(paintings: Painting[], span: number) {
return { slots, spanNeeded: Math.max(span, rowWidth + WALL_PADDING) }; return { slots, spanNeeded: Math.max(span, rowWidth + WALL_PADDING) };
} }
function orderForWall(paintings: Painting[]) {
return [...paintings].sort(comparePaintingsChronological).reverse();
}
function formatYearLabel(paintings: Painting[]) { function formatYearLabel(paintings: Painting[]) {
const years = paintings.map((p) => p.year).filter((y): y is number => y != null); const years = paintings.map((p) => p.year).filter((y): y is number => y != null);
if (years.length === 0) return 'Undated works'; if (years.length === 0) return 'Undated works';
@@ -104,22 +99,27 @@ function formatYearLabel(paintings: Painting[]) {
return min === max ? `${min}` : `${min} ${max}`; return min === max ? `${min}` : `${min} ${max}`;
} }
/** Preserve caller order (chrono for movements, stop order for tours). */
export function splitPaintingsIntoMovementHalls(paintings: Painting[]): Painting[][] { export function splitPaintingsIntoMovementHalls(paintings: Painting[]): Painting[][] {
const sorted = [...paintings].sort(comparePaintingsChronological); if (paintings.length === 0) return [[]];
if (sorted.length === 0) return [[]];
const chunks: Painting[][] = []; const chunks: Painting[][] = [];
for (let i = 0; i < sorted.length; i += MOVEMENT_PAINTINGS_PER_HALL) { for (let i = 0; i < paintings.length; i += MOVEMENT_PAINTINGS_PER_HALL) {
chunks.push(sorted.slice(i, i + MOVEMENT_PAINTINGS_PER_HALL)); chunks.push(paintings.slice(i, i + MOVEMENT_PAINTINGS_PER_HALL));
} }
return chunks; return chunks;
} }
/**
* First half left wall, second half right.
* Callers pass paintings already in visit order; first work hangs near the
* entrance on the left, last work near the entrance on the right.
*/
function distributeToSideWalls(paintings: Painting[]) { function distributeToSideWalls(paintings: Painting[]) {
const left: Painting[] = []; const mid = Math.ceil(paintings.length / 2);
const right: Painting[] = []; return {
const sorted = [...paintings].sort(comparePaintingsChronological); left: paintings.slice(0, mid),
sorted.forEach((p, i) => (i % 2 === 0 ? left : right).push(p)); right: paintings.slice(mid),
return { left: orderForWall(left), right: orderForWall(right) }; };
} }
function layoutSideSlots( function layoutSideSlots(
@@ -132,16 +132,21 @@ function layoutSideSlots(
if (paintings.length === 0) return []; if (paintings.length === 0) return [];
const { slots: rowSlots } = layoutRow(paintings, span); const { slots: rowSlots } = layoutRow(paintings, span);
const y = EYE_HEIGHT; const y = EYE_HEIGHT;
return rowSlots.map((s) => ({ return rowSlots.map((s) => {
// layoutRow places index 0 at negative offset. Flip on the left wall so
// the first painting sits at the entrance (+Z), left of the starting view.
const alongWall = side === 'left' ? -s.offset : s.offset;
return {
maxW: s.maxW, maxW: s.maxW,
maxH: s.maxH, maxH: s.maxH,
rotationY: side === 'left' ? Math.PI / 2 : -Math.PI / 2, rotationY: side === 'left' ? Math.PI / 2 : -Math.PI / 2,
side, side,
position: position:
side === 'left' side === 'left'
? ([-halfW + inset + WALL_STANDOFF, y, s.offset] as [number, number, number]) ? ([-halfW + inset + WALL_STANDOFF, y, alongWall] as [number, number, number])
: ([halfW - inset - WALL_STANDOFF, y, s.offset] as [number, number, number]), : ([halfW - inset - WALL_STANDOFF, y, alongWall] as [number, number, number]),
})); };
});
} }
export function buildMovementHallLayout( export function buildMovementHallLayout(
+40
View File
@@ -0,0 +1,40 @@
-- Guided tours: curated ordered stops with per-painting text.
CREATE TABLE IF NOT EXISTS tours (
id SERIAL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
description TEXT NOT NULL DEFAULT '',
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'published')),
cover_painting_id INTEGER REFERENCES paintings(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS tours_status_idx ON tours (status);
CREATE INDEX IF NOT EXISTS tours_updated_at_idx ON tours (updated_at DESC);
CREATE TABLE IF NOT EXISTS tour_stops (
id SERIAL PRIMARY KEY,
tour_id INTEGER NOT NULL REFERENCES tours(id) ON DELETE CASCADE,
painting_id INTEGER NOT NULL REFERENCES paintings(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
body TEXT NOT NULL DEFAULT '',
UNIQUE (tour_id, painting_id)
);
CREATE INDEX IF NOT EXISTS tour_stops_tour_idx ON tour_stops (tour_id, sort_order);
CREATE INDEX IF NOT EXISTS tour_stops_painting_idx ON tour_stops (painting_id);
CREATE OR REPLACE FUNCTION tours_touch_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS tours_updated_at ON tours;
CREATE TRIGGER tours_updated_at
BEFORE UPDATE ON tours
FOR EACH ROW EXECUTE PROCEDURE tours_touch_updated_at();
+4
View File
@@ -30,6 +30,8 @@ const CATALOG_TABLES = [
'painting_influence_sources', 'painting_influence_sources',
'painting_annotations', 'painting_annotations',
'entity_translations', 'entity_translations',
'tours',
'tour_stops',
]; ];
const NATURAL_KEY_FN = { const NATURAL_KEY_FN = {
@@ -42,6 +44,8 @@ const NATURAL_KEY_FN = {
painting_influence_sources: (r) => `${r.painting_id}:${r.source_type}:${r.source_painting_id || 0}:${r.source_artist_id || 0}:${r.source_movement_id || 0}`, painting_influence_sources: (r) => `${r.painting_id}:${r.source_type}:${r.source_painting_id || 0}:${r.source_artist_id || 0}:${r.source_movement_id || 0}`,
painting_annotations: (r) => `${r.painting_id}:${String(r.label || '').trim().toLowerCase()}:${r.sort_order}`, painting_annotations: (r) => `${r.painting_id}:${String(r.label || '').trim().toLowerCase()}:${r.sort_order}`,
entity_translations: (r) => `${r.entity_type}:${r.entity_id}:${r.locale}:${r.field_name}`, entity_translations: (r) => `${r.entity_type}:${r.entity_id}:${r.locale}:${r.field_name}`,
tours: (r) => `tour:${r.id}`,
tour_stops: (r) => `${r.tour_id}:${r.painting_id}`,
}; };
function parseArgs(argv) { function parseArgs(argv) {
+2
View File
@@ -27,6 +27,7 @@ const {
} = require('./translation-service'); } = require('./translation-service');
const translationRoutes = require('./routes/translations'); const translationRoutes = require('./routes/translations');
const influenceRoutes = require('./routes/influences'); const influenceRoutes = require('./routes/influences');
const tourRoutes = require('./routes/tours');
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();
@@ -45,6 +46,7 @@ app.use(createSessionMiddleware());
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
app.use('/api/translations', translationRoutes); app.use('/api/translations', translationRoutes);
app.use('/api/influences', influenceRoutes); app.use('/api/influences', influenceRoutes);
app.use('/api/tours', tourRoutes);
app.use( app.use(
'/images', '/images',
express.static(IMAGE_DIR, { express.static(IMAGE_DIR, {
+1
View File
@@ -15,6 +15,7 @@ const INCREMENTAL_MIGRATIONS = [
'migrate-search.sql', 'migrate-search.sql',
'migrate-sync-timestamps.sql', 'migrate-sync-timestamps.sql',
'migrate-i18n.sql', 'migrate-i18n.sql',
'migrate-tours.sql',
]; ];
async function bootstrapCurator() { async function bootstrapCurator() {
+352
View File
@@ -0,0 +1,352 @@
const express = require('express');
const pool = require('../db');
const { requireCurator } = require('../middleware/auth');
const { logCuratorAction } = require('../audit-log');
const { enrichPaintingRow } = require('../image-service');
const { localizePaintings, resolveLocale, translationStatuses } = require('../translation-service');
const router = express.Router();
const INFLUENCE_LINKS_EXISTS = `EXISTS (
SELECT 1 FROM painting_influence_sources pis WHERE pis.painting_id = p.id
)`;
function parseId(value) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
}
function localeContext(req) {
return {
locale: resolveLocale(req),
statuses: translationStatuses(req),
};
}
async function loadTourStops(tourId, req) {
const { rows } = await pool.query(
`SELECT ts.id AS stop_id, ts.sort_order, ts.body,
p.*,
a.name AS artist_name,
a.id AS artist_id,
p.checkup_checked,
p.checkup_fixed,
(${INFLUENCE_LINKS_EXISTS}) AS has_influence_links
FROM tour_stops ts
JOIN paintings p ON p.id = ts.painting_id
JOIN artists a ON a.id = p.artist_id
WHERE ts.tour_id = $1
ORDER BY ts.sort_order ASC, ts.id ASC`,
[tourId],
);
const { locale, statuses } = localeContext(req);
const localized = await localizePaintings(rows, locale, statuses);
const paintings = localized.map((row) => {
const enriched = enrichPaintingRow(row);
return enriched;
});
const stopBodies = {};
for (const row of rows) {
stopBodies[row.id] = row.body || '';
}
return { paintings, stopBodies, stopCount: rows.length };
}
function mapTourSummary(row) {
return {
id: row.id,
title: row.title,
description: row.description || '',
status: row.status,
coverPaintingId: row.cover_painting_id,
coverThumbnailPath: row.cover_thumbnail_path || null,
coverImagePath: row.cover_image_path || null,
stopCount: Number(row.stop_count) || 0,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
router.get('/', async (req, res) => {
try {
const { rows } = await pool.query(
`SELECT t.*,
cp.thumbnail_path AS cover_thumbnail_path,
cp.image_path AS cover_image_path,
(SELECT COUNT(*)::int FROM tour_stops s WHERE s.tour_id = t.id) AS stop_count
FROM tours t
LEFT JOIN paintings cp ON cp.id = t.cover_painting_id
WHERE t.status = 'published'
ORDER BY t.updated_at DESC, t.title ASC`,
);
res.json({ tours: rows.map(mapTourSummary) });
} catch (err) {
console.error('Tours list error:', err.message);
res.status(500).json({ error: 'Failed to list tours' });
}
});
router.get('/admin', requireCurator, async (_req, res) => {
try {
const { rows } = await pool.query(
`SELECT t.*,
cp.thumbnail_path AS cover_thumbnail_path,
cp.image_path AS cover_image_path,
(SELECT COUNT(*)::int FROM tour_stops s WHERE s.tour_id = t.id) AS stop_count
FROM tours t
LEFT JOIN paintings cp ON cp.id = t.cover_painting_id
ORDER BY t.updated_at DESC, t.title ASC`,
);
res.json({ tours: rows.map(mapTourSummary) });
} catch (err) {
console.error('Tours admin list error:', err.message);
res.status(500).json({ error: 'Failed to list tours' });
}
});
router.get('/:id', async (req, res) => {
try {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid id' });
const { rows } = await pool.query(
`SELECT t.*,
cp.thumbnail_path AS cover_thumbnail_path,
cp.image_path AS cover_image_path,
(SELECT COUNT(*)::int FROM tour_stops s WHERE s.tour_id = t.id) AS stop_count
FROM tours t
LEFT JOIN paintings cp ON cp.id = t.cover_painting_id
WHERE t.id = $1`,
[id],
);
if (!rows[0]) return res.status(404).json({ error: 'Tour not found' });
const tour = rows[0];
let isCurator = false;
if (req.session?.userId) {
const { rows: users } = await pool.query(
'SELECT id FROM users WHERE id = $1',
[req.session.userId],
);
isCurator = users.length > 0;
}
if (tour.status !== 'published' && !isCurator) {
return res.status(404).json({ error: 'Tour not found' });
}
const { paintings, stopBodies } = await loadTourStops(id, req);
const { locale } = localeContext(req);
res.json({
locale,
tour: mapTourSummary(tour),
paintings,
stopBodies,
});
} catch (err) {
console.error('Tour detail error:', err.message);
res.status(500).json({ error: 'Failed to load tour' });
}
});
router.post('/', requireCurator, async (req, res) => {
try {
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
if (!title) return res.status(400).json({ error: 'title required' });
const description = typeof req.body?.description === 'string' ? req.body.description : '';
const status = req.body?.status === 'published' ? 'published' : 'draft';
const { rows } = await pool.query(
`INSERT INTO tours (title, description, status)
VALUES ($1, $2, $3)
RETURNING *`,
[title, description, status],
);
await logCuratorAction({
userId: req.curatorUser.id,
action: 'tour.create',
resourceType: 'tour',
resourceId: rows[0].id,
details: { title, status },
req,
});
res.status(201).json({ tour: mapTourSummary({ ...rows[0], stop_count: 0 }) });
} catch (err) {
console.error('Tour create error:', err.message);
res.status(500).json({ error: 'Failed to create tour' });
}
});
router.patch('/:id', requireCurator, async (req, res) => {
try {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid id' });
const { rows: existing } = await pool.query('SELECT * FROM tours WHERE id = $1', [id]);
if (!existing[0]) return res.status(404).json({ error: 'Tour not found' });
const title =
typeof req.body?.title === 'string' ? req.body.title.trim() : existing[0].title;
if (!title) return res.status(400).json({ error: 'title required' });
const description =
typeof req.body?.description === 'string' ? req.body.description : existing[0].description;
let status = existing[0].status;
if (req.body?.status === 'published' || req.body?.status === 'draft') {
status = req.body.status;
}
let coverPaintingId = existing[0].cover_painting_id;
if (req.body?.coverPaintingId === null) coverPaintingId = null;
else if (req.body?.coverPaintingId != null) {
const cid = parseId(req.body.coverPaintingId);
if (!cid) return res.status(400).json({ error: 'Invalid coverPaintingId' });
coverPaintingId = cid;
}
const { rows } = await pool.query(
`UPDATE tours
SET title = $2, description = $3, status = $4, cover_painting_id = $5
WHERE id = $1
RETURNING *`,
[id, title, description, status, coverPaintingId],
);
await logCuratorAction({
userId: req.curatorUser.id,
action: 'tour.update',
resourceType: 'tour',
resourceId: id,
details: { title, status, coverPaintingId },
req,
});
const { rows: countRows } = await pool.query(
'SELECT COUNT(*)::int AS n FROM tour_stops WHERE tour_id = $1',
[id],
);
res.json({ tour: mapTourSummary({ ...rows[0], stop_count: countRows[0].n }) });
} catch (err) {
console.error('Tour update error:', err.message);
res.status(500).json({ error: 'Failed to update tour' });
}
});
router.delete('/:id', requireCurator, async (req, res) => {
try {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid id' });
const { rows } = await pool.query('DELETE FROM tours WHERE id = $1 RETURNING id, title', [id]);
if (!rows[0]) return res.status(404).json({ error: 'Tour not found' });
await logCuratorAction({
userId: req.curatorUser.id,
action: 'tour.delete',
resourceType: 'tour',
resourceId: id,
details: { title: rows[0].title },
req,
});
res.json({ ok: true });
} catch (err) {
console.error('Tour delete error:', err.message);
res.status(500).json({ error: 'Failed to delete tour' });
}
});
router.put('/:id/stops', requireCurator, async (req, res) => {
const client = await pool.connect();
try {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid id' });
const { rows: tours } = await client.query('SELECT id FROM tours WHERE id = $1', [id]);
if (!tours[0]) return res.status(404).json({ error: 'Tour not found' });
const stops = Array.isArray(req.body?.stops) ? req.body.stops : null;
if (!stops) return res.status(400).json({ error: 'stops array required' });
if (stops.length > 200) return res.status(400).json({ error: 'Too many stops (max 200)' });
const normalized = [];
const seen = new Set();
for (let i = 0; i < stops.length; i += 1) {
const paintingId = parseId(stops[i]?.paintingId ?? stops[i]?.painting_id);
if (!paintingId) {
return res.status(400).json({ error: `Invalid paintingId at index ${i}` });
}
if (seen.has(paintingId)) continue;
seen.add(paintingId);
normalized.push({
paintingId,
body: typeof stops[i]?.body === 'string' ? stops[i].body : '',
sortOrder: i,
});
}
if (normalized.length) {
const ids = normalized.map((s) => s.paintingId);
const { rows: found } = await client.query(
'SELECT id FROM paintings WHERE id = ANY($1::int[])',
[ids],
);
if (found.length !== ids.length) {
return res.status(400).json({ error: 'One or more paintings not found' });
}
}
await client.query('BEGIN');
await client.query('DELETE FROM tour_stops WHERE tour_id = $1', [id]);
for (const stop of normalized) {
await client.query(
`INSERT INTO tour_stops (tour_id, painting_id, sort_order, body)
VALUES ($1, $2, $3, $4)`,
[id, stop.paintingId, stop.sortOrder, stop.body],
);
}
// Auto-set cover from first stop when cover empty
const { rows: tourRows } = await client.query(
'SELECT cover_painting_id FROM tours WHERE id = $1',
[id],
);
if (!tourRows[0].cover_painting_id && normalized[0]) {
await client.query('UPDATE tours SET cover_painting_id = $2 WHERE id = $1', [
id,
normalized[0].paintingId,
]);
}
await client.query('COMMIT');
await logCuratorAction({
userId: req.curatorUser.id,
action: 'tour.stops',
resourceType: 'tour',
resourceId: id,
details: { stopCount: normalized.length },
req,
});
const detail = await loadTourStops(id, req);
res.json({
ok: true,
stopCount: detail.stopCount,
paintings: detail.paintings,
stopBodies: detail.stopBodies,
});
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
console.error('Tour stops error:', err.message);
res.status(500).json({ error: 'Failed to save tour stops' });
} finally {
client.release();
}
});
module.exports = router;