From 5ddc3fd7f0eaee23215b74c6ce898aa967b18e13 Mon Sep 17 00:00:00 2001 From: Danila Khodjaef Date: Thu, 16 Jul 2026 20:27:36 +0300 Subject: [PATCH] Add guided tours and unify left-to-right hall wall hang. Visitors walk published tours in a 3D hall with stop notes; curators edit drafts via Tour editor. All galleries (artist, movement, tour) place the first work left of the entrance view and the last on the right. Co-authored-by: Cursor --- Documentation/API.md | 26 ++ Documentation/DB_structure.md | 27 ++ Documentation/FAC.md | 1 + Documentation/Plans.md | 2 +- Documentation/basics.md | 43 ++- Documentation/data-and-images.md | 2 +- Documentation/setup.md | 2 +- Documentation/tours.md | 47 +++ client/src/api/client.ts | 81 +++++ client/src/components/PaintingDetail.css | 34 +++ client/src/components/PaintingDetail.tsx | 19 +- client/src/components/ToursPopup.css | 144 +++++++++ client/src/components/ToursPopup.tsx | 98 ++++++ client/src/components/VirtualGallery.tsx | 144 +++++---- client/src/i18n/index.ts | 6 +- client/src/locales/en/home.json | 5 + client/src/locales/en/painting.json | 6 +- client/src/locales/en/tours.json | 28 ++ client/src/locales/ru/home.json | 5 + client/src/locales/ru/painting.json | 6 +- client/src/locales/ru/tours.json | 28 ++ client/src/pages/HomePage.tsx | 246 +++++++++++++-- client/src/pages/ToursPage.css | 180 +++++++++++ client/src/pages/ToursPage.tsx | 363 +++++++++++++++++++++++ client/src/types/index.ts | 19 ++ client/src/utils/movementHallLayout.ts | 53 ++-- db/migrate-tours.sql | 40 +++ scripts/harmonize-db.js | 4 + server/index.js | 2 + server/migrate.js | 1 + server/routes/tours.js | 352 ++++++++++++++++++++++ 31 files changed, 1890 insertions(+), 124 deletions(-) create mode 100644 Documentation/tours.md create mode 100644 client/src/components/ToursPopup.css create mode 100644 client/src/components/ToursPopup.tsx create mode 100644 client/src/locales/en/tours.json create mode 100644 client/src/locales/ru/tours.json create mode 100644 client/src/pages/ToursPage.css create mode 100644 client/src/pages/ToursPage.tsx create mode 100644 db/migrate-tours.sql create mode 100644 server/routes/tours.js diff --git a/Documentation/API.md b/Documentation/API.md index 1da70e4..3525435 100644 --- a/Documentation/API.md +++ b/Documentation/API.md @@ -214,6 +214,32 @@ Public painting detail still exposes read-only `influencedBy` / `influenced` (un --- +## Tours + +Base path: `/api/tours`. Full guide: [tours.md](tours.md). + +| Method | Path | Auth | Purpose | +|--------|------|------|---------| +| `GET` | `/api/tours` | public | Published tour summaries (`id`, `title`, `description`, cover thumb, `stopCount`) | +| `GET` | `/api/tours/:id` | public* | Full tour + ordered `paintings` + `stopBodies` (*draft tours require curator session) | +| `GET` | `/api/tours/admin` | curator | All tours (any status) | +| `POST` | `/api/tours` | curator | Create `{ title, description?, status? }` | +| `PATCH` | `/api/tours/:id` | curator | Update title / description / status / cover | +| `DELETE` | `/api/tours/:id` | curator | Delete tour + stops | +| `PUT` | `/api/tours/:id/stops` | curator | Replace ordered stops `{ stops: [{ paintingId, body }] }` | + +Detail response shape: + +```json +{ + "tour": { "id": 1, "title": "…", "status": "published", "stopCount": 5 }, + "paintings": [ /* Painting rows in stop order */ ], + "stopBodies": { "42": "Tour text for this stop…" } +} +``` + +--- + ## `GET /api/search` Public catalog search over **artists**, **paintings**, and **art movements**. Used by the timeline header search bar (`CatalogSearchBar.tsx`). diff --git a/Documentation/DB_structure.md b/Documentation/DB_structure.md index b679691..b179511 100644 --- a/Documentation/DB_structure.md +++ b/Documentation/DB_structure.md @@ -136,6 +136,33 @@ Short art-history notes shown on painting detail (`PaintingAnnotations.tsx`). Applied by `npm run dev:migrate:painting-annotations` (`db/migrate-painting-annotations.sql`). Load data with `npm run dev:update-painting-annotations` (curated entries in `scripts/painting-annotations-data.js`; add `--wikipedia` for intro sentences from each work’s `wikipedia_title`). +### `tours` / `tour_stops` + +Curated guided tours. See [tours.md](tours.md). + +**`tours`** + +| Column | Type | Notes | +|--------|------|-------| +| `id` | SERIAL PK | | +| `title` | VARCHAR(200) | | +| `description` | TEXT | Default `''` | +| `status` | VARCHAR(20) | `draft` \| `published` | +| `cover_painting_id` | FK → `paintings` | ON DELETE SET NULL | +| `created_at` / `updated_at` | TIMESTAMPTZ | `updated_at` via trigger | + +**`tour_stops`** + +| Column | Type | Notes | +|--------|------|-------| +| `id` | SERIAL PK | | +| `tour_id` | FK → `tours` | ON DELETE CASCADE | +| `painting_id` | FK → `paintings` | ON DELETE CASCADE; UNIQUE with `tour_id` | +| `sort_order` | INTEGER | Visitor / editor order | +| `body` | TEXT | English tour notes for the stop (v1) | + +Applied by `npm run dev:migrate` (`db/migrate-tours.sql`). + ### `painting_influences` Directed edges: *this painting* was influenced by *that painting*. diff --git a/Documentation/FAC.md b/Documentation/FAC.md index 1691a67..daab03b 100644 --- a/Documentation/FAC.md +++ b/Documentation/FAC.md @@ -359,4 +359,5 @@ Remote: https://gitea.mysuperlab.netcraze.pro/Danilka/Art-gallery | [setup.md](setup.md) | Install, env vars, troubleshooting | | [data-and-images.md](data-and-images.md) | Catalog and image pipeline | | [API.md](API.md) | REST endpoints | +| [tours.md](tours.md) | Guided tours | | [DB_structure.md](DB_structure.md) | PostgreSQL schema | diff --git a/Documentation/Plans.md b/Documentation/Plans.md index 51bfac8..0e447ac 100644 --- a/Documentation/Plans.md +++ b/Documentation/Plans.md @@ -6,5 +6,5 @@ this file contains draft for future releases and features 4. ~~tool to sync prod /env resources (both ways), db structure, db data, images, users etc~~ — done for catalog DB + images: `npm run harmonize` (schema dev→prod only; users/audit excluded) — [harmonize-dev-prod.md](harmonize-dev-prod.md) 5. ~~curator_audit_log should contain log of actions like fixit, checked, upload etc with details for which entity it was made and details what was the action and outcome~~ — done: table + `logCuratorAction` on fix/clear/upload/delete/checkup flags (and translation upsert/publish); see [DB_structure.md](DB_structure.md#curator_audit_log). (UI to browse logs is still item 3.) 6. ~~create search by entity (painting, artist, movement)~~ — done: timeline header + `GET /api/search` -7. create guided tours (with text/extra infor, set of entities) +7. ~~create guided tours (with text/extra infor, set of entities)~~ — done: `tours` / `tour_stops`, public Tours popup + 3D tour hall, curator Tour editor — [tours.md](tours.md) diff --git a/Documentation/basics.md b/Documentation/basics.md index 7545671..3e1ba4f 100644 --- a/Documentation/basics.md +++ b/Documentation/basics.md @@ -8,7 +8,7 @@ The app is organised as a **drill-down hierarchy**: 1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries. 2. **Movement flow** — art movements as curved SVG streams on the same year axis; documented predecessor→successor branches; portrait thumbnails placed along each stream. -3. **3D gallery** — one personal hall per artist *or* a **movement gallery** (click a movement name on the flow diagram): period-themed interiors, chronological wings of up to ~55 works, side-wall hang only. +3. **3D gallery** — one personal hall per artist, a **movement gallery** (click a movement name), or a **guided tour** hall (timeline → **Tours**): period-themed or tour wings of up to ~55 works, side-wall hang, visit order left→right. 4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), optional **art-history notes** with image markers, prev/next catalog browsing, optional fullscreen, link to artist biography. 5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`). With debug mode on, the same image-audit panel as painting detail (portrait search, **Checked** / **Fix it** / **More** / **Clear** / **Upload**). @@ -52,6 +52,8 @@ Gallery/ │ │ ├── pages/CheckupPage.tsx # Image audit table │ │ ├── pages/TranslationsPage.tsx # Russian translation review │ │ ├── pages/InfluencesPage.tsx # Influence links CRUD + import wizard +│ │ ├── pages/ToursPage.tsx # Guided tour editor +│ │ ├── components/ToursPopup.tsx # Public published-tours modal │ │ ├── i18n/ # react-i18next bootstrap │ │ └── locales/{en,ru}/ # UI chrome strings │ │ ├── data/historical-events.ts # Timeline event markers (UI) @@ -279,7 +281,9 @@ Only **mousedown** on portraits and movement labels stops propagation (so drag-t ## Virtual gallery (3D halls) -The 3D scene supports two modes in `VirtualGallery.tsx`: **artist halls** (personal catalog) and **movement galleries** (full movement collection, chronological). +The 3D scene supports three modes in `VirtualGallery.tsx`: **artist halls** (personal catalog), **movement galleries** (full movement collection, chronological), and **guided tours** (curator-ordered stops — [tours.md](tours.md)). + +**Shared wall hang (all modes):** visit order fills the **left wall first**, then the **right**. The **first** work hangs near the entrance on the left (immediately left of the opening view); the **last** hangs near the entrance on the right. Artist halls use chronological order; movement wings use chronological order within each wing; tours use stop order. ### Artist halls @@ -289,9 +293,8 @@ Each artist has **exactly one hall**. The hall is a rectangular room sized to fi |------|----------------| | One hall per artist | `VirtualGallery.tsx` builds a single room from that artist’s paintings | | Catalog depth | Most artists target **≥ 6** notable works via `npm run dev:expand-catalog` and `famous-paintings-data.js`; some masters have larger museum dumps | -| Paintings on walls | Works hang on the **back, left, and right** walls in **one row per wall**; room **depth grows** when the catalog is large | -| Corridor layout | **15+ paintings:** short back wall (up to 8 works), remaining works on extended **left/right** side walls — a long gallery corridor | -| Wall order | On each wall, left → right: **later works on the left**, **earlier works on the right**; undated works sort toward the left | +| Paintings on walls | Works hang on the **left and right** walls in **one row per wall**; room **depth grows** when the catalog is large (back wall is for the exit only) | +| Wall order | Chronological visit order: first half on the **left** (entrance → back), second half on the **right** (back → entrance); first work left of the opening view, last on the right | | Floor | Herringbone **parquet** procedural texture (`parquetFloorTexture.ts`) | | Wall tint | Gallery walls blend the artist’s **movement colour** into cream plaster tones | | Frame finish | **Unchecked** works: black moulding; **Reviewed** (`checkup_checked`): bright gold moulding at **double width** | @@ -327,7 +330,7 @@ Enter from the home page by clicking a **movement name** on the movement flow (` | One gallery per movement | All paintings by artists in that movement, sorted chronologically | | Wings | Catalog split into wings of up to **55 works** (`movementHallLayout.ts`); large movements (e.g. Baroque) use multiple wings | | Paintings on walls | **Left and right walls only** — back wall reserved for exit, front for passage to the next wing | -| Wall order | Along each side wall: **later works on the left**, **earlier on the right** (same convention as artist halls) | +| Wall order | Same shared hang as artist halls: first half left (entrance → back), second half right (back → entrance) | | Frame captions | **Year · artist** label below each frame | | Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, NYC loft, white cube, etc.) | | Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco — plus **single-colour painted walls** (`painted-lime`, `painted-oil-matte`, `painted-oil-satin`, `painted-emulsion`, `painted-flat`) tinted per movement for Renaissance salons through modern white cubes | @@ -351,6 +354,20 @@ Enter from the home page by clicking a **movement name** on the movement flow (` Movement galleries do **not** use the predecessor/successor influence picker — that remains artist-hall only. +### Guided tour halls + +Enter from the home page **Tours** popup (`GET /api/tours/:id`). Layout reuses the movement winged hall (`mode: 'tour'` in `VirtualGallery.tsx`). + +| Rule | Implementation | +|------|----------------| +| Visit order | Curator `sort_order` on `tour_stops` (not chronological) | +| Wings | Same ~55-per-wing split as movements; order preserved across wings | +| Wall hang | Same left-then-right rule as other halls | +| Detail | Tour stop text panel; ‹ › walks tour stops | +| Exit | Wing navigator / **Exit to Timeline** (no influence picker) | + +Full guide: [tours.md](tours.md). + ### Shared 3D behaviour **3D images** use locally cached files only (`galleryImageUrl` in `client/src/api/client.ts`). Remote fetches are too slow for realtime WebGL textures; the client calls `POST /api/artists/:id/preload-images` automatically when entering an **artist** hall (public route — links disk files only). Movement galleries load painting lists from the API without a separate preload step. While a texture is loading, the frame shows the canvas cover instead of a white placeholder. The center of the hall shows **“Loading gallery…”** until the WebGL canvas is ready, then **“Loading paintings…”** until every wall texture has resolved (tracked through `GalleryTextureLoadContext`). The 3D hall stays mounted while painting detail or bio overlays are open; returning remounts the canvas when the hall becomes active again. @@ -359,11 +376,12 @@ Movement galleries do **not** use the predecessor/successor influence picker — ## Painting detail view -Opened from the 3D hall (artist or movement wing — click a frame) or from influence thumbnails on another work’s detail page. +Opened from the 3D hall (artist, movement, or tour wing — click a frame) or from influence thumbnails on another work’s detail page. | Layer | What you see | |-------|----------------| | **Detail** | Centre image, *Influenced By* (left) and *Influenced* (right) — painting thumbnails (full work visible, letterboxed), artist portraits, or movement swatches — position in catalog (e.g. `3 of 12`) | +| **Tour notes** | When opened from a guided tour: stop text panel under the image (English body from `tour_stops`) | | **Art history notes** | Numbered markers on the image (when positioned) plus a note list below — short citations from Gombrich, museum catalogs, Wikipedia, etc. (`painting_annotations` table) | | **Fullscreen** | Click the centre image; `Escape` or click anywhere to return to detail only | @@ -371,7 +389,7 @@ Opened from the 3D hall (artist or movement wing — click a frame) or from infl | Input | Action | |-------|--------| -| `‹` / `›` beside image | Previous / next work by the **same artist** (chronological order) | +| `‹` / `›` beside image | Previous / next in the **current catalog** — artist chronology, movement chronology, or **tour stop order** | | `←` / `→` | Same as prev / next (disabled while fullscreen is open) | | Click centre image | Open fullscreen lightbox | | Click influence thumbnail | Open that work’s detail (different artist allowed) | @@ -382,7 +400,7 @@ Opened from the 3D hall (artist or movement wing — click a frame) or from infl **Navigation rules:** -- **Catalog browsing** (‹ › / arrow keys) walks the current artist’s works earliest → latest. It does **not** change the back target: after browsing several works, **Back to Gallery** still returns directly to the hall. +- **Catalog browsing** (‹ › / arrow keys) walks the active catalog (artist / movement chronology, or tour stops). It does **not** change the back target: after browsing several works, **Back to Gallery** still returns directly to the hall. - **Influence links** push a new detail layer; **Back** from an influenced work returns to the painting you came from (and from there back to the gallery if applicable). - Side-panel influence images use **`object-fit: contain`** so tall or wide works are not cropped (dark letterbox background). - The 3D hall stays mounted in the background while detail is open so nothing is lost on return. @@ -404,6 +422,8 @@ Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens - **Movement lineage** (`movement-lineage.ts`) documents art-historical predecessor→successor links for the flow diagram; extend that file to add or correct branches. - **One hall per artist** keeps navigation predictable: enter from the timeline or bio, leave via the single exit or back button. - **Movement galleries** complement artist halls: full movement corpus in period-themed wings, entered from the flow diagram. +- **Guided tours** add curator-ordered winged halls with stop text on painting detail — [tours.md](tours.md). +- **Wall hang** is shared: first work on the left at the entrance, last on the right. - **Influence-based hall links** connect artists through documented painting relationships, grouped by movement at the exit. - **3D gallery images** use locally cached files only; slow remote fetches would break realtime rendering. The client calls `POST /api/artists/:id/preload-images` automatically when entering an **artist** hall (public route — links disk files only). - **3D gallery session** stays mounted while painting detail or bio overlays are open; returning to the hall remounts the WebGL canvas when it becomes active again. @@ -416,7 +436,7 @@ Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens | Role | Who | Can do | |------|-----|--------| | **`user`** | Anonymous visitor (default) | Browse timeline, movement flow, 3D artist/movement halls, painting detail, artist bios, images | -| **`curator`** | Named account (`users` table) | Everything above + **Debug mode**, **Checkup**, **Translations**, **Influences**, debug API mutations | +| **`curator`** | Named account (`users` table) | Everything above + **Debug mode**, **Checkup**, **Translations**, **Influences**, **Tour editor**, debug API mutations | Curators sign in via **Curator login** in the site header. Sessions use an HTTP-only cookie (`gallery.sid`). The UI hides debug controls from guests; the server enforces the same rules on debug/checkup API routes (`401` without a valid session). @@ -435,6 +455,8 @@ Curator-only workflow for reviewing and fixing local image files — not part of | **Checkup page** | Home header → **Checkup** (curators only) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags | | **Translations** | Home header → **Translations** (curators only) | Review/publish Russian `entity_translations` | | **Influences** | Home header → **Influences** (curators only) | List/CRUD influence edges, CSV/JSON/XLSX import wizard, neighborhood graph — [influence-import.md](influence-import.md) | +| **Tour editor** | Home header → **Tour editor** (curators only) | Create/publish guided tours and stop text — [tours.md](tours.md) | +| **Tours** | Home header → **Tours** (everyone) | Open published tours in a 3D hall — [tours.md](tours.md) | | **Logout** | Home header (curators) | Ends session; hides debug tools | | **Debug panel** | Painting detail or artist bio (bottom-left, when debug mode on) | Search preview + action buttons (six on painting detail, five on artist bio) | @@ -481,5 +503,6 @@ See [API.md](API.md#authentication) and [data-and-images.md](data-and-images.md# | [DB_structure.md](DB_structure.md) | Tables and relationships | | [API.md](API.md) | REST endpoints | | [influence-import.md](influence-import.md) | Curator Influences tool — import wizard, CRUD, graph | +| [tours.md](tours.md) | Guided tours — editor, public popup, 3D tour hall | | [i18n-russian.md](i18n-russian.md) | Russian UI + entity_translations | | [data-and-images.md](data-and-images.md) | Image pipeline and seeding | diff --git a/Documentation/data-and-images.md b/Documentation/data-and-images.md index ca0d97f..e31cffc 100644 --- a/Documentation/data-and-images.md +++ b/Documentation/data-and-images.md @@ -265,7 +265,7 @@ To add or fix a branch, edit `MOVEMENT_LINEAGE` in that file and rebuild the cli Separate from movement lineage layout, `client/src/data/movement-interior-styles.ts` defines a **unique 3D interior** for each seeded art movement (26 styles): wall/floor/ceiling textures, trim colours, window style, and architectural details (columns, coffered ceilings, etc.). Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts`. -Wing layout (up to 55 works per wing, side-wall-only hang, window gap placement) lives in `client/src/utils/movementHallLayout.ts`. To change a movement’s look, edit its entry in `movement-interior-styles.ts` and rebuild the client. +Wing layout (up to 55 works per wing, side-wall-only hang, window gap placement) lives in `client/src/utils/movementHallLayout.ts`. Visit order fills the **left wall** (first work at the entrance), then the **right** (last work at the entrance). The same hang applies to artist halls and guided tours. To change a movement’s look, edit its entry in `movement-interior-styles.ts` and rebuild the client. ## Historical event markers (frontend timeline) diff --git a/Documentation/setup.md b/Documentation/setup.md index e4fe44a..6ce04b1 100644 --- a/Documentation/setup.md +++ b/Documentation/setup.md @@ -72,7 +72,7 @@ If migration fails with permission errors, grant schema rights to the app user f `npm run dev:migrate` applies `db/migrate-auth.sql` (`users`, `curator_audit_log`, `session` tables). When the `users` table is empty and `CURATOR_USERNAME` / `CURATOR_PASSWORD` are set in `.env`, the first curator account is created automatically. -After migrate, sign in from the site header (**Curator login**). Debug mode, Checkup, Translations, Influences, and all mutating debug APIs require an active curator session. Anonymous visitors browse the timeline and 3D halls without logging in. +After migrate, sign in from the site header (**Curator login**). Debug mode, Checkup, Translations, Influences, Tour editor, and all mutating debug APIs require an active curator session. Anonymous visitors browse the timeline, published Tours, and 3D halls without logging in. See [API.md — Authentication](API.md#authentication) and [basics.md — Developer tools](basics.md#developer-tools-image-audit). diff --git a/Documentation/tours.md b/Documentation/tours.md new file mode 100644 index 0000000..c3cb0a0 --- /dev/null +++ b/Documentation/tours.md @@ -0,0 +1,47 @@ +# Guided tours + +Curated walkthroughs of selected paintings. Visitors open **Tours** on the timeline, pick a published tour, and walk a 3D hall (`VirtualGallery` mode `tour`). Opening a frame shows stop notes on painting detail; prev/next follow tour order. + +## Status + +| Status | Who sees it | +|--------|-------------| +| `draft` | Curators only (Tour editor + `GET /api/tours/:id` when signed in) | +| `published` | Everyone via Tours popup + public API | + +Tour stop **body** text is English-only in v1 (stored on `tour_stops.body`). UI chrome is EN/RU via i18n. + +## Data model + +Migration: `db/migrate-tours.sql` (applied by `npm run dev:migrate`). + +| Table | Role | +|-------|------| +| `tours` | Title, description, `status`, optional `cover_painting_id` | +| `tour_stops` | Ordered stops: `tour_id`, `painting_id`, `sort_order`, `body` (unique per tour+painting) | + +Included in `scripts/harmonize-db.js` catalog sync. + +## Curator editor + +Home header → **Tour editor** (curators). Create tours, set draft/published, search-add paintings, reorder, edit stop text, save. + +## Visitor flow + +1. Timeline → **Tours** → modal of published tours +2. Select tour → `GET /api/tours/:id` → winged 3D hall (left wall first → right wall last, stop order preserved) +3. Click frame → painting detail with tour notes + stop-ordered navigation +4. Back / hall exit → timeline + +Hall hang rules match artist and movement galleries — see [basics.md — Virtual gallery](basics.md#virtual-gallery-3d-halls). + +## API + +See [API.md](API.md#tours). Audit actions: `tour.create`, `tour.update`, `tour.delete`, `tour.stops`. + +## Out of scope (v1) + +- Russian tour body translations +- Auto-generated / AI tours +- Audio or timed slideshow +- Editing tours from painting detail diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 9121ba6..cf3ad86 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -5,9 +5,12 @@ import type { Artist, ArtistDetail, MovementGalleryDetail, + Painting, PaintingDetail, ArtistNavigation, CatalogSearchResponse, + TourSummary, + TourGalleryDetail, } from '../types'; import { readStoredLocale, type AppLocale } from '../utils/localeStorage'; @@ -686,6 +689,82 @@ export const api = { return res.json() as Promise<{ inserted: number; skipped: number; attempted: number }>; }), + listPublishedTours: () => + fetchJson<{ tours: TourSummary[] }>(`${API}/tours`), + + listAdminTours: () => + fetchJson<{ tours: TourSummary[] }>(`${API}/tours/admin`), + + getTour: (id: number) => + fetchJson(`${API}/tours/${id}`), + + createTour: (payload: { title: string; description?: string; status?: 'draft' | 'published' }) => + fetch(`${API}/tours`, { + ...fetchCredentials, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }).then(async (res) => { + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Create failed: ${res.status}`); + } + return res.json() as Promise<{ tour: TourSummary }>; + }), + + updateTour: ( + id: number, + payload: Partial<{ + title: string; + description: string; + status: 'draft' | 'published'; + coverPaintingId: number | null; + }>, + ) => + fetch(`${API}/tours/${id}`, { + ...fetchCredentials, + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }).then(async (res) => { + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Update failed: ${res.status}`); + } + return res.json() as Promise<{ tour: TourSummary }>; + }), + + deleteTour: (id: number) => + fetch(`${API}/tours/${id}`, { + ...fetchCredentials, + method: 'DELETE', + }).then(async (res) => { + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Delete failed: ${res.status}`); + } + return res.json() as Promise<{ ok: boolean }>; + }), + + saveTourStops: (id: number, stops: Array<{ paintingId: number; body: string }>) => + fetch(`${API}/tours/${id}/stops`, { + ...fetchCredentials, + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ stops }), + }).then(async (res) => { + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Save stops failed: ${res.status}`); + } + return res.json() as Promise<{ + ok: boolean; + stopCount: number; + paintings: Painting[]; + stopBodies: Record; + }>; + }), + preloadArtistImages, }; @@ -750,6 +829,8 @@ export interface InfluencePriorImport { match: 'file' | 'data' | 'unknown'; } +export type { TourSummary, TourGalleryDetail }; + export interface InfluenceImportParseResult { filename: string; format: string; diff --git a/client/src/components/PaintingDetail.css b/client/src/components/PaintingDetail.css index 036f37c..cc17500 100644 --- a/client/src/components/PaintingDetail.css +++ b/client/src/components/PaintingDetail.css @@ -407,6 +407,40 @@ display: block; } +.tour-stop-panel { + max-width: 700px; + width: 100%; + margin-top: 20px; + padding: 16px 18px; + background: rgba(201, 169, 110, 0.12); + border-radius: 6px; + border: 1px solid rgba(201, 169, 110, 0.35); +} + +.tour-stop-panel h3 { + margin: 0 0 10px; + font-family: Georgia, 'Times New Roman', serif; + font-size: 15px; + font-weight: 600; + color: #c9a96e; +} + +.tour-stop-body { + margin: 0; + font-family: Georgia, 'Times New Roman', serif; + font-size: 14px; + line-height: 1.7; + color: rgba(232, 213, 181, 0.92); + white-space: pre-wrap; +} + +.tour-stop-empty { + margin: 0; + font-size: 13px; + color: rgba(201, 169, 110, 0.55); + font-style: italic; +} + .painting-description { max-width: 700px; margin-top: 20px; diff --git a/client/src/components/PaintingDetail.tsx b/client/src/components/PaintingDetail.tsx index 018ccb4..5c2f964 100644 --- a/client/src/components/PaintingDetail.tsx +++ b/client/src/components/PaintingDetail.tsx @@ -14,6 +14,8 @@ interface Props { data: PaintingDetail; artistPaintings?: Painting[]; backLabel?: string; + tourTitle?: string | null; + tourText?: string | null; onBack: () => void; onPaintingClick: (paintingId: number) => void; onCatalogNavigate: (paintingId: number) => void; @@ -194,6 +196,8 @@ export default function PaintingDetailView({ data, artistPaintings = [], backLabel = '← Back to Gallery', + tourTitle = null, + tourText = null, onBack, onPaintingClick, onCatalogNavigate, @@ -207,6 +211,7 @@ export default function PaintingDetailView({ }: Props) { const { t } = useTranslation('painting'); const { painting, influencedBy, influenced, annotations = [] } = data; + const inTour = tourText != null; const [fullscreen, setFullscreen] = useState(false); const [imageVersion, setImageVersion] = useState(0); const [debugSearch, setDebugSearch] = useState(null); @@ -470,7 +475,9 @@ export default function PaintingDetailView({ {showCatalogNav && ( {' · '} - {catalogIndex + 1} of {artistPaintings.length} + {inTour + ? t('tourStopPosition', { current: catalogIndex + 1, total: artistPaintings.length }) + : `${catalogIndex + 1} of ${artistPaintings.length}`} )}

@@ -569,6 +576,16 @@ export default function PaintingDetailView({ )} + {inTour && ( + + )} {painting.description && (

{painting.description}

diff --git a/client/src/components/ToursPopup.css b/client/src/components/ToursPopup.css new file mode 100644 index 0000000..a2aa025 --- /dev/null +++ b/client/src/components/ToursPopup.css @@ -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); +} diff --git a/client/src/components/ToursPopup.tsx b/client/src/components/ToursPopup.tsx new file mode 100644 index 0000000..1827cdf --- /dev/null +++ b/client/src/components/ToursPopup.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +
+
e.stopPropagation()} + > +
+

{t('popupTitle')}

+ +
+

{t('popupHint')}

+ {loading &&

{t('loading')}

} + {error &&

{error}

} + {!loading && !error && tours.length === 0 && ( +

{t('noPublished')}

+ )} +
    + {tours.map((tour) => { + const cover = tour.coverThumbnailPath || tour.coverImagePath; + return ( +
  • + +
  • + ); + })} +
+
+
+ ); +} diff --git a/client/src/components/VirtualGallery.tsx b/client/src/components/VirtualGallery.tsx index 3762bb3..3d3ca94 100644 --- a/client/src/components/VirtualGallery.tsx +++ b/client/src/components/VirtualGallery.tsx @@ -10,6 +10,7 @@ import type { ArtistPeriod, ArtistNavigation, MovementArtistGroup, + TourGalleryDetail, } from '../types'; import { galleryImageUrlWithRevision, imageUrl, api } from '../api/client'; import { comparePaintingsChronological, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils'; @@ -75,7 +76,12 @@ interface MovementGalleryProps extends BaseGalleryProps { 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_THICKNESS = 0.18; @@ -97,9 +103,6 @@ const MAX_FRAME_H = 1.35; const MIN_HALL_SIZE = 9; const ROW_GAP = 0.2; 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. */ const MAX_WALL_ROWS = 1; const DOOR_WIDTH = 2.4; @@ -290,36 +293,18 @@ function minSpanForWall(paintings: Painting[], maxRows: number = paintings.lengt 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[]) { - return [...paintings].sort(comparePaintingsChronological).reverse(); -} - +/** + * Visit order along the side walls: first half on the left (starting at the + * 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[]) { - const sorted = [...paintings].sort(comparePaintingsChronological); - const back: Painting[] = []; - 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); - }); - } - + const ordered = [...paintings].sort(comparePaintingsChronological); + const mid = Math.ceil(ordered.length / 2); return [ - orderPaintingsForWallDisplay(back), - orderPaintingsForWallDisplay(left), - orderPaintingsForWallDisplay(right), + [] as Painting[], + ordered.slice(0, mid), + ordered.slice(mid), ]; } @@ -377,12 +362,13 @@ function layoutWallSlots( position: [s.offset, y, -halfD + inset + WALL_STANDOFF + BACK_WALL_EXTRA], }); } else if (side === 'left') { + // Flip along-wall offset so index 0 is at the entrance (+Z), left of view. slots.push({ maxW: s.maxW, maxH: s.maxH, rotationY: Math.PI / 2, side, - position: [-halfW + inset + WALL_STANDOFF, y, s.offset], + position: [-halfW + inset + WALL_STANDOFF, y, -s.offset], }); } else { slots.push({ @@ -1690,16 +1676,38 @@ export default function VirtualGallery(props: Props) { } = props; const isMovement = props.mode === 'movement'; - const hallKey = isMovement ? props.data.movement.id : props.data.artist.id; - const hallTitle = isMovement ? props.data.movement.name : props.data.artist.name; - const movementColor = isMovement ? props.data.movement.color : props.data.artist.movement_color; + const isTour = props.mode === 'tour'; + const isWingedHall = isMovement || isTour; + 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(() => { if (props.mode === 'movement') { return [...props.data.paintings].sort(comparePaintingsChronological); } 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 [periods, setPeriods] = useState(initialPeriods); @@ -1779,8 +1787,8 @@ export default function VirtualGallery(props: Props) { ); const movementHalls = useMemo( - () => (isMovement ? buildAllMovementHallLayouts(paintings) : []), - [isMovement, paintings] + () => (isWingedHall ? buildAllMovementHallLayouts(paintings) : []), + [isWingedHall, paintings] ); const [hallIndex, setHallIndex] = useState(0); @@ -1790,11 +1798,11 @@ export default function VirtualGallery(props: Props) { }, [hallKey]); const layout = useMemo(() => { - if (isMovement && movementHalls.length > 0) { + if (isWingedHall && movementHalls.length > 0) { return movementHalls[Math.min(hallIndex, movementHalls.length - 1)]; } return buildHallLayout(paintings, periods); - }, [isMovement, movementHalls, hallIndex, paintings, periods]); + }, [isWingedHall, movementHalls, hallIndex, paintings, periods]); const gallerySceneLoading = active && !glLost && (!canvasReady || texturesPending > 0); @@ -1809,7 +1817,7 @@ export default function VirtualGallery(props: Props) { return computeSideWallWindows(layout as MovementHallLayout, interiorStyle); }, [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 halfD = layout.depth / 2 - 0.35; @@ -1869,7 +1877,7 @@ export default function VirtualGallery(props: Props) { }, [hallKey, initialPos, initialTarget]); const openExitNav = useCallback(async () => { - if (isMovement) { + if (isWingedHall) { setShowExitNav(true); return; } @@ -1883,9 +1891,9 @@ export default function VirtualGallery(props: Props) { } finally { 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 moveCamera = useCallback( @@ -1910,7 +1918,7 @@ export default function VirtualGallery(props: Props) { pos.x = Math.max(-halfW, Math.min(halfW, pos.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)); 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; @@ -1930,7 +1938,7 @@ export default function VirtualGallery(props: Props) { setCamPos(pos); setCamTarget(target); }, - [halfW, halfD, exitZ, isMovement, backExitZ, frontPassageZ, hasNextHall] + [halfW, halfD, exitZ, isWingedHall, backExitZ, frontPassageZ, hasNextHall] ); useEffect(() => { @@ -1947,7 +1955,7 @@ export default function VirtualGallery(props: Props) { const onKeyDown = (e: KeyboardEvent) => { keysPressed.current.add(e.key); if ((e.key === 'e' || e.key === 'E') && !showExitNav) { - if (isMovement && nearPassage && hasNextHall) { + if (isWingedHall && nearPassage && hasNextHall) { goToNextHall(); } else { openExitNav(); @@ -1975,15 +1983,19 @@ export default function VirtualGallery(props: Props) { window.removeEventListener('keyup', onKeyUp); clearInterval(interval); }; - }, [active, moveCamera, showExitNav, openExitNav, isMovement, nearPassage, hasNextHall, goToNextHall]); + }, [active, moveCamera, showExitNav, openExitNav, isWingedHall, nearPassage, hasNextHall, goToNextHall]); const handleNavigate = (artistId: number) => { - if (isMovement) return; + if (isWingedHall) return; setShowExitNav(false); 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.subtitle} · ${paintings.length} works${ movementHalls.length > 1 @@ -2029,7 +2041,7 @@ export default function VirtualGallery(props: Props) { } }; - const exitHint = isMovement ? ( + const exitHint = isWingedHall ? ( <> Back wall: E for wing navigator · Front arch: next wing {movementHalls.length > 1 ? ` (${hallIndex + 1}/${movementHalls.length})` : ''} @@ -2039,11 +2051,15 @@ export default function VirtualGallery(props: Props) { ); const hallSubtitle = - isMovement && 'yearLabel' in layout - ? `Wing ${hallIndex + 1} of ${movementHalls.length} · ${(layout as MovementHallLayout).yearLabel}` + isWingedHall && 'yearLabel' in layout + ? `Wing ${hallIndex + 1} of ${movementHalls.length}${ + isMovement ? ` · ${(layout as MovementHallLayout).yearLabel}` : '' + }` : undefined; - const instructionsTitle = isMovement + const instructionsTitle = isTour + ? `${hallTitle} · Guided tour` + : isMovement ? interiorStyle ? `${hallTitle} · ${interiorStyle.label}` : `${hallTitle} Gallery` @@ -2059,9 +2075,9 @@ export default function VirtualGallery(props: Props) {
- {!isMovement && ( + {!isWingedHall && ( )}
@@ -2124,11 +2140,11 @@ export default function VirtualGallery(props: Props) { movementColor={movementColor} interiorStyle={interiorStyle} imageRevisions={imageRevisions} - showCaptions={isMovement} + showCaptions={isWingedHall} onPaintingClick={onPaintingClick} onExitActivate={openExitNav} nearExit={nearExit} - movementMode={isMovement} + movementMode={isWingedHall} computedWindows={computedWindows} hasNextHall={hasNextHall} onNextHall={goToNextHall} @@ -2139,7 +2155,7 @@ export default function VirtualGallery(props: Props) { - {!isMovement && showExitNav && ( + {!isWingedHall && showExitNav && ( )} - {isMovement && showExitNav && ( + {isWingedHall && showExitNav && ( A / / Q Turn left
  • D / Turn right
  • Drag on the view to look around
  • -
  • Click a painting to view details and influences
  • - {isMovement ? ( +
  • Click a painting to view details{isTour ? ' and tour notes' : ' and influences'}
  • + {isWingedHall ? ( <>
  • Date and artist labels appear below each frame
  • Works hang on left & right walls — up to ~55 per wing
  • diff --git a/client/src/i18n/index.ts b/client/src/i18n/index.ts index 4eb3604..67dd0e8 100644 --- a/client/src/i18n/index.ts +++ b/client/src/i18n/index.ts @@ -12,6 +12,7 @@ import enAnnotations from '../locales/en/annotations.json'; import enDebug from '../locales/en/debug.json'; import enTranslations from '../locales/en/translations.json'; import enInfluences from '../locales/en/influences.json'; +import enTours from '../locales/en/tours.json'; import ruCommon from '../locales/ru/common.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 ruTranslations from '../locales/ru/translations.json'; import ruInfluences from '../locales/ru/influences.json'; +import ruTours from '../locales/ru/tours.json'; const initialLocale = readStoredLocale(); writeStoredLocale(initialLocale); @@ -31,7 +33,7 @@ void i18n.use(initReactI18next).init({ lng: initialLocale, fallbackLng: 'en', 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', resources: { en: { @@ -45,6 +47,7 @@ void i18n.use(initReactI18next).init({ debug: enDebug, translations: enTranslations, influences: enInfluences, + tours: enTours, }, ru: { common: ruCommon, @@ -57,6 +60,7 @@ void i18n.use(initReactI18next).init({ debug: ruDebug, translations: ruTranslations, influences: ruInfluences, + tours: ruTours, }, }, interpolation: { escapeValue: false }, diff --git a/client/src/locales/en/home.json b/client/src/locales/en/home.json index 8c71ca4..70e7135 100644 --- a/client/src/locales/en/home.json +++ b/client/src/locales/en/home.json @@ -15,6 +15,11 @@ "checkup": "Checkup", "translations": "Translations", "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", "curatorRequiredBody": "Sign in as a curator to use this tool.", "backToGalleryBtn": "Back to gallery" diff --git a/client/src/locales/en/painting.json b/client/src/locales/en/painting.json index 75254f0..808c7e5 100644 --- a/client/src/locales/en/painting.json +++ b/client/src/locales/en/painting.json @@ -7,5 +7,9 @@ "catalogPosition": "Catalog position", "lightboxHint": "Click anywhere to close", "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}}" } diff --git a/client/src/locales/en/tours.json b/client/src/locales/en/tours.json new file mode 100644 index 0000000..849e02d --- /dev/null +++ b/client/src/locales/en/tours.json @@ -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." +} diff --git a/client/src/locales/ru/home.json b/client/src/locales/ru/home.json index 50e2d25..9f75db3 100644 --- a/client/src/locales/ru/home.json +++ b/client/src/locales/ru/home.json @@ -15,6 +15,11 @@ "checkup": "Проверка", "translations": "Переводы", "influences": "Влияния", + "tours": "Экскурсии", + "toursEditor": "Редактор экскурсий", + "openingTourGallery": "Открытие экскурсии…", + "tourEmpty": "В этой экскурсии пока нет картин.", + "tourLoadFailed": "Не удалось загрузить экскурсию.", "curatorRequiredTitle": "Требуется доступ куратора", "curatorRequiredBody": "Войдите как куратор, чтобы использовать этот инструмент.", "backToGalleryBtn": "Вернуться в галерею" diff --git a/client/src/locales/ru/painting.json b/client/src/locales/ru/painting.json index c4bc023..b56fcd0 100644 --- a/client/src/locales/ru/painting.json +++ b/client/src/locales/ru/painting.json @@ -7,5 +7,9 @@ "catalogPosition": "Позиция в каталоге", "lightboxHint": "Нажмите в любом месте, чтобы закрыть", "prevPainting": "Предыдущая картина", - "nextPainting": "Следующая картина" + "nextPainting": "Следующая картина", + "tourNotes": "Текст экскурсии", + "tourNotesFor": "Экскурсия · {{title}}", + "tourNotesEmpty": "Для этой остановки нет текста.", + "tourStopPosition": "Остановка {{current}} из {{total}}" } diff --git a/client/src/locales/ru/tours.json b/client/src/locales/ru/tours.json new file mode 100644 index 0000000..3e8f264 --- /dev/null +++ b/client/src/locales/ru/tours.json @@ -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": "Остановок пока нет. Найдите и добавьте картины." +} diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx index 8b295f7..8a579f8 100644 --- a/client/src/pages/HomePage.tsx +++ b/client/src/pages/HomePage.tsx @@ -9,18 +9,30 @@ import ArtistBio from '../components/ArtistBio'; import CheckupPage from '../pages/CheckupPage'; import TranslationsPage from '../pages/TranslationsPage'; import InfluencesPage from '../pages/InfluencesPage'; +import ToursPage from '../pages/ToursPage'; import CuratorLoginModal from '../components/CuratorLoginModal'; +import ToursPopup from '../components/ToursPopup'; import CatalogSearchBar from '../components/CatalogSearchBar'; import LocaleSwitcher from '../components/LocaleSwitcher'; import GalleryLoadingMarker from '../components/GalleryLoadingMarker'; import '../components/CatalogSearchBar.css'; import '../components/CuratorLoginModal.css'; +import '../components/ToursPopup.css'; import '../components/LocaleSwitcher.css'; import '../pages/TranslationsPage.css'; import '../pages/InfluencesPage.css'; +import '../pages/ToursPage.css'; import { useAuth } from '../context/AuthContext'; 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 { sortArtistPaintingsChronological } from '../utils/paintingUtils'; import { readDebugMode, readDebugShowMore, writeDebugMode, writeDebugShowMore } from '../utils/debugMode'; @@ -31,14 +43,19 @@ type View = | { type: 'checkup' } | { type: 'translations' } | { type: 'influences' } + | { type: 'tours' } | { type: 'gallery'; artistId: number; data: ArtistDetail } | { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail } + | { type: 'tour-gallery'; tourId: number; data: TourGalleryDetail } | { type: 'painting'; paintingId: number; data: PaintingDetail; returnTo: View } | { type: 'bio'; artistId: number; data: ArtistDetail; returnTo: View }; type GallerySession = | { 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( detail: MovementGalleryDetail, @@ -51,6 +68,17 @@ function patchPaintingInMovementDetail( }; } +function patchPaintingInTourDetail( + detail: TourGalleryDetail, + paintingId: number, + patch: Partial +): TourGalleryDetail { + return { + ...detail, + paintings: detail.paintings.map((p) => (p.id === paintingId ? { ...p, ...patch } : p)), + }; +} + function patchPaintingInArtistDetail( detail: ArtistDetail, paintingId: number, @@ -72,7 +100,8 @@ function patchArtistInArtistDetail(detail: ArtistDetail, patch: Partial) function patchReturnToAfterRemove( returnTo: View, freshArtist?: ArtistDetail, - freshMovement?: MovementGalleryDetail + freshMovement?: MovementGalleryDetail, + freshTour?: TourGalleryDetail ): View { if (returnTo.type === 'gallery' && freshArtist && returnTo.artistId === freshArtist.artist.id) { return { ...returnTo, data: freshArtist }; @@ -84,8 +113,14 @@ function patchReturnToAfterRemove( ) { return { ...returnTo, data: freshMovement }; } + if (returnTo.type === 'tour-gallery' && freshTour && returnTo.tourId === freshTour.tour.id) { + return { ...returnTo, data: freshTour }; + } 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') { const data = @@ -93,7 +128,7 @@ function patchReturnToAfterRemove( return { ...returnTo, data, - returnTo: patchReturnToAfterRemove(returnTo.returnTo, freshArtist, freshMovement), + returnTo: patchReturnToAfterRemove(returnTo.returnTo, freshArtist, freshMovement, freshTour), }; } return returnTo; @@ -131,7 +166,8 @@ export default function HomePage() { const [debugMode, setDebugMode] = useState(readDebugMode); const [debugShowMore, setDebugShowMore] = useState(readDebugShowMore); const [loginOpen, setLoginOpen] = useState(false); - const [loginRedirect, setLoginRedirect] = useState<'checkup' | 'translations' | 'influences' | null>(null); + const [loginRedirect, setLoginRedirect] = useState(null); + const [toursPopupOpen, setToursPopupOpen] = useState(false); const effectiveDebugMode = debugMode && isCurator; const [galleryRevision, setGalleryRevision] = useState(0); const viewRef = useRef(view); @@ -148,6 +184,8 @@ export default function HomePage() { setGallerySession({ kind: 'artist', artistId: view.artistId, data: view.data }); } else if (view.type === 'movement-gallery') { 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') { setGallerySession(null); } @@ -225,7 +263,7 @@ export default function HomePage() { writeDebugShowMore(enabled); }; - const openCuratorLogin = (redirect: 'checkup' | 'translations' | 'influences' | null = null) => { + const openCuratorLogin = (redirect: CuratorLoginRedirect = null) => { setLoginRedirect(redirect); setLoginOpen(true); }; @@ -239,6 +277,8 @@ export default function HomePage() { setView({ type: 'translations' }); } else if (loginRedirect === 'influences') { setView({ type: 'influences' }); + } else if (loginRedirect === 'tours') { + setView({ type: 'tours' }); } setLoginRedirect(null); }; @@ -247,7 +287,12 @@ export default function HomePage() { await logout(); writeDebugMode(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(); } }; @@ -276,6 +321,14 @@ export default function HomePage() { setView({ type: 'influences' }); }; + const openToursEditor = () => { + if (!isCurator) { + openCuratorLogin('tours'); + return; + } + setView({ type: 'tours' }); + }; + const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => { const data = await api.getPainting(paintingId); const patch: Partial = { @@ -308,6 +361,12 @@ export default function HomePage() { data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch), }; } + if (returnTo.type === 'tour-gallery') { + returnTo = { + ...returnTo, + data: patchPaintingInTourDetail(returnTo.data, paintingId, patch), + }; + } return { ...current, data: updatedData, returnTo }; }); @@ -322,6 +381,9 @@ export default function HomePage() { if (session?.kind === 'movement') { return { ...session, data: patchPaintingInMovementDetail(session.data, paintingId, patch) }; } + if (session?.kind === 'tour') { + return { ...session, data: patchPaintingInTourDetail(session.data, paintingId, patch) }; + } return session; }); }, []); @@ -353,6 +415,12 @@ export default function HomePage() { data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch), }; } + if (returnTo.type === 'tour-gallery') { + returnTo = { + ...returnTo, + data: patchPaintingInTourDetail(returnTo.data, paintingId, patch), + }; + } return { ...current, data: updatedData, returnTo }; }); @@ -367,6 +435,9 @@ export default function HomePage() { if (session?.kind === 'movement') { return { ...session, data: patchPaintingInMovementDetail(session.data, paintingId, patch) }; } + if (session?.kind === 'tour') { + return { ...session, data: patchPaintingInTourDetail(session.data, paintingId, patch) }; + } return session; }); }, @@ -455,6 +526,32 @@ export default function HomePage() { 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) => { setGalleryEntryLoading('Opening artist gallery…'); try { @@ -517,25 +614,38 @@ export default function HomePage() { const currentView = viewRef.current; 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 inMovementCatalog = gallerySession?.kind === 'movement' || currentView.returnTo.type === 'movement-gallery'; + const inTourCatalog = + gallerySession?.kind === 'tour' || currentView.returnTo.type === 'tour-gallery'; await api.deletePainting(paintingId); const freshArtist = await api.getArtist(artistId); let freshMovement: MovementGalleryDetail | undefined; + let freshTour: TourGalleryDetail | undefined; if (gallerySession?.kind === 'movement') { freshMovement = await api.getMovementGallery(gallerySession.movementId); } else if (currentView.returnTo.type === 'movement-gallery') { 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 = - inMovementCatalog && freshMovement - ? sortArtistPaintingsChronological(freshMovement.paintings) - : sortArtistPaintingsChronological(freshArtist.paintings); + inTourCatalog && freshTour + ? freshTour.paintings + : inMovementCatalog && freshMovement + ? sortArtistPaintingsChronological(freshMovement.paintings) + : sortArtistPaintingsChronological(freshArtist.paintings); const removedIdx = sorted.findIndex((p) => p.id === paintingId); const navigateId = @@ -556,6 +666,9 @@ export default function HomePage() { if (session.kind === 'movement' && freshMovement) { return { ...session, data: freshMovement }; } + if (session.kind === 'tour' && freshTour) { + return { ...session, data: freshTour }; + } return session; }); @@ -570,7 +683,8 @@ export default function HomePage() { const patchedReturnTo = patchReturnToAfterRemove( currentView.returnTo, freshArtist, - freshMovement + freshMovement, + freshTour ); detailReturnToRef.current = patchedReturnTo; @@ -581,6 +695,9 @@ export default function HomePage() { if (current.type === 'movement-gallery' && freshMovement) { return { ...current, data: freshMovement }; } + if (current.type === 'tour-gallery' && freshTour) { + return { ...current, data: freshTour }; + } if (current.type !== 'painting' || current.paintingId !== paintingId) { return current; } @@ -612,12 +729,20 @@ export default function HomePage() { } const artistId = view.data.painting.artist_id; + if (gallerySession?.kind === 'tour') { + setDetailArtistPaintings(gallerySession.data.paintings); + return; + } if (gallerySession?.kind === 'artist' && gallerySession.artistId === artistId) { setDetailArtistPaintings(gallerySession.data.paintings); return; } const returnTo = detailReturnToRef.current; + if (returnTo.type === 'tour-gallery') { + setDetailArtistPaintings(returnTo.data.paintings); + return; + } if (returnTo.type === 'movement-gallery') { setDetailArtistPaintings(sortArtistPaintingsChronological(returnTo.data.paintings)); return; @@ -637,12 +762,31 @@ export default function HomePage() { }; }, [view, gallerySession]); - const sortedDetailArtistPaintings = useMemo( - () => sortArtistPaintingsChronological(detailArtistPaintings), - [detailArtistPaintings] - ); + const sortedDetailArtistPaintings = useMemo(() => { + const fromTourSession = gallerySession?.kind === 'tour'; + 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 => { if (view.type === 'gallery') { @@ -651,6 +795,9 @@ export default function HomePage() { if (view.type === 'movement-gallery') { 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; }, [view, gallerySession]); @@ -679,7 +826,7 @@ export default function HomePage() { }) } /> - ) : ( + ) : displayGallery.kind === 'movement' ? ( + ) : ( + )} )} @@ -700,6 +857,8 @@ export default function HomePage() { data={view.data} artistPaintings={sortedDetailArtistPaintings} backLabel={view.returnTo.type === 'timeline' ? t('backToTimeline') : t('backToGallery')} + tourTitle={tourOverlay?.title ?? null} + tourText={tourOverlay ? tourOverlay.text : null} onBack={() => { if (view.returnTo.type === 'timeline') { goToTimelineHome(); @@ -718,10 +877,18 @@ export default function HomePage() { gallerySession.movementId === returnTo.movementId ) { 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') { openArtistGallery(returnTo.artistId, returnTo.data); } else if (returnTo.type === 'movement-gallery') { openMovementGallery(returnTo.movementId, returnTo.data); + } else if (returnTo.type === 'tour-gallery') { + openTourGallery(returnTo.tourId, returnTo.data); } else { setView(returnTo); } @@ -778,6 +945,25 @@ export default function HomePage() { ) )} + {view.type === 'tours' && ( + isCurator ? ( + + ) : ( +
    +

    {t('curatorRequiredTitle')}

    +

    {t('curatorRequiredBody')}

    +
    + + +
    +
    + ) + )} + {view.type === 'translations' && ( isCurator ? ( @@ -828,6 +1014,12 @@ export default function HomePage() { onLogin={handleCuratorLogin} /> + setToursPopupOpen(false)} + onSelectTour={(tourId) => void handleSelectPublishedTour(tourId)} + /> + {view.type === 'timeline' && (
    @@ -864,6 +1056,14 @@ export default function HomePage() { > {t('influences')} +

    {t('title')}

    diff --git a/client/src/pages/ToursPage.css b/client/src/pages/ToursPage.css new file mode 100644 index 0000000..51a2e62 --- /dev/null +++ b/client/src/pages/ToursPage.css @@ -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; + } +} diff --git a/client/src/pages/ToursPage.tsx b/client/src/pages/ToursPage.tsx new file mode 100644 index 0000000..4221e35 --- /dev/null +++ b/client/src/pages/ToursPage.tsx @@ -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([]); + const [selectedId, setSelectedId] = useState(null); + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [status, setStatus] = useState<'draft' | 'published'>('draft'); + const [stops, setStops] = useState([]); + 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(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 ( +
    +
    + +

    {t('title')}

    +
    + + {error &&
    {error}
    } + +
    + + +
    + {!selectedId ? ( +

    {t('selectTour')}

    + ) : ( + <> +
    + +