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:
co-authored by
Cursor
parent
48bd17e985
commit
5ddc3fd7f0
@@ -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`).
|
||||||
|
|||||||
@@ -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`).
|
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`
|
### `painting_influences`
|
||||||
|
|
||||||
Directed edges: *this painting* was influenced by *that painting*.
|
Directed edges: *this painting* was influenced by *that painting*.
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
@@ -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 artist’s paintings |
|
| 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 |
|
| 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 artist’s **movement colour** into cream plaster tones |
|
| 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** |
|
| 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 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 |
|
| 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 work’s detail (different artist allowed) |
|
| 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:**
|
**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).
|
- **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 |
|
||||||
|
|||||||
@@ -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 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)
|
## Historical event markers (frontend timeline)
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 & right walls — up to ~55 per wing</li>
|
<li>Works hang on left & right walls — up to ~55 per wing</li>
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -15,6 +15,11 @@
|
|||||||
"checkup": "Проверка",
|
"checkup": "Проверка",
|
||||||
"translations": "Переводы",
|
"translations": "Переводы",
|
||||||
"influences": "Влияния",
|
"influences": "Влияния",
|
||||||
|
"tours": "Экскурсии",
|
||||||
|
"toursEditor": "Редактор экскурсий",
|
||||||
|
"openingTourGallery": "Открытие экскурсии…",
|
||||||
|
"tourEmpty": "В этой экскурсии пока нет картин.",
|
||||||
|
"tourLoadFailed": "Не удалось загрузить экскурсию.",
|
||||||
"curatorRequiredTitle": "Требуется доступ куратора",
|
"curatorRequiredTitle": "Требуется доступ куратора",
|
||||||
"curatorRequiredBody": "Войдите как куратор, чтобы использовать этот инструмент.",
|
"curatorRequiredBody": "Войдите как куратор, чтобы использовать этот инструмент.",
|
||||||
"backToGalleryBtn": "Вернуться в галерею"
|
"backToGalleryBtn": "Вернуться в галерею"
|
||||||
|
|||||||
@@ -7,5 +7,9 @@
|
|||||||
"catalogPosition": "Позиция в каталоге",
|
"catalogPosition": "Позиция в каталоге",
|
||||||
"lightboxHint": "Нажмите в любом месте, чтобы закрыть",
|
"lightboxHint": "Нажмите в любом месте, чтобы закрыть",
|
||||||
"prevPainting": "Предыдущая картина",
|
"prevPainting": "Предыдущая картина",
|
||||||
"nextPainting": "Следующая картина"
|
"nextPainting": "Следующая картина",
|
||||||
|
"tourNotes": "Текст экскурсии",
|
||||||
|
"tourNotesFor": "Экскурсия · {{title}}",
|
||||||
|
"tourNotesEmpty": "Для этой остановки нет текста.",
|
||||||
|
"tourStopPosition": "Остановка {{current}} из {{total}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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 (50–60 works). */
|
/** Target capacity per movement wing (50–60 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(
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user