From 5ddc3fd7f0eaee23215b74c6ce898aa967b18e13 Mon Sep 17 00:00:00 2001
From: Danila Khodjaef
Date: Thu, 16 Jul 2026 20:27:36 +0300
Subject: [PATCH] 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
---
Documentation/API.md | 26 ++
Documentation/DB_structure.md | 27 ++
Documentation/FAC.md | 1 +
Documentation/Plans.md | 2 +-
Documentation/basics.md | 43 ++-
Documentation/data-and-images.md | 2 +-
Documentation/setup.md | 2 +-
Documentation/tours.md | 47 +++
client/src/api/client.ts | 81 +++++
client/src/components/PaintingDetail.css | 34 +++
client/src/components/PaintingDetail.tsx | 19 +-
client/src/components/ToursPopup.css | 144 +++++++++
client/src/components/ToursPopup.tsx | 98 ++++++
client/src/components/VirtualGallery.tsx | 144 +++++----
client/src/i18n/index.ts | 6 +-
client/src/locales/en/home.json | 5 +
client/src/locales/en/painting.json | 6 +-
client/src/locales/en/tours.json | 28 ++
client/src/locales/ru/home.json | 5 +
client/src/locales/ru/painting.json | 6 +-
client/src/locales/ru/tours.json | 28 ++
client/src/pages/HomePage.tsx | 246 +++++++++++++--
client/src/pages/ToursPage.css | 180 +++++++++++
client/src/pages/ToursPage.tsx | 363 +++++++++++++++++++++++
client/src/types/index.ts | 19 ++
client/src/utils/movementHallLayout.ts | 53 ++--
db/migrate-tours.sql | 40 +++
scripts/harmonize-db.js | 4 +
server/index.js | 2 +
server/migrate.js | 1 +
server/routes/tours.js | 352 ++++++++++++++++++++++
31 files changed, 1890 insertions(+), 124 deletions(-)
create mode 100644 Documentation/tours.md
create mode 100644 client/src/components/ToursPopup.css
create mode 100644 client/src/components/ToursPopup.tsx
create mode 100644 client/src/locales/en/tours.json
create mode 100644 client/src/locales/ru/tours.json
create mode 100644 client/src/pages/ToursPage.css
create mode 100644 client/src/pages/ToursPage.tsx
create mode 100644 db/migrate-tours.sql
create mode 100644 server/routes/tours.js
diff --git a/Documentation/API.md b/Documentation/API.md
index 1da70e4..3525435 100644
--- a/Documentation/API.md
+++ b/Documentation/API.md
@@ -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`
Public catalog search over **artists**, **paintings**, and **art movements**. Used by the timeline header search bar (`CatalogSearchBar.tsx`).
diff --git a/Documentation/DB_structure.md b/Documentation/DB_structure.md
index b679691..b179511 100644
--- a/Documentation/DB_structure.md
+++ b/Documentation/DB_structure.md
@@ -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 work’s `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`
Directed edges: *this painting* was influenced by *that painting*.
diff --git a/Documentation/FAC.md b/Documentation/FAC.md
index 1691a67..daab03b 100644
--- a/Documentation/FAC.md
+++ b/Documentation/FAC.md
@@ -359,4 +359,5 @@ Remote: https://gitea.mysuperlab.netcraze.pro/Danilka/Art-gallery
| [setup.md](setup.md) | Install, env vars, troubleshooting |
| [data-and-images.md](data-and-images.md) | Catalog and image pipeline |
| [API.md](API.md) | REST endpoints |
+| [tours.md](tours.md) | Guided tours |
| [DB_structure.md](DB_structure.md) | PostgreSQL schema |
diff --git a/Documentation/Plans.md b/Documentation/Plans.md
index 51bfac8..0e447ac 100644
--- a/Documentation/Plans.md
+++ b/Documentation/Plans.md
@@ -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)
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`
-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)
diff --git a/Documentation/basics.md b/Documentation/basics.md
index 7545671..3e1ba4f 100644
--- a/Documentation/basics.md
+++ b/Documentation/basics.md
@@ -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.
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.
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/TranslationsPage.tsx # Russian translation review
│ │ ├── 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
│ │ └── locales/{en,ru}/ # UI chrome strings
│ │ ├── 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)
-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
@@ -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 artist’s 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 |
-| 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 |
-| 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 | On each wall, left → right: **later works on the left**, **earlier works on the right**; undated works sort toward the left |
+| 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) |
+| 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 |
| Floor | Herringbone **parquet** procedural texture (`parquetFloorTexture.ts`) |
| Wall tint | Gallery walls blend the artist’s **movement colour** into cream plaster tones |
| 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 |
| 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 |
-| 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 |
| 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 |
@@ -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.
+### 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
**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
-Opened from the 3D hall (artist or movement wing — click a frame) or from influence thumbnails on another work’s detail page.
+Opened from the 3D hall (artist, movement, or tour wing — click a frame) or from influence thumbnails on another work’s detail page.
| 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`) |
+| **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) |
| **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 |
|-------|--------|
-| `‹` / `›` 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) |
| Click centre image | Open fullscreen lightbox |
| Click influence thumbnail | Open that work’s 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:**
-- **Catalog browsing** (‹ › / arrow keys) walks the current artist’s 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).
- 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.
@@ -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.
- **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.
+- **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.
- **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.
@@ -416,7 +436,7 @@ Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens
| Role | Who | Can do |
|------|-----|--------|
| **`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).
@@ -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 |
| **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) |
+| **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 |
| **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 |
| [API.md](API.md) | REST endpoints |
| [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 |
| [data-and-images.md](data-and-images.md) | Image pipeline and seeding |
diff --git a/Documentation/data-and-images.md b/Documentation/data-and-images.md
index ca0d97f..e31cffc 100644
--- a/Documentation/data-and-images.md
+++ b/Documentation/data-and-images.md
@@ -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`.
-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 movement’s 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 movement’s look, edit its entry in `movement-interior-styles.ts` and rebuild the client.
## Historical event markers (frontend timeline)
diff --git a/Documentation/setup.md b/Documentation/setup.md
index e4fe44a..6ce04b1 100644
--- a/Documentation/setup.md
+++ b/Documentation/setup.md
@@ -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.
-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).
diff --git a/Documentation/tours.md b/Documentation/tours.md
new file mode 100644
index 0000000..c3cb0a0
--- /dev/null
+++ b/Documentation/tours.md
@@ -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
diff --git a/client/src/api/client.ts b/client/src/api/client.ts
index 9121ba6..cf3ad86 100644
--- a/client/src/api/client.ts
+++ b/client/src/api/client.ts
@@ -5,9 +5,12 @@ import type {
Artist,
ArtistDetail,
MovementGalleryDetail,
+ Painting,
PaintingDetail,
ArtistNavigation,
CatalogSearchResponse,
+ TourSummary,
+ TourGalleryDetail,
} from '../types';
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 }>;
}),
+ listPublishedTours: () =>
+ fetchJson<{ tours: TourSummary[] }>(`${API}/tours`),
+
+ listAdminTours: () =>
+ fetchJson<{ tours: TourSummary[] }>(`${API}/tours/admin`),
+
+ getTour: (id: number) =>
+ fetchJson(`${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;
+ }>;
+ }),
+
preloadArtistImages,
};
@@ -750,6 +829,8 @@ export interface InfluencePriorImport {
match: 'file' | 'data' | 'unknown';
}
+export type { TourSummary, TourGalleryDetail };
+
export interface InfluenceImportParseResult {
filename: string;
format: string;
diff --git a/client/src/components/PaintingDetail.css b/client/src/components/PaintingDetail.css
index 036f37c..cc17500 100644
--- a/client/src/components/PaintingDetail.css
+++ b/client/src/components/PaintingDetail.css
@@ -407,6 +407,40 @@
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 {
max-width: 700px;
margin-top: 20px;
diff --git a/client/src/components/PaintingDetail.tsx b/client/src/components/PaintingDetail.tsx
index 018ccb4..5c2f964 100644
--- a/client/src/components/PaintingDetail.tsx
+++ b/client/src/components/PaintingDetail.tsx
@@ -14,6 +14,8 @@ interface Props {
data: PaintingDetail;
artistPaintings?: Painting[];
backLabel?: string;
+ tourTitle?: string | null;
+ tourText?: string | null;
onBack: () => void;
onPaintingClick: (paintingId: number) => void;
onCatalogNavigate: (paintingId: number) => void;
@@ -194,6 +196,8 @@ export default function PaintingDetailView({
data,
artistPaintings = [],
backLabel = '← Back to Gallery',
+ tourTitle = null,
+ tourText = null,
onBack,
onPaintingClick,
onCatalogNavigate,
@@ -207,6 +211,7 @@ export default function PaintingDetailView({
}: Props) {
const { t } = useTranslation('painting');
const { painting, influencedBy, influenced, annotations = [] } = data;
+ const inTour = tourText != null;
const [fullscreen, setFullscreen] = useState(false);
const [imageVersion, setImageVersion] = useState(0);
const [debugSearch, setDebugSearch] = useState(null);
@@ -470,7 +475,9 @@ export default function PaintingDetailView({
{showCatalogNav && (
{' · '}
- {catalogIndex + 1} of {artistPaintings.length}
+ {inTour
+ ? t('tourStopPosition', { current: catalogIndex + 1, total: artistPaintings.length })
+ : `${catalogIndex + 1} of ${artistPaintings.length}`}
)}