4 Commits
Author SHA1 Message Date
Danila KhodjaefandCursor d709e98640 Merge remote main: upside-down influence lamps with local hall fixes.
Keep remote lamp orientation, drop castShadow (shadows stay disabled), and retain curator-note plates plus large-hall loading docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 11:40:55 +03:00
Danila KhodjaefandCursor 8e444fa461 Fix harmonize prod DB targeting and large-hall loading hangs.
Prod env files now win over root .env so harmonize:db can open gallery_prod after gallery_dev; tour_stops gains updated_at; halls no longer block the overlay on every painting texture. Docs cover the fixes and one-artist image pull from prod; Duccio painting assets synced from TrueNAS.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 01:26:51 +03:00
Danila KhodjaefandCursor f6c73c1792 Smooth 3D hall boot: disable shadows and warm door shaders under the loading overlay.
Keep Loading gallery until Environment and gl.compileAsync finish (with timeouts), GPU-upload painting textures before dismiss, and drop Canvas shadow maps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 15:32:10 +03:00
Danila KhodjaefandCursor bc8369e373 Add public curator notes and U-shaped hall wall hang.
Paintings get editable curator notes with brass plates in the 3D hall, and visit order now uses the far/end wall between left and right.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 14:21:22 +03:00
142 changed files with 912 additions and 137 deletions
+2 -1
View File
@@ -16,8 +16,9 @@ TRUST_PROXY=true
IMAGE_DIR=./data/images IMAGE_DIR=./data/images
# Curator auth (run npm run dev:migrate after setting CURATOR_PASSWORD) # Curator auth (run npm run dev:migrate after setting CURATOR_PASSWORD)
# Reset password anytime: npm run dev:reset-curator
SESSION_SECRET=change-me-to-a-long-random-string SESSION_SECRET=change-me-to-a-long-random-string
SESSION_COOKIE_SECURE=false # Omit SESSION_COOKIE_SECURE for auto (HTTPS via proxy → Secure cookie). Set true/false to force.
CURATOR_USERNAME=curator CURATOR_USERNAME=curator
CURATOR_PASSWORD= CURATOR_PASSWORD=
+19
View File
@@ -73,6 +73,7 @@ These return **`401`** with `{ "error": "Curator login required" }` without a va
| `GET /api/artists/:id/debug-portrait-search` (+ `/more`) | — | | `GET /api/artists/:id/debug-portrait-search` (+ `/more`) | — |
| `GET /api/debug/image-proxy` | — | | `GET /api/debug/image-proxy` | — |
| `PATCH /api/paintings/:id/checkup-flags` | `painting.checkup_flags` | | `PATCH /api/paintings/:id/checkup-flags` | `painting.checkup_flags` |
| `PATCH /api/paintings/:id/curator-notes` | `painting.update_curator_notes` |
| `PATCH /api/artists/:id/checkup-flags` | `artist.checkup_flags` | | `PATCH /api/artists/:id/checkup-flags` | `artist.checkup_flags` |
| `POST /api/paintings/:id/fix-image` | `painting.fix_image` | | `POST /api/paintings/:id/fix-image` | `painting.fix_image` |
| `POST /api/paintings/:id/clear-image` | `painting.clear_image` | | `POST /api/paintings/:id/clear-image` | `painting.clear_image` |
@@ -593,6 +594,7 @@ Painting detail with influence graph neighbours.
"thumbnail_cache_key": 1739123456790, "thumbnail_cache_key": 1739123456790,
"checkup_checked": false, "checkup_checked": false,
"checkup_fixed": false, "checkup_fixed": false,
"curator_notes": "",
"has_influence_links": true "has_influence_links": true
}, },
"influencedBy": [ "influencedBy": [
@@ -698,6 +700,22 @@ Full-catalog audit table for the Checkup UI.
--- ---
### `PATCH /api/paintings/:id/curator-notes`
Update public curator notes for a painting.
**Body**
```json
{ "curatorNotes": "Optional editorial text…" }
```
`curatorNotes` must be a string (trim applied; empty string clears the notes).
**Response:** `{ "curatorNotes": "…" }`
**Audit:** `painting.update_curator_notes`
### `PATCH /api/paintings/:id/checkup-flags` ### `PATCH /api/paintings/:id/checkup-flags`
Update review flags. Body: `{ "checked"?: boolean, "fixed"?: boolean }` — at least one field required. Update review flags. Body: `{ "checked"?: boolean, "fixed"?: boolean }` — at least one field required.
@@ -877,6 +895,7 @@ The React client wraps these endpoints in `client/src/api/client.ts`. All reques
| `validateDebugUploadFile(file)` | Client-side size/type check before base64 upload | | `validateDebugUploadFile(file)` | Client-side size/type check before base64 upload |
| `api.getPaintingCheckup()` | `GET /api/paintings/checkup` | | `api.getPaintingCheckup()` | `GET /api/paintings/checkup` |
| `api.updatePaintingCheckupFlags(id, flags)` | `PATCH /api/paintings/:id/checkup-flags` | | `api.updatePaintingCheckupFlags(id, flags)` | `PATCH /api/paintings/:id/checkup-flags` |
| `api.updatePaintingCuratorNotes(id, curatorNotes)` | `PATCH /api/paintings/:id/curator-notes` |
| `api.getPaintingDebugImageSearch(id)` | `GET /api/paintings/:id/debug-image-search` | | `api.getPaintingDebugImageSearch(id)` | `GET /api/paintings/:id/debug-image-search` |
| `api.getPaintingDebugImageSearchMore(id, limit?)` | `GET /api/paintings/:id/debug-image-search/more` | | `api.getPaintingDebugImageSearchMore(id, limit?)` | `GET /api/paintings/:id/debug-image-search/more` |
| `api.fixPaintingImage(id, imageUrl, context?)` | `POST /api/paintings/:id/fix-image` | | `api.fixPaintingImage(id, imageUrl, context?)` | `POST /api/paintings/:id/fix-image` |
+5 -3
View File
@@ -104,7 +104,8 @@ Phases within an artists career (e.g. “Blue Period”, “Roman Period”).
| `period_id` | FK → `artist_periods` | Optional grouping | | `period_id` | FK → `artist_periods` | Optional grouping |
| `title` | VARCHAR(300) | | | `title` | VARCHAR(300) | |
| `year`, `year_end` | INTEGER | Creation date(s) | | `year`, `year_end` | INTEGER | Creation date(s) |
| `description` | TEXT | | | `description` | TEXT | Wikipedia-style catalog text |
| `curator_notes` | TEXT NOT NULL DEFAULT '' | Public curator editorial notes (inline edit on detail; brass plate in 3D hall when non-empty) |
| `image_path` | VARCHAR(500) | Full-size local file; nullable after debug **Clear** | | `image_path` | VARCHAR(500) | Full-size local file; nullable after debug **Clear** |
| `thumbnail_path` | VARCHAR(500) | Smaller variant for lists / 3D; nullable after **Clear** | | `thumbnail_path` | VARCHAR(500) | Smaller variant for lists / 3D; nullable after **Clear** |
| `wikipedia_title` | VARCHAR(300) | Used by image fetcher | | `wikipedia_title` | VARCHAR(300) | Used by image fetcher |
@@ -114,7 +115,7 @@ Phases within an artists career (e.g. “Blue Period”, “Roman Period”).
When `checkup_fixed` is true, `checkup_checked` is set automatically and cannot be cleared until **Fixed** is off. A cleared painting (`image_path` and `thumbnail_path` both null, `checkup_fixed` true) is shown as an empty frame in detail view and is not refetched on demand. When `checkup_fixed` is true, `checkup_checked` is set automatically and cannot be cleared until **Fixed** is off. A cleared painting (`image_path` and `thumbnail_path` both null, `checkup_fixed` true) is shown as an empty frame in detail view and is not refetched on demand.
Applied by `npm run dev:migrate:checkup-flags` (`db/migrate-checkup-flags.sql`). Applied by `npm run dev:migrate:checkup-flags` (`db/migrate-checkup-flags.sql`). `curator_notes` is applied by `db/migrate-curator-notes.sql` via `npm run dev:migrate`.
### `painting_annotations` ### `painting_annotations`
@@ -160,8 +161,9 @@ Curated guided tours. See [tours.md](tours.md).
| `painting_id` | FK → `paintings` | ON DELETE CASCADE; UNIQUE with `tour_id` | | `painting_id` | FK → `paintings` | ON DELETE CASCADE; UNIQUE with `tour_id` |
| `sort_order` | INTEGER | Visitor / editor order | | `sort_order` | INTEGER | Visitor / editor order |
| `body` | TEXT | English tour notes for the stop (v1) | | `body` | TEXT | English tour notes for the stop (v1) |
| `updated_at` | TIMESTAMPTZ | Trigger on UPDATE; required for `npm run harmonize:db` |
Applied by `npm run dev:migrate` (`db/migrate-tours.sql`). Applied by `npm run dev:migrate` (`db/migrate-tours.sql`; `updated_at` also covered by `migrate-sync-timestamps.sql` when the table already exists).
### `painting_influences` ### `painting_influences`
+18 -6
View File
@@ -23,7 +23,7 @@ Details: [environments.md](environments.md) · Deploy: [../infra/docker/DEPLOY-t
### Start — public dev (Keenetic URL) ### Start — public dev (Keenetic URL)
```powershell ```powershell
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery cd T:\Repo\Gallery
npm run dev:web npm run dev:web
``` ```
@@ -77,12 +77,13 @@ Stop-Process -Id <PID> -Force
## First-time install ## First-time install
```powershell ```powershell
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery cd T:\Repo\Gallery
copy .env.example .env # edit DB credentials, PUBLIC_URL copy .env.example .env # edit DB credentials, PUBLIC_URL
npm install npm install
cd client; npm install; cd .. cd client; npm install; cd ..
npm run dev:migrate # schema + incremental SQL (+ auth tables, bootstrap curator) npm run dev:migrate # schema + incremental SQL (+ auth tables, bootstrap curator)
npm run dev:reset-curator # upsert curator password from .env CURATOR_* (clears sessions)
npm run dev:setup # migrate + seed (fresh empty DB only) npm run dev:setup # migrate + seed (fresh empty DB only)
``` ```
@@ -94,14 +95,16 @@ CURATOR_USERNAME=curator
CURATOR_PASSWORD=your-secure-password CURATOR_PASSWORD=your-secure-password
``` ```
Then open the gallery → **Curator login** (top-right) → use debug mode / Checkup / Translations / **Influences**. Mutations are logged in `curator_audit_log` (view in pgAdmin). Then open the gallery → **Curator login** (top-right) → use debug mode / Checkup / Translations / **Influences** / inline **curator notes** on painting detail. Mutations are logged in `curator_audit_log` (view in pgAdmin).
If login fails after changing `CURATOR_PASSWORD` in `.env`, run `npm run dev:reset-curator` (bootstrap only runs when `users` is empty).
**Roles:** **Roles:**
| Role | Access | | Role | Access |
|------|--------| |------|--------|
| Guest (`user`) | Timeline, movement flow, 3D halls, painting detail, bios | | Guest (`user`) | Timeline, movement flow, 3D halls, painting detail, bios |
| Curator | Above + debug mode, Checkup, Translations, Influences (import/CRUD/graph), image fix/upload/delete APIs | | Curator | Above + debug mode, Checkup, Translations, Influences (import/CRUD/graph), curator notes, image fix/upload/delete APIs |
**Audit log (pgAdmin on `gallery_dev` or `gallery_prod`):** **Audit log (pgAdmin on `gallery_dev` or `gallery_prod`):**
@@ -152,7 +155,7 @@ DB_NAME=gallery_dev
PORT=3451 PORT=3451
PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro
SESSION_SECRET=your-long-random-secret SESSION_SECRET=your-long-random-secret
SESSION_COOKIE_SECURE=false # Omit SESSION_COOKIE_SECURE for auto (HTTPS via Keenetic → Secure cookie)
CURATOR_USERNAME=curator CURATOR_USERNAME=curator
CURATOR_PASSWORD=your-secure-password CURATOR_PASSWORD=your-secure-password
``` ```
@@ -240,11 +243,20 @@ net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER
npm run devtoprod:release # full promote from infra/deploy/devtoprod.config.json npm run devtoprod:release # full promote from infra/deploy/devtoprod.config.json
npm run devtoprod:thumbnails # rebuild thumb files + DB paths on dev (before backup) npm run devtoprod:thumbnails # rebuild thumb files + DB paths on dev (before backup)
npm run devtoprod:images # dev repo → TrueNAS (promote / first deploy) npm run devtoprod:images # dev repo → TrueNAS (promote / first deploy)
npm run prodto:dev:images # TrueNAS → dev repo npm run prodto:dev:images # TrueNAS → dev repo (all images)
npm run harmonize # bidirectional merge (newer wins) — see harmonize-dev-prod.md npm run harmonize # bidirectional merge (newer wins) — see harmonize-dev-prod.md
npm run harmonize:dry-run # preview DB + image changes only npm run harmonize:dry-run # preview DB + image changes only
``` ```
**One artist only (prod → dev):** after `net use`, robocopy the artist file prefix (example: Duccio):
```powershell
$src = "\\192.168.10.122\Gallery\data\images\paintings"
$dst = "T:\Repo\Gallery\data\images\paintings"
robocopy $src $dst "Duccio*" /XO /R:2 /W:3
robocopy "$src\thumbs" "$dst\thumbs" "Duccio*" /XO /R:2 /W:3
```
Type `yes` when prompted (or set `autoConfirm: true` in release config). Robocopy exit codes **07** = success. Deploy scripts print a final **`===== SUCCESS =====`** or **`===== FAILED =====`** banner. Type `yes` when prompted (or set `autoConfirm: true` in release config). Robocopy exit codes **07** = success. Deploy scripts print a final **`===== SUCCESS =====`** or **`===== FAILED =====`** banner.
--- ---
+17 -12
View File
@@ -8,8 +8,8 @@ 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, 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. 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, U-shaped hang (left → end wall → 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 **curator notes** and **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**).
**Catalog search** — on the timeline home page, the header search bar (`CatalogSearchBar.tsx`) finds artists, paintings, and movements by name and metadata (year, movement, Wikipedia title). Type at least **2 characters** (300 ms debounce); results group into **Artists**, **Movements**, and **Paintings** with thumbnails. Keyboard: `↑`/`↓` to move, `Enter` to open, `Escape` to close. Choosing a result opens the artist gallery, movement gallery, or painting detail. Paintings opened from search show **← Back to Timeline** and return to the home timeline (full year range), not the previous view. **Catalog search** — on the timeline home page, the header search bar (`CatalogSearchBar.tsx`) finds artists, paintings, and movements by name and metadata (year, movement, Wikipedia title). Type at least **2 characters** (300 ms debounce); results group into **Artists**, **Movements**, and **Paintings** with thumbnails. Keyboard: `↑`/`↓` to move, `Enter` to open, `Escape` to close. Choosing a result opens the artist gallery, movement gallery, or painting detail. Paintings opened from search show **← Back to Timeline** and return to the home timeline (full year range), not the previous view.
@@ -210,8 +210,8 @@ Pan, zoom, and era/event click-to-zoom only update **local** `viewStart` / `view
| Overlay **“Loading art history…”** | Until the first catalog fetch (`bounds` + `timeline` + `artists`) completes | | Overlay **“Loading art history…”** | Until the first catalog fetch (`bounds` + `timeline` + `artists`) completes |
| Bottom banner **“Loading portraits…”** | While artist portrait thumbnails are still downloading on the movement flow (timeline stays interactive) | | Bottom banner **“Loading portraits…”** | While artist portrait thumbnails are still downloading on the movement flow (timeline stays interactive) |
| Overlay **“Opening artist/movement gallery…”** | Between clicking a portrait/movement and the 3D hall data being ready | | Overlay **“Opening artist/movement gallery…”** | Between clicking a portrait/movement and the 3D hall data being ready |
| Overlay **“Loading gallery…”** | While the 3D canvas initializes after the hall opens (center area; header and controls stay visible) | | Overlay **“Loading gallery…”** | While the 3D canvas initializes, HDR Environment settles, or door/hall shaders warm up after the hall opens (painting images continue loading after the overlay dismisses) |
| Overlay **“Loading paintings…”** | While wall textures are still downloading in the 3D hall | | Overlay **“Loading paintings…”** | While wall painting textures are still downloading / uploading to the GPU |
| Overlay **“Restoring gallery…”** | Briefly after WebGL context loss while the canvas remounts | | Overlay **“Restoring gallery…”** | Briefly after WebGL context loss while the canvas remounts |
View updates are **batched to one commit per animation frame** via `createViewChangeScheduler()` in `timelineView.ts` (`HomePage.tsx``handleViewChange`), so rapid scroll-wheel events do not flood React with separate renders. View updates are **batched to one commit per animation frame** via `createViewChangeScheduler()` in `timelineView.ts` (`HomePage.tsx``handleViewChange`), so rapid scroll-wheel events do not flood React with separate renders.
@@ -283,7 +283,7 @@ Only **mousedown** on portraits and movement labels stops propagation (so drag-t
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)). 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. **Shared wall hang (all modes):** visit order is a **U-shape****left wall**, then the **far/end wall** ahead when entering, then the **right wall**. 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. With fewer than three works, only left/right are used. Artist halls use chronological order; movement wings use chronological order within each wing; tours use stop order.
### Artist halls ### Artist halls
@@ -293,15 +293,17 @@ Each artist has **exactly one hall**. The hall is a rectangular room sized to fi
|------|----------------| |------|----------------|
| One hall per artist | `VirtualGallery.tsx` builds a single room from that artists paintings | | One hall per artist | `VirtualGallery.tsx` builds a single room from that artists paintings |
| Catalog depth | Most artists target **≥ 6** notable works via `npm run dev:expand-catalog` and `famous-paintings-data.js`; some masters have larger museum dumps | | Catalog depth | Most artists target **≥ 6** notable works via `npm run dev:expand-catalog` and `famous-paintings-data.js`; some masters have larger museum dumps |
| Paintings on walls | Works hang on the **left and right** walls in **one row per wall**; room **depth grows** when the catalog is large (back wall is for the exit only) | | Paintings on walls | Works hang on **left**, **far/end**, and **right** walls in **one row per wall**; room **depth** and **width** grow with the catalog |
| 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 | Chronological U-path: first third **left** (entrance → end), middle third **end wall**, last third **right** (end → entrance) |
| Floor | Herringbone **parquet** procedural texture (`parquetFloorTexture.ts`) | | Floor | Herringbone **parquet** procedural texture (`parquetFloorTexture.ts`) |
| Wall tint | Gallery walls blend the artists **movement colour** into cream plaster tones | | Wall tint | Gallery walls blend the artists **movement colour** into cream plaster tones |
| Frame finish | **Unchecked** works: black moulding; **Reviewed** (`checkup_checked`): bright gold moulding at **double width** | | Frame finish | **Unchecked** works: black moulding; **Reviewed** (`checkup_checked`): bright gold moulding at **double width** |
| Influence lamps | A golden picture light appears **above frames** whose work has any influence-graph edge (`has_influence_links` from the API); fixture is mounted **upside down** (`InfluencePictureLamp` in `VirtualGallery.tsx`) | | Influence lamps | A golden picture light appears **above frames** whose work has any influence-graph edge (`has_influence_links` from the API); fixture is mounted **upside down** (`InfluencePictureLamp` in `VirtualGallery.tsx`) |
| Curator-note plates | A small brass plate hangs **beneath frames** that have non-empty `curator_notes` |
| Eye-level viewing | Frame centres sit at **eye height (~1.65 m)**; the camera stays **level with the floor** (no pitch up/down) | | Eye-level viewing | Frame centres sit at **eye height (~1.65 m)**; the camera stays **level with the floor** (no pitch up/down) |
| Open centre | Floor and ceiling only — no columns, pedestals, or other centre objects | | Open centre | Floor and ceiling only — no columns, pedestals, or other centre objects |
| Museum exit | Front-wall **double doors** with transom, brass hardware, sconces, marble threshold, and warm vestibule glow | | Museum exit | Front-wall **double doors** with transom, brass hardware, sconces, marble threshold, and warm vestibule glow |
| Shadows | **Disabled** — no Canvas shadow maps / `castShadow` (performance; flat lighting only) |
| Hall-to-hall travel | Exit opens a panel: **Predecessors** (left) and **Successors** (right), each grouped by art movement | | Hall-to-hall travel | Exit opens a panel: **Predecessors** (left) and **Successors** (right), each grouped by art movement |
| Missing images | Works without a local file show a **draped canvas cover** in the frame (not a blank white rectangle) | | Missing images | Works without a local file show a **draped canvas cover** in the frame (not a blank white rectangle) |
| Detail view return | Opening a painting close-up **keeps the 3D hall mounted** in the background so position and view direction are preserved when you go back | | Detail view return | Opening a painting close-up **keeps the 3D hall mounted** in the background so position and view direction are preserved when you go back |
@@ -329,8 +331,8 @@ 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, end, and right** — end-wall works sit on the panels beside the exit doors; front wall remains passage / solid |
| Wall order | Same shared hang as artist halls: first half left (entrance → back), second half right (back → entrance) | | Wall order | Same U-shaped hang as artist halls (left → end → right) |
| 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 |
@@ -362,7 +364,7 @@ Enter from the home page **Tours** popup (`GET /api/tours/:id`). Layout reuses t
|------|----------------| |------|----------------|
| Visit order | Curator `sort_order` on `tour_stops` (not chronological) | | Visit order | Curator `sort_order` on `tour_stops` (not chronological) |
| Wings | Same ~55-per-wing split as movements; order preserved across wings | | Wings | Same ~55-per-wing split as movements; order preserved across wings |
| Wall hang | Same left-then-right rule as other halls | | Wall hang | Same U-shaped hang as other halls (left → end → right) |
| Detail | Tour stop text panel; walks tour stops | | Detail | Tour stop text panel; walks tour stops |
| Exit | Wing navigator / **Exit to Timeline** (no influence picker) | | Exit | Wing navigator / **Exit to Timeline** (no influence picker) |
@@ -370,7 +372,9 @@ 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.
**Boot overlay** (`VirtualGallery.tsx`): the center shows **“Loading gallery…”** until the WebGL canvas is ready, HDR `Environment` has settled (or timed out / failed), and a one-shot `gl.compileAsync` warm-up finishes so entrance doors / passages (often frustum-culled at spawn) do not hitch on the first turn. Painting textures keep loading in the background (prefer thumbnails; per-image timeouts; GPU upload after decode) so large halls (50+ works) are not stuck on the overlay. Env and shader warm-up also have short timeouts. The 3D hall stays mounted while painting detail or bio overlays are open; returning remounts the canvas when the hall becomes active again.
**WebGL context-loss recovery:** on some GPUs/drivers (notably certain Chrome setups) the browser can drop the WebGL context right after entering a hall, which would otherwise leave a permanent dark window. `VirtualGallery.tsx` listens for `webglcontextlost` / `webglcontextrestored`, calls `preventDefault()` so the browser can restore the context, and remounts the `<Canvas>` with a fresh context (a **“Restoring gallery…”** overlay shows briefly). The network-loaded HDR `Environment` map is wrapped in an error boundary so, if it fails to load, the hall still renders without reflections instead of unmounting the whole scene. **WebGL context-loss recovery:** on some GPUs/drivers (notably certain Chrome setups) the browser can drop the WebGL context right after entering a hall, which would otherwise leave a permanent dark window. `VirtualGallery.tsx` listens for `webglcontextlost` / `webglcontextrestored`, calls `preventDefault()` so the browser can restore the context, and remounts the `<Canvas>` with a fresh context (a **“Restoring gallery…”** overlay shows briefly). The network-loaded HDR `Environment` map is wrapped in an error boundary so, if it fails to load, the hall still renders without reflections instead of unmounting the whole scene.
@@ -382,6 +386,7 @@ Opened from the 3D hall (artist, movement, or tour wing — click a frame) or fr
|-------|----------------| |-------|----------------|
| **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`) | | **Tour notes** | When opened from a guided tour: stop text panel under the image (English body from `tour_stops`) |
| **Curator notes** | Public editorial text on the painting (`paintings.curator_notes`); visitors see it when non-empty; curators edit inline on the detail page |
| **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 |
@@ -428,7 +433,7 @@ Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens
- **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.
- **3D gallery resilience:** a lost WebGL context is recovered by remounting the canvas with a fresh context (rather than showing a dark window), and the HDR environment map is isolated behind an error boundary so its failure never blanks the scene. - **3D gallery resilience:** a lost WebGL context is recovered by remounting the canvas with a fresh context (rather than showing a dark window), and the HDR environment map is isolated behind an error boundary so its failure never blanks the scene.
- **Loading feedback:** `GalleryLoadingMarker` surfaces catalog load, portrait download, gallery entry, painting-texture load, and context-restore states so the user always knows work is still in progress. - **Loading feedback:** `GalleryLoadingMarker` surfaces catalog load, portrait download, gallery entry, painting-texture / GPU warm-up, Environment settle, and context-restore states so the user always knows work is still in progress.
- **Influence data** is stored in **`painting_influence_sources`** (directed links from paintings to source paintings, artists, or movements), with optional period fields and citation metadata. Sources include curated scholarship (`art-influences-data.js`) and **PainterPalette** (`discovered_via = painter-palette`). Legacy `painting_influences` mirrors painting-to-painting edges for scripts only. - **Influence data** is stored in **`painting_influence_sources`** (directed links from paintings to source paintings, artists, or movements), with optional period fields and citation metadata. Sources include curated scholarship (`art-influences-data.js`) and **PainterPalette** (`discovered_via = painter-palette`). Legacy `painting_influences` mirrors painting-to-painting edges for scripts only.
## User roles and access ## User roles and access
+16 -3
View File
@@ -31,7 +31,7 @@ File names are sanitised `{Artist}_{Title}.{ext}`. The image service can redisco
| **Development** | `./data/images/` in repo | Working copy on dev PC | | **Development** | `./data/images/` in repo | Working copy on dev PC |
| **Production** | `/mnt/BasePool/Applications/Gallery/data/images` on TrueNAS | SMB `\\192.168.10.122\Gallery\data\images` | | **Production** | `/mnt/BasePool/Applications/Gallery/data/images` on TrueNAS | SMB `\\192.168.10.122\Gallery\data\images` |
**One-direction promote:** `npm run devtoprod:images` (dev → prod, skip older). **Refresh dev from prod:** `npm run prodto:dev:images`. **Bidirectional merge** (newer file wins): `npm run harmonize:images` or full `npm run harmonize` — see [harmonize-dev-prod.md](harmonize-dev-prod.md). General sync reference: [environments.md](environments.md). **One-direction promote:** `npm run devtoprod:images` (dev → prod, skip older). **Refresh all images on dev from prod:** `npm run prodto:dev:images`. **One artist only:** map the SMB share, then `robocopy` that artists `ArtistName*` files under `paintings/` and `paintings/thumbs/` (see [FAC.md](FAC.md#dev--prod-image-sync-smb)). **Bidirectional merge** (newer file wins): `npm run harmonize:images` or full `npm run harmonize` — see [harmonize-dev-prod.md](harmonize-dev-prod.md). General sync reference: [environments.md](environments.md).
## Scripts overview ## Scripts overview
@@ -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`. Visit order fills the **left wall** (first work at the entrance), then the **right** (last work at the entrance). The same hang applies to artist halls and guided tours. To change a movements look, edit its entry in `movement-interior-styles.ts` and rebuild the client. Wing layout (up to 55 works per wing, U-shaped hang, window gap placement) lives in `client/src/utils/movementHallLayout.ts`. Visit order fills the **left wall** (first work at the entrance), then the **far/end wall**, then the **right** (last work at the entrance). The same hang applies to artist halls and guided tours. To change a movements look, edit its entry in `movement-interior-styles.ts` and rebuild the client.
## Historical event markers (frontend timeline) ## Historical event markers (frontend timeline)
@@ -282,9 +282,22 @@ Wing layout (up to 55 works per wing, side-wall-only hang, window gap placement)
Edit `HISTORICAL_EVENTS` and rebuild the client to extend the set. Edit `HISTORICAL_EVENTS` and rebuild the client to extend the set.
## Curator notes (painting editorial text)
Public notes authored by curators on a painting — separate from Wikipedia `description`, tour stop text, and art-history annotations.
| Aspect | Detail |
|--------|--------|
| Storage | `paintings.curator_notes` (`TEXT NOT NULL DEFAULT ''`) |
| Migration | `db/migrate-curator-notes.sql` via `npm run dev:migrate` |
| UI | Panel on painting detail (after tour notes, before description); curators edit inline when signed in |
| Hall marker | Small brass plate beneath the frame in the 3D hall when notes are non-empty |
| API | Included on `GET /api/paintings/:id` (`p.*`); update via `PATCH /api/paintings/:id/curator-notes` (curator) |
| i18n | Canonical English only for now (not in the translations pipeline) |
## Painting annotations (art-history notes) ## Painting annotations (art-history notes)
Short curator-style notes on the painting detail page — separate from the influence graph. Short art-history citations on the painting detail page — separate from the influence graph and from `curator_notes`.
| Aspect | Detail | | Aspect | Detail |
|--------|--------| |--------|--------|
+1 -1
View File
@@ -16,7 +16,7 @@ Step-by-step guide for promoting the **development** version of Gallery to **pro
| URL | https://devgallery.mysuperlab.netcraze.pro | https://gallery.mysuperlab.netcraze.pro | | URL | https://devgallery.mysuperlab.netcraze.pro | https://gallery.mysuperlab.netcraze.pro |
| Env file | root [`.env`](../.env) (`gallery_dev`) | [`infra/docker/.env.prod`](../infra/docker/.env.prod) (`gallery_prod`) | | Env file | root [`.env`](../.env) (`gallery_dev`) | [`infra/docker/.env.prod`](../infra/docker/.env.prod) (`gallery_prod`) |
All commands run on the **dev PC** from the repo root (`C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery`) unless a step says **TrueNAS**. All commands run on the **dev PC** from the repo root (`T:\Repo\Gallery`) unless a step says **TrueNAS**.
--- ---
+7 -9
View File
@@ -90,14 +90,14 @@ If STEP 2 fails with “database already exists”, STEP 0 likely already shows
**Alternative (dev PC with psql installed):** **Alternative (dev PC with psql installed):**
```powershell ```powershell
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery cd T:\Repo\Gallery
psql -h 192.168.10.122 -U postgres -d postgres -f db/split-dev-prod.sql psql -h 192.168.10.122 -U postgres -d postgres -f db/split-dev-prod.sql
``` ```
**Alternative (Node, postgres password in env):** **Alternative (Node, postgres password in env):**
```powershell ```powershell
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery cd T:\Repo\Gallery
$env:PGHOST="192.168.10.122"; $env:PGUSER="postgres"; $env:PGPASSWORD="YOUR_POSTGRES_PASSWORD" $env:PGHOST="192.168.10.122"; $env:PGUSER="postgres"; $env:PGPASSWORD="YOUR_POSTGRES_PASSWORD"
npm run infra:db:split-dev-prod npm run infra:db:split-dev-prod
``` ```
@@ -113,17 +113,16 @@ npm run infra:db:split-dev-prod
PORT=3451 PORT=3451
PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro
SESSION_SECRET=your-long-random-secret SESSION_SECRET=your-long-random-secret
SESSION_COOKIE_SECURE=false
CURATOR_USERNAME=curator CURATOR_USERNAME=curator
CURATOR_PASSWORD=your-secure-password CURATOR_PASSWORD=your-secure-password
``` ```
`npm run dev:migrate` creates auth tables and bootstraps the first curator when `users` is empty. Omit `SESSION_COOKIE_SECURE` so cookies follow the request scheme (`TRUST_PROXY` + HTTPS → Secure). Set `true`/`false` to force. `npm run dev:migrate` creates auth tables and bootstraps the first curator when `users` is empty. Reset password later with `npm run dev:reset-curator`.
2. Run: 2. Run:
```powershell ```powershell
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery cd T:\Repo\Gallery
npm run dev:migrate npm run dev:migrate
npm run dev:web npm run dev:web
``` ```
@@ -156,7 +155,7 @@ Requires SMB share **`Gallery`** → `/mnt/BasePool/Applications/Gallery` on Tru
# Map share (use your TrueNAS SMB user/password) # Map share (use your TrueNAS SMB user/password)
net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery cd T:\Repo\Gallery
npm run devtoprod:images npm run devtoprod:images
``` ```
@@ -173,7 +172,7 @@ If `net use` fails, open `\\192.168.10.122\Gallery` in File Explorer and sign in
Prerequisites: **Docker Desktop running**, logged in to Gitea. Prerequisites: **Docker Desktop running**, logged in to Gitea.
```powershell ```powershell
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery cd T:\Repo\Gallery
docker login gitea.mysuperlab.netcraze.pro docker login gitea.mysuperlab.netcraze.pro
npm run prod:docker:publish npm run prod:docker:publish
``` ```
@@ -254,10 +253,9 @@ Expect **HTTP 200** (not 502).
PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro
TRUST_PROXY=true TRUST_PROXY=true
SESSION_SECRET=your-long-random-secret SESSION_SECRET=your-long-random-secret
SESSION_COOKIE_SECURE=false
``` ```
Prod (`infra/docker/.env.prod`): set `SESSION_COOKIE_SECURE=true` and the same `SESSION_SECRET` / `CURATOR_*` vars on the TrueNAS app environment. Omit `SESSION_COOKIE_SECURE` for auto Secure cookies behind Keenetic HTTPS. Prod (`infra/docker/.env.prod`): set `SESSION_COOKIE_SECURE=true` and the same `SESSION_SECRET` / `CURATOR_*` vars on the TrueNAS app environment.
Restart `npm run dev:web` after changing `PUBLIC_URL`. Restart `npm run dev:web` after changing `PUBLIC_URL`.
+17 -2
View File
@@ -45,7 +45,7 @@ For refreshing dev from prod entirely, use `npm run prodto:dev:db` (destructive
net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER
``` ```
5. Copy harmonize config: 5. Copy **dedicated** harmonize config (do not rely on `devtoprod.config.json` alone — that file is a release profile and its step flags are ignored for harmonize):
```powershell ```powershell
Copy-Item infra/deploy/harmonize.config.example.json infra/deploy/harmonize.config.json Copy-Item infra/deploy/harmonize.config.example.json infra/deploy/harmonize.config.json
@@ -53,6 +53,8 @@ For refreshing dev from prod entirely, use `npm run prodto:dev:db` (destructive
Edit `harmonize.config.json` (gitignored) — optional `smb.user` / `smb.password`, `prefer` for tie-breaks (`dev` | `prod`), `schemaChanged: true` when new migrations shipped. Edit `harmonize.config.json` (gitignored) — optional `smb.user` / `smb.password`, `prefer` for tie-breaks (`dev` | `prod`), `schemaChanged: true` when new migrations shipped.
6. Prod DB targeting uses [`infra/docker/.env.prod`](../infra/docker/.env.prod). `scripts/db-env.js` loads that file with **file values winning** over the root `.env`, so `harmonize:db` can open `gallery_dev` and `gallery_prod` in the same process without the safety check rejecting `gallery_dev` as a fake prod target.
--- ---
## One-command harmonize ## One-command harmonize
@@ -107,7 +109,9 @@ Reports are written to `db/SyncReports/harmonize_db_*.json` and `harmonize_image
Processed in FK order: Processed in FK order:
`historical_eras` → `art_movements` → `artists` → `artist_periods` → `paintings` → `painting_influences` → `painting_influence_sources` → `painting_annotations` → **`entity_translations`** `historical_eras` → `art_movements` → `artists` → `artist_periods` → `paintings` → `painting_influences` → `painting_influence_sources` → `painting_annotations` → **`entity_translations`** → **`tours`** → **`tour_stops`**
Every synced table needs an `updated_at` column (including `tour_stops` — added via `migrate-tours.sql` / `migrate-sync-timestamps.sql`).
--- ---
@@ -134,6 +138,7 @@ Harmonize reports conflicts in the JSON report and skips those rows:
| New migration in repo | `harmonize:schema` or deploy step 4 | | New migration in repo | `harmonize:schema` or deploy step 4 |
| Only images changed on one side | `npm run harmonize:images` | | Only images changed on one side | `npm run harmonize:images` |
| Only DB metadata changed | `npm run harmonize:db` | | Only DB metadata changed | `npm run harmonize:db` |
| Pull **one artists** images from prod → dev | Map SMB, then robocopy `Duccio*` (or the artist prefix) under `paintings/` and `paintings/thumbs/` — see [FAC.md — Dev ↔ prod image sync](FAC.md#dev--prod-image-sync-smb) |
--- ---
@@ -163,6 +168,16 @@ npm run harmonize
--- ---
## Troubleshooting
| Symptom | Likely cause | Fix |
|---------|--------------|-----|
| `Refusing prod operation on database "gallery_dev"` | Old `db-env` left `DB_NAME` from root `.env` when loading prod | Update to current `scripts/db-env.js` (`.env.prod` wins); confirm `infra/docker/.env.prod` has `DB_NAME=gallery_prod` |
| `Table tour_stops missing updated_at` | Prod/dev schema behind | `npm run dev:migrate` and migrate prod (`$env:DB_NAME="gallery_prod"; npm run dev:migrate; Remove-Item Env:\DB_NAME`) |
| Orchestrator lists release steps (`restoreProd`, …) | Missing `harmonize.config.json`; fallback to release config | Copy `harmonize.config.example.json` → `harmonize.config.json` (current `harmonize.ps1` ignores release-only step keys) |
---
## Safety ## Safety
- Pre-flight backups of dev and prod DB (configurable; on by default) - Pre-flight backups of dev and prod DB (configurable; on by default)
+1 -1
View File
@@ -69,7 +69,7 @@ API (curator-only): see [API.md](API.md#translations-curator).
| `era`, `movement` | `name`, `description` | | `era`, `movement` | `name`, `description` |
| `artist` | `name`, `bio_short`, `bio_full` | | `artist` | `name`, `bio_short`, `bio_full` |
| `artist_period` | `name`, `description` | | `artist_period` | `name`, `description` |
| `painting` | `title`, `description` | | `painting` | `title`, `description` (not `curator_notes` — English-only for now) |
| `annotation` | `label`, `body` | | `annotation` | `label`, `body` |
| `influence_source` | `notes`, `aspects`, `quote`, `period_note` | | `influence_source` | `notes`, `aspects`, `quote`, `period_note` |
+6 -5
View File
@@ -29,9 +29,9 @@ cp .env.example .env
| `TRUST_PROXY` | Set to `true` when behind nginx/reverse proxy (honours `X-Forwarded-*`) | | `TRUST_PROXY` | Set to `true` when behind nginx/reverse proxy (honours `X-Forwarded-*`) |
| `IMAGE_DIR` | Root for cached images (default `./data/images`) | | `IMAGE_DIR` | Root for cached images (default `./data/images`) |
| `SESSION_SECRET` | Random string for signed session cookies (required for curator login) | | `SESSION_SECRET` | Random string for signed session cookies (required for curator login) |
| `SESSION_COOKIE_SECURE` | `false` for local HTTP dev; `true` in prod behind HTTPS | | `SESSION_COOKIE_SECURE` | Optional — omit for auto (HTTPS via proxy → Secure); set `true`/`false` to force |
| `CURATOR_USERNAME` | Bootstrap only — first curator account name (default `curator`) | | `CURATOR_USERNAME` | Bootstrap / reset — curator account name (default `curator`) |
| `CURATOR_PASSWORD` | Bootstrap only — password for first curator when `users` table is empty | | `CURATOR_PASSWORD` | Bootstrap when `users` is empty; also used by `npm run dev:reset-curator` |
`.env` is git-ignored; never commit passwords. `.env` is git-ignored; never commit passwords.
@@ -70,7 +70,7 @@ If migration fails with permission errors, grant schema rights to the app user f
### Curator accounts (auth migration) ### Curator accounts (auth migration)
`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. To sync the password from `.env` later, run `npm run dev:reset-curator`.
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. 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.
@@ -283,9 +283,10 @@ After clone: copy `.env.example` → `.env`, install dependencies, run [one-time
| Movement gallery shows generic cream walls | Stale client build | `cd client && npm run build`; hard-refresh browser | | Movement gallery shows generic cream walls | Stale client build | `cd client && npm run build`; hard-refresh browser |
| Windows overlap paintings in movement wing | Stale client | Rebuild client — windows are placed only on side walls in gaps between frames | | Windows overlap paintings in movement wing | Stale client | Rebuild client — windows are placed only on side walls in gaps between frames |
| Influence thumbnails cropped on painting detail | Stale client build | `npm run prod:build` — panels use `object-fit: contain` for full image | | Influence thumbnails cropped on painting detail | Stale client build | `npm run prod:build` — panels use `object-fit: contain` for full image |
| **Curator login** fails / always guest | Auth tables missing or wrong password | Set `SESSION_SECRET` + `CURATOR_PASSWORD` in `.env`, run `npm run dev:migrate`, restart server | | **Curator login** fails / always guest | Auth tables missing or wrong password | Set `SESSION_SECRET` + `CURATOR_PASSWORD` in `.env`, run `npm run dev:migrate` (or `npm run dev:reset-curator`), restart server |
| Debug / Checkup returns **401** | Not signed in as curator | **Curator login** (top-right); session cookie `gallery.sid` must be sent (`credentials: include`) | | Debug / Checkup returns **401** | Not signed in as curator | **Curator login** (top-right); session cookie `gallery.sid` must be sent (`credentials: include`) |
| Debug works in UI but API rejects | Stale server without auth middleware | Restart `npm run dev:web` or `npm run dev:server` after pulling auth changes | | Debug works in UI but API rejects | Stale server without auth middleware | Restart `npm run dev:web` or `npm run dev:server` after pulling auth changes |
| **Empty screen** entering 3D hall (header missing) | Stale client before gallery-session fix | Hard-refresh; pull latest client — hall renders from `view` state, not only `gallerySession` | | **Empty screen** entering 3D hall (header missing) | Stale client before gallery-session fix | Hard-refresh; pull latest client — hall renders from `view` state, not only `gallerySession` |
| **Dark center** entering 3D hall (header visible, no spinner) | Stale client before gallery loading overlay fix | Hard-refresh; latest client shows **Loading gallery…** / **Loading paintings…** in the canvas area until ready | | **Dark center** entering 3D hall (header visible, no spinner) | Stale client before gallery loading overlay fix | Hard-refresh; latest client shows **Loading gallery…** / **Loading paintings…** in the canvas area until ready |
| 3D hall loading overlay never clears | Stuck texture counter or hung HDR | Hard-refresh; current client times out Environment / shader warm-up so the overlay cannot stay forever |
| 3D hall black after returning from painting detail | WebGL context lost while hall was hidden | Hard-refresh; latest client remounts canvas when hall becomes active again | | 3D hall black after returning from painting detail | WebGL context lost while hall was hidden | Hard-refresh; latest client remounts canvas when hall becomes active again |
+22
View File
@@ -64,6 +64,14 @@ export async function loginCurator(username: string, password: string): Promise<
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),
}); });
if (!res.ok) { if (!res.ok) {
const contentType = res.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
throw new Error(
res.status === 401
? 'Login blocked by the reverse proxy (not the gallery). Use http://localhost:5173 or fix Keenetic access.'
: `Login failed: HTTP ${res.status}`
);
}
const body = await res.json().catch(() => ({})); const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Login failed: ${res.status}`); throw new Error(body.error || `Login failed: ${res.status}`);
} }
@@ -436,6 +444,20 @@ export const api = {
return res.json() as Promise<{ checked: boolean; fixed: boolean }>; return res.json() as Promise<{ checked: boolean; fixed: boolean }>;
}), }),
updatePaintingCuratorNotes: (id: number, curatorNotes: string) =>
fetch(`${API}/paintings/${id}/curator-notes`, {
...fetchCredentials,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ curatorNotes }),
}).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<{ curatorNotes: string }>;
}),
getArtistDebugPortraitSearch: (id: number) => getArtistDebugPortraitSearch: (id: number) =>
fetchJson<DebugImageSearchResult>(`${API}/artists/${id}/debug-portrait-search`), fetchJson<DebugImageSearchResult>(`${API}/artists/${id}/debug-portrait-search`),
-2
View File
@@ -181,7 +181,6 @@ function SingleWindow({
intensity={spec.lightIntensity} intensity={spec.lightIntensity}
distance={14} distance={14}
color={spec.lightColor} color={spec.lightColor}
castShadow={false}
/> />
<pointLight <pointLight
position={[0, 0, 0.25]} position={[0, 0, 0.25]}
@@ -250,7 +249,6 @@ export function GalleryTrackLights({
intensity={intensity} intensity={intensity}
distance={8} distance={8}
color={color} color={color}
castShadow={false}
/> />
</group> </group>
))} ))}
+2 -2
View File
@@ -46,14 +46,14 @@ export default function HallPassage({
{/* Side jambs */} {/* Side jambs */}
{([-1, 1] as const).map((sign) => ( {([-1, 1] as const).map((sign) => (
<mesh key={sign} position={[sign * (openingW / 2 + jamb / 2), openingH / 2, faceZ]} castShadow> <mesh key={sign} position={[sign * (openingW / 2 + jamb / 2), openingH / 2, faceZ]}>
<boxGeometry args={[jamb, openingH + 0.1, 0.1]} /> <boxGeometry args={[jamb, openingH + 0.1, 0.1]} />
<meshStandardMaterial color={trimColor} roughness={0.45} metalness={0.25} /> <meshStandardMaterial color={trimColor} roughness={0.45} metalness={0.25} />
</mesh> </mesh>
))} ))}
{/* Arch header */} {/* Arch header */}
<mesh position={[0, openingH + 0.06, faceZ]} castShadow> <mesh position={[0, openingH + 0.06, faceZ]}>
<boxGeometry args={[openingW + jamb * 2, 0.14, 0.1]} /> <boxGeometry args={[openingW + jamb * 2, 0.14, 0.1]} />
<meshStandardMaterial color={trimColor} roughness={0.4} metalness={0.3} /> <meshStandardMaterial color={trimColor} roughness={0.4} metalness={0.3} />
</mesh> </mesh>
@@ -26,7 +26,7 @@ function PalazzoDetails({ width, depth, halfW, halfD, trim }: Props & { trim: st
<group> <group>
{pilasterPositions.map(([x, z], i) => ( {pilasterPositions.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}> <group key={i} position={[x, 0, z]}>
<mesh position={[0, 1.8, 0]} castShadow> <mesh position={[0, 1.8, 0]}>
<boxGeometry args={[0.22, 3.6, 0.22]} /> <boxGeometry args={[0.22, 3.6, 0.22]} />
<meshStandardMaterial color="#e8dcc8" roughness={0.85} metalness={0.05} /> <meshStandardMaterial color="#e8dcc8" roughness={0.85} metalness={0.05} />
</mesh> </mesh>
+109
View File
@@ -441,6 +441,115 @@
font-style: italic; font-style: italic;
} }
.curator-notes-panel {
max-width: 700px;
width: 100%;
margin-top: 20px;
padding: 16px 18px;
background: rgba(201, 169, 110, 0.1);
border-radius: 6px;
border: 1px solid rgba(201, 169, 110, 0.28);
}
.curator-notes-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.curator-notes-panel h3 {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: 15px;
font-weight: 600;
color: #c9a96e;
}
.curator-notes-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;
}
.curator-notes-empty {
margin: 0;
font-size: 13px;
color: rgba(201, 169, 110, 0.55);
font-style: italic;
}
.curator-notes-edit-btn,
.curator-notes-save-btn,
.curator-notes-cancel-btn {
font-family: Georgia, 'Times New Roman', serif;
font-size: 13px;
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
border: 1px solid rgba(201, 169, 110, 0.45);
background: transparent;
color: #c9a96e;
}
.curator-notes-edit-btn:hover,
.curator-notes-cancel-btn:hover {
background: rgba(201, 169, 110, 0.12);
}
.curator-notes-save-btn {
background: rgba(201, 169, 110, 0.2);
color: #e8d5b5;
}
.curator-notes-save-btn:hover:not(:disabled) {
background: rgba(201, 169, 110, 0.32);
}
.curator-notes-edit-btn:disabled,
.curator-notes-save-btn:disabled,
.curator-notes-cancel-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.curator-notes-textarea {
display: block;
width: 100%;
box-sizing: border-box;
margin: 0;
padding: 10px 12px;
border-radius: 4px;
border: 1px solid rgba(201, 169, 110, 0.35);
background: rgba(15, 15, 26, 0.55);
color: rgba(232, 213, 181, 0.95);
font-family: Georgia, 'Times New Roman', serif;
font-size: 14px;
line-height: 1.6;
resize: vertical;
}
.curator-notes-textarea:focus {
outline: none;
border-color: rgba(201, 169, 110, 0.7);
}
.curator-notes-actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
.curator-notes-error {
margin: 8px 0 0;
font-size: 13px;
color: #e08a6a;
}
.painting-description { .painting-description {
max-width: 700px; max-width: 700px;
margin-top: 20px; margin-top: 20px;
+95 -1
View File
@@ -21,6 +21,7 @@ interface Props {
onCatalogNavigate: (paintingId: number) => void; onCatalogNavigate: (paintingId: number) => void;
onArtistBio: () => void; onArtistBio: () => void;
onInfluenceArtistClick?: (artistId: number) => void; onInfluenceArtistClick?: (artistId: number) => void;
isCurator?: boolean;
debugMode?: boolean; debugMode?: boolean;
debugShowMore?: boolean; debugShowMore?: boolean;
onPaintingImageFixed?: ( onPaintingImageFixed?: (
@@ -32,6 +33,7 @@ interface Props {
flags: { checked: boolean; fixed: boolean } flags: { checked: boolean; fixed: boolean }
) => void | Promise<void>; ) => void | Promise<void>;
onPaintingRemoved?: (paintingId: number, artistId: number) => void | Promise<void>; onPaintingRemoved?: (paintingId: number, artistId: number) => void | Promise<void>;
onCuratorNotesUpdated?: (paintingId: number, curatorNotes: string) => void;
} }
function influenceKey(inf: InfluenceLink, index: number): string { function influenceKey(inf: InfluenceLink, index: number): string {
@@ -203,15 +205,22 @@ export default function PaintingDetailView({
onCatalogNavigate, onCatalogNavigate,
onArtistBio, onArtistBio,
onInfluenceArtistClick, onInfluenceArtistClick,
isCurator = false,
debugMode = false, debugMode = false,
debugShowMore = false, debugShowMore = false,
onPaintingImageFixed, onPaintingImageFixed,
onPaintingCheckupFlagsUpdated, onPaintingCheckupFlagsUpdated,
onPaintingRemoved, onPaintingRemoved,
onCuratorNotesUpdated,
}: 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 inTour = tourText != null;
const [curatorNotes, setCuratorNotes] = useState(painting.curator_notes ?? '');
const [editingNotes, setEditingNotes] = useState(false);
const [notesDraft, setNotesDraft] = useState(painting.curator_notes ?? '');
const [savingNotes, setSavingNotes] = useState(false);
const [notesError, setNotesError] = useState<string | null>(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);
@@ -257,7 +266,13 @@ export default function PaintingDetailView({
setMarkingChecked(false); setMarkingChecked(false);
setApplyingUrl(null); setApplyingUrl(null);
setMoreLoading(false); setMoreLoading(false);
}, [painting.id]); const notes = painting.curator_notes ?? '';
setCuratorNotes(notes);
setNotesDraft(notes);
setEditingNotes(false);
setSavingNotes(false);
setNotesError(null);
}, [painting.id, painting.curator_notes]);
useEffect(() => { useEffect(() => {
if (!debugMode) { if (!debugMode) {
@@ -291,6 +306,34 @@ export default function PaintingDetailView({
}; };
}, [debugMode, uploading, painting.id, painting.title, painting.artist_name]); }, [debugMode, uploading, painting.id, painting.title, painting.artist_name]);
const startEditingNotes = () => {
setNotesDraft(curatorNotes);
setNotesError(null);
setEditingNotes(true);
};
const cancelEditingNotes = () => {
setNotesDraft(curatorNotes);
setNotesError(null);
setEditingNotes(false);
};
const saveCuratorNotes = async () => {
setSavingNotes(true);
setNotesError(null);
try {
const result = await api.updatePaintingCuratorNotes(painting.id, notesDraft);
setCuratorNotes(result.curatorNotes);
setNotesDraft(result.curatorNotes);
setEditingNotes(false);
onCuratorNotesUpdated?.(painting.id, result.curatorNotes);
} catch {
setNotesError(t('curatorNotesSaveFailed'));
} finally {
setSavingNotes(false);
}
};
const applyImageUpdate = async (fixResult: FixPaintingImageResult) => { const applyImageUpdate = async (fixResult: FixPaintingImageResult) => {
setImageVersion((v) => v + 1); setImageVersion((v) => v + 1);
if (onPaintingImageFixed) { if (onPaintingImageFixed) {
@@ -586,6 +629,57 @@ export default function PaintingDetailView({
)} )}
</aside> </aside>
)} )}
{(isCurator || curatorNotes.trim()) && (
<aside className="curator-notes-panel" aria-label={t('curatorNotes')}>
<div className="curator-notes-header">
<h3>{t('curatorNotes')}</h3>
{isCurator && !editingNotes && (
<button
type="button"
className="curator-notes-edit-btn"
onClick={startEditingNotes}
>
{t('curatorNotesEdit')}
</button>
)}
</div>
{editingNotes ? (
<div className="curator-notes-editor">
<textarea
className="curator-notes-textarea"
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
rows={6}
disabled={savingNotes}
aria-label={t('curatorNotes')}
/>
{notesError && <p className="curator-notes-error">{notesError}</p>}
<div className="curator-notes-actions">
<button
type="button"
className="curator-notes-save-btn"
onClick={saveCuratorNotes}
disabled={savingNotes}
>
{savingNotes ? t('curatorNotesSaving') : t('curatorNotesSave')}
</button>
<button
type="button"
className="curator-notes-cancel-btn"
onClick={cancelEditingNotes}
disabled={savingNotes}
>
{t('curatorNotesCancel')}
</button>
</div>
</div>
) : curatorNotes.trim() ? (
<p className="curator-notes-body">{curatorNotes}</p>
) : (
<p className="curator-notes-empty">{t('curatorNotesEmpty')}</p>
)}
</aside>
)}
{painting.description && ( {painting.description && (
<div className="painting-description"> <div className="painting-description">
<p>{painting.description}</p> <p>{painting.description}</p>
+253 -51
View File
@@ -13,7 +13,7 @@ import type {
TourGalleryDetail, 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, paintingHasCuratorNotes, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
import { cloneSurfaceTexture, getSurfaceTexture } from '../utils/galleryProceduralTextures'; import { cloneSurfaceTexture, getSurfaceTexture } from '../utils/galleryProceduralTextures';
import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles'; import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles';
import { useTexturedMaterial } from '../hooks/useTexturedMaterial'; import { useTexturedMaterial } from '../hooks/useTexturedMaterial';
@@ -38,7 +38,7 @@ const GalleryTextureLoadContext = createContext<{
* from unmounting the whole 3D scene and leaving a dark window. * from unmounting the whole 3D scene and leaving a dark window.
*/ */
class SceneErrorBoundary extends Component< class SceneErrorBoundary extends Component<
{ children: ReactNode; fallback?: ReactNode }, { children: ReactNode; fallback?: ReactNode; onError?: () => void },
{ hasError: boolean } { hasError: boolean }
> { > {
state = { hasError: false }; state = { hasError: false };
@@ -49,6 +49,7 @@ class SceneErrorBoundary extends Component<
componentDidCatch(error: unknown) { componentDidCatch(error: unknown) {
console.warn('Gallery scene subtree failed, continuing without it.', error); console.warn('Gallery scene subtree failed, continuing without it.', error);
this.props.onError?.();
} }
render() { render() {
@@ -97,6 +98,9 @@ const REVIEWED_MAT_BORDER = FRAME_MAT_BORDER * 2;
const REVIEWED_RAIL = FRAME_RAIL * 2; const REVIEWED_RAIL = FRAME_RAIL * 2;
const EYE_HEIGHT = 1.65; const EYE_HEIGHT = 1.65;
const FRAME_GAP = 0.32; const FRAME_GAP = 0.32;
const SHADER_WARM_TIMEOUT_MS = 4000;
/** Per-image fetch/decode deadline so a stuck request cannot hold the counter forever. */
const TEXTURE_LOAD_TIMEOUT_MS = 10000;
const MIN_FRAME_W = 0.45; const MIN_FRAME_W = 0.45;
const MAX_FRAME_W = 1.05; const MAX_FRAME_W = 1.05;
const MAX_FRAME_H = 1.35; const MAX_FRAME_H = 1.35;
@@ -294,17 +298,26 @@ function minSpanForWall(paintings: Painting[], maxRows: number = paintings.lengt
} }
/** /**
* Visit order along the side walls: first half on the left (starting at the * U-shaped visit order:
* entrance, left of the opening view), second half on the right (ending at the * left wall (first, near entrance) → far/end wall → right wall (last, near entrance).
* entrance). Back wall stays empty so first/last always sit on left/right. * Fewer than 3 works keep the old left/right split so first stays left and last stays right.
* The far wall is the solid wall ahead when entering (code side `'back'`).
*/ */
function distributePaintingsAcrossWalls(paintings: Painting[]) { function distributePaintingsAcrossWalls(paintings: Painting[]) {
const ordered = [...paintings].sort(comparePaintingsChronological); const ordered = [...paintings].sort(comparePaintingsChronological);
const mid = Math.ceil(ordered.length / 2); const n = ordered.length;
if (n < 3) {
const mid = Math.ceil(n / 2);
return [[] as Painting[], ordered.slice(0, mid), ordered.slice(mid)];
}
const q = Math.floor(n / 3);
const r = n % 3;
const leftCount = q + (r > 0 ? 1 : 0);
const backCount = q + (r > 1 ? 1 : 0);
return [ return [
[] as Painting[], ordered.slice(leftCount, leftCount + backCount),
ordered.slice(0, mid), ordered.slice(0, leftCount),
ordered.slice(mid), ordered.slice(leftCount + backCount),
]; ];
} }
@@ -390,18 +403,19 @@ function layoutWallSlots(
function buildHallLayout(paintings: Painting[], periods: ArtistPeriod[]): HallLayout { function buildHallLayout(paintings: Painting[], periods: ArtistPeriod[]): HallLayout {
const walls: WallSide[] = ['back', 'left', 'right']; const walls: WallSide[] = ['back', 'left', 'right'];
const wallPaintings = distributePaintingsAcrossWalls(paintings); const wallPaintings = distributePaintingsAcrossWalls(paintings);
const [backPaintings, leftPaintings, rightPaintings] = wallPaintings;
let width = minSpanForWall(wallPaintings[0], MAX_WALL_ROWS); let width = minSpanForWall(backPaintings, MAX_WALL_ROWS);
let depth = Math.max( let depth = Math.max(
minSpanForWall(wallPaintings[1], MAX_WALL_ROWS), minSpanForWall(leftPaintings, MAX_WALL_ROWS),
minSpanForWall(wallPaintings[2], MAX_WALL_ROWS) minSpanForWall(rightPaintings, MAX_WALL_ROWS)
); );
for (let i = 0; i < 24; i++) { for (let i = 0; i < 24; i++) {
const nextWidth = minSpanForWall(wallPaintings[0], MAX_WALL_ROWS); const nextWidth = minSpanForWall(backPaintings, MAX_WALL_ROWS);
const nextDepth = Math.max( const nextDepth = Math.max(
minSpanForWall(wallPaintings[1], MAX_WALL_ROWS), minSpanForWall(leftPaintings, MAX_WALL_ROWS),
minSpanForWall(wallPaintings[2], MAX_WALL_ROWS) minSpanForWall(rightPaintings, MAX_WALL_ROWS)
); );
if (nextWidth === width && nextDepth === depth) break; if (nextWidth === width && nextDepth === depth) break;
width = nextWidth; width = nextWidth;
@@ -568,6 +582,7 @@ function usePaintingTexture(url: string | null) {
const [texture, setTexture] = useState<THREE.Texture | null>(null); const [texture, setTexture] = useState<THREE.Texture | null>(null);
const [failed, setFailed] = useState(!url); const [failed, setFailed] = useState(!url);
const textureLoad = useContext(GalleryTextureLoadContext); const textureLoad = useContext(GalleryTextureLoadContext);
const { gl } = useThree();
useEffect(() => { useEffect(() => {
if (!url) { if (!url) {
@@ -590,27 +605,44 @@ function usePaintingTexture(url: string | null) {
}; };
textureLoad?.begin(); textureLoad?.begin();
const loadTimeout = window.setTimeout(() => {
if (settled || disposed) return;
setFailed(true);
finish();
}, TEXTURE_LOAD_TIMEOUT_MS);
loader.load( loader.load(
url, url,
(tex) => { (tex) => {
finish(); window.clearTimeout(loadTimeout);
if (disposed) { if (disposed) {
tex.dispose(); tex.dispose();
finish();
return; return;
} }
const img = tex.image as HTMLImageElement | undefined; const img = tex.image as HTMLImageElement | undefined;
if (!img || img.width < 4 || img.height < 4) { if (!img || img.width < 4 || img.height < 4) {
tex.dispose(); tex.dispose();
setFailed(true); setFailed(true);
finish();
return; return;
} }
loaded = tex; loaded = tex;
tex.colorSpace = THREE.SRGBColorSpace; tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 4; tex.anisotropy = 4;
setTexture(tex); // Release the hall overlay counter before GPU upload — large halls
// (50+ works) otherwise stay on "Loading paintings…" for a long time.
finish();
if (!disposed) setTexture(tex);
try {
if (!disposed) gl.initTexture(tex);
} catch {
// Upload can fail after context loss; texture still usable later.
}
}, },
undefined, undefined,
() => { () => {
window.clearTimeout(loadTimeout);
finish(); finish();
if (!disposed) setFailed(true); if (!disposed) setFailed(true);
} }
@@ -618,11 +650,12 @@ function usePaintingTexture(url: string | null) {
return () => { return () => {
disposed = true; disposed = true;
window.clearTimeout(loadTimeout);
finish(); finish();
loaded?.dispose(); loaded?.dispose();
setTexture(null); setTexture(null);
}; };
}, [url, textureLoad]); }, [url, textureLoad, gl]);
return { texture, failed }; return { texture, failed };
} }
@@ -644,11 +677,11 @@ function InfluencePictureLamp({
return ( return (
<group position={[0, mountY, frameDepth * 0.55]} rotation={[Math.PI, 0, 0]} scale={scale}> <group position={[0, mountY, frameDepth * 0.55]} rotation={[Math.PI, 0, 0]} scale={scale}>
<mesh position={[0, 0.04, -0.05]} castShadow renderOrder={30}> <mesh position={[0, 0.04, -0.05]} renderOrder={30}>
<boxGeometry args={[0.11, 0.05, 0.05]} /> <boxGeometry args={[0.11, 0.05, 0.05]} />
<meshStandardMaterial color="#2f2f2f" metalness={0.9} roughness={0.2} /> <meshStandardMaterial color="#2f2f2f" metalness={0.9} roughness={0.2} />
</mesh> </mesh>
<mesh position={[0, 0.01, -0.01]} rotation={[0.25, 0, 0]} castShadow renderOrder={30}> <mesh position={[0, 0.01, -0.01]} rotation={[0.25, 0, 0]} renderOrder={30}>
<boxGeometry args={[0.04, 0.08, 0.035]} /> <boxGeometry args={[0.04, 0.08, 0.035]} />
<meshStandardMaterial color="#555555" metalness={0.8} roughness={0.3} /> <meshStandardMaterial color="#555555" metalness={0.8} roughness={0.3} />
</mesh> </mesh>
@@ -687,13 +720,67 @@ function InfluencePictureLamp({
intensity={2.2 * glow} intensity={2.2 * glow}
distance={5} distance={5}
color="#fff0c8" color="#fff0c8"
castShadow={false}
/> />
</group> </group>
</group> </group>
); );
} }
function CuratorNotesPlate({
frameWidth,
frameHeight,
matBorder,
rail,
frameDepth,
faceZ,
highlighted,
}: {
frameWidth: number;
frameHeight: number;
matBorder: number;
rail: number;
frameDepth: number;
faceZ: number;
highlighted: boolean;
}) {
const plateW = Math.min(0.28, Math.max(0.16, frameWidth * 0.42));
const plateH = 0.038;
const plateD = 0.012;
const y = -frameHeight / 2 - matBorder - rail - plateH / 2 - 0.028;
const z = frameDepth + faceZ + 0.01;
const brass = highlighted ? '#e8c76a' : '#d4af37';
const rim = highlighted ? '#a07828' : '#8a6820';
return (
<group position={[0, y, z]}>
{/* Slightly darker rim so the plate reads as a cast metal plaque */}
<mesh position={[0, 0, -0.001]} renderOrder={28}>
<boxGeometry args={[plateW + 0.012, plateH + 0.01, plateD]} />
<meshStandardMaterial color={rim} metalness={0.85} roughness={0.28} />
</mesh>
<mesh renderOrder={29}>
<boxGeometry args={[plateW, plateH, plateD]} />
<meshStandardMaterial
color={brass}
metalness={0.9}
roughness={0.22}
emissive={highlighted ? '#6a5010' : '#3a2a08'}
emissiveIntensity={highlighted ? 0.35 : 0.12}
/>
</mesh>
{/* Soft engraved center band */}
<mesh position={[0, 0, plateD / 2 + 0.001]} renderOrder={30}>
<planeGeometry args={[plateW * 0.72, plateH * 0.28]} />
<meshStandardMaterial
color={highlighted ? '#b8943a' : '#9a7828'}
metalness={0.7}
roughness={0.4}
/>
</mesh>
</group>
);
}
function PaintingFrame({ function PaintingFrame({
painting, painting,
position, position,
@@ -726,6 +813,7 @@ function PaintingFrame({
const showImage = hasImage && !failed && !!texture; const showImage = hasImage && !failed && !!texture;
const showCanvas = !showImage; const showCanvas = !showImage;
const hasInfluenceLinks = paintingHasInfluenceLinks(painting); const hasInfluenceLinks = paintingHasInfluenceLinks(painting);
const hasCuratorNotes = paintingHasCuratorNotes(painting);
const finish = frameFinish(reviewed, hovered); const finish = frameFinish(reviewed, hovered);
const faceZ = FRAME_FACE_Z + (wallSide === 'back' ? 0.012 : 0); const faceZ = FRAME_FACE_Z + (wallSide === 'back' ? 0.012 : 0);
@@ -737,6 +825,9 @@ function PaintingFrame({
} }
}, [texture, showImage]); }, [texture, showImage]);
const captionY =
-height / 2 - matBorder - rail - (hasCuratorNotes ? 0.18 : 0.1);
return ( return (
<group position={position} rotation={[0, rotationY, 0]}> <group position={position} rotation={[0, rotationY, 0]}>
<spotLight <spotLight
@@ -750,7 +841,6 @@ function PaintingFrame({
<mesh <mesh
position={[0, 0, frameDepth / 2]} position={[0, 0, frameDepth / 2]}
castShadow
renderOrder={1} renderOrder={1}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -812,9 +902,21 @@ function PaintingFrame({
/> />
)} )}
{hasCuratorNotes && (
<CuratorNotesPlate
frameWidth={width}
frameHeight={height}
matBorder={matBorder}
rail={rail}
frameDepth={frameDepth}
faceZ={faceZ}
highlighted={hovered}
/>
)}
{caption && ( {caption && (
<Text <Text
position={[0, -height / 2 - matBorder - rail - 0.1, frameDepth + faceZ + 0.02]} position={[0, captionY, frameDepth + faceZ + 0.02]}
fontSize={0.085} fontSize={0.085}
maxWidth={Math.max(width + matBorder * 2, 0.55)} maxWidth={Math.max(width + matBorder * 2, 0.55)}
color="#4a3828" color="#4a3828"
@@ -842,7 +944,7 @@ function GalleryWall({
}) { }) {
const [w, h] = size; const [w, h] = size;
return ( return (
<mesh position={position} rotation={rotation} receiveShadow renderOrder={0}> <mesh position={position} rotation={rotation} renderOrder={0}>
<boxGeometry args={[w, h, WALL_THICKNESS]} /> <boxGeometry args={[w, h, WALL_THICKNESS]} />
<meshStandardMaterial color={color} roughness={0.92} /> <meshStandardMaterial color={color} roughness={0.92} />
</mesh> </mesh>
@@ -901,7 +1003,7 @@ function ExitPortal({
const panelInset = 0.06; const panelInset = 0.06;
return ( return (
<group key={side} position={[x, leafY, faceZ]}> <group key={side} position={[x, leafY, faceZ]}>
<mesh castShadow> <mesh>
<boxGeometry args={[leafW, leafH, 0.055]} /> <boxGeometry args={[leafW, leafH, 0.055]} />
<meshStandardMaterial color={woodMid} roughness={0.62} metalness={0.04} /> <meshStandardMaterial color={woodMid} roughness={0.62} metalness={0.04} />
</mesh> </mesh>
@@ -955,7 +1057,7 @@ function ExitPortal({
</mesh> </mesh>
{/* Marble threshold */} {/* Marble threshold */}
<mesh position={[0, 0.025, faceZ + 0.04]} receiveShadow> <mesh position={[0, 0.025, faceZ + 0.04]}>
<boxGeometry args={[frameW + 0.12, 0.05, 0.14]} /> <boxGeometry args={[frameW + 0.12, 0.05, 0.14]} />
<meshStandardMaterial color="#e8e4dc" roughness={0.28} metalness={0.08} /> <meshStandardMaterial color="#e8e4dc" roughness={0.28} metalness={0.08} />
</mesh> </mesh>
@@ -982,14 +1084,14 @@ function ExitPortal({
{/* Side jambs */} {/* Side jambs */}
{([-1, 1] as const).map((sign) => ( {([-1, 1] as const).map((sign) => (
<mesh key={sign} position={[sign * (openingW / 2 + jamb / 2), frameFullH / 2, faceZ + 0.01]} castShadow> <mesh key={sign} position={[sign * (openingW / 2 + jamb / 2), frameFullH / 2, faceZ + 0.01]}>
<boxGeometry args={[jamb, frameFullH, 0.1]} /> <boxGeometry args={[jamb, frameFullH, 0.1]} />
<meshStandardMaterial color={woodDark} roughness={0.55} metalness={0.06} /> <meshStandardMaterial color={woodDark} roughness={0.55} metalness={0.06} />
</mesh> </mesh>
))} ))}
{/* Header lintel */} {/* Header lintel */}
<mesh position={[0, openingH + header / 2, faceZ + 0.01]} castShadow> <mesh position={[0, openingH + header / 2, faceZ + 0.01]}>
<boxGeometry args={[frameW, header, 0.1]} /> <boxGeometry args={[frameW, header, 0.1]} />
<meshStandardMaterial color={woodMid} roughness={0.58} metalness={0.05} /> <meshStandardMaterial color={woodMid} roughness={0.58} metalness={0.05} />
</mesh> </mesh>
@@ -1020,13 +1122,12 @@ function ExitPortal({
<mesh <mesh
key={`sur-${sign}`} key={`sur-${sign}`}
position={[sign * (openingW / 2 + jamb + surround / 2), frameFullH / 2, faceZ - 0.01]} position={[sign * (openingW / 2 + jamb + surround / 2), frameFullH / 2, faceZ - 0.01]}
castShadow
> >
<boxGeometry args={[surround, frameFullH + 0.08, 0.08]} /> <boxGeometry args={[surround, frameFullH + 0.08, 0.08]} />
<meshStandardMaterial color={stone} roughness={0.88} /> <meshStandardMaterial color={stone} roughness={0.88} />
</mesh> </mesh>
))} ))}
<mesh position={[0, frameFullH + 0.04, faceZ - 0.01]} castShadow> <mesh position={[0, frameFullH + 0.04, faceZ - 0.01]}>
<boxGeometry args={[frameW + surround * 2, 0.12, 0.08]} /> <boxGeometry args={[frameW + surround * 2, 0.12, 0.08]} />
<meshStandardMaterial color={stoneDark} roughness={0.82} metalness={0.08} /> <meshStandardMaterial color={stoneDark} roughness={0.82} metalness={0.08} />
</mesh> </mesh>
@@ -1139,7 +1240,7 @@ function TexturedWall({
const [w, h, d] = size; const [w, h, d] = size;
const mat = useTexturedMaterial(kind, tint, Math.max(w, d), h); const mat = useTexturedMaterial(kind, tint, Math.max(w, d), h);
return ( return (
<mesh position={position} rotation={rotation} receiveShadow material={mat} renderOrder={0}> <mesh position={position} rotation={rotation} material={mat} renderOrder={0}>
<boxGeometry args={size} /> <boxGeometry args={size} />
</mesh> </mesh>
); );
@@ -1158,7 +1259,7 @@ function TexturedCeiling({
}) { }) {
const mat = useTexturedMaterial(kind, tint, width + 0.4, depth + 0.4); const mat = useTexturedMaterial(kind, tint, width + 0.4, depth + 0.4);
return ( return (
<mesh rotation={[Math.PI / 2, 0, 0]} position={[0, WALL_HEIGHT, 0]} receiveShadow material={mat}> <mesh rotation={[Math.PI / 2, 0, 0]} position={[0, WALL_HEIGHT, 0]} material={mat}>
<planeGeometry args={[width + 0.4, depth + 0.4]} /> <planeGeometry args={[width + 0.4, depth + 0.4]} />
</mesh> </mesh>
); );
@@ -1190,7 +1291,7 @@ function GalleryFloor({
); );
return ( return (
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.002, 0]} receiveShadow> <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.002, 0]}>
<planeGeometry args={[floorW, floorD]} /> <planeGeometry args={[floorW, floorD]} />
<meshStandardMaterial <meshStandardMaterial
map={texture.map} map={texture.map}
@@ -1208,7 +1309,7 @@ function GalleryFloor({
function ParquetFloor({ width, depth }: { width: number; depth: number }) { function ParquetFloor({ width, depth }: { width: number; depth: number }) {
const mat = useTexturedMaterial('parquet-herringbone', '#c8a882', width + 0.4, depth + 0.4); const mat = useTexturedMaterial('parquet-herringbone', '#c8a882', width + 0.4, depth + 0.4);
return ( return (
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.002, 0]} receiveShadow material={mat}> <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.002, 0]} material={mat}>
<planeGeometry args={[width + 0.4, depth + 0.4]} /> <planeGeometry args={[width + 0.4, depth + 0.4]} />
</mesh> </mesh>
); );
@@ -1315,15 +1416,15 @@ function ArtistHall({
</> </>
) : ( ) : (
<> <>
<mesh position={[0, WALL_HEIGHT / 2, -halfD]} receiveShadow renderOrder={0}> <mesh position={[0, WALL_HEIGHT / 2, -halfD]} renderOrder={0}>
<boxGeometry args={[width, WALL_HEIGHT, WALL_THICKNESS]} /> <boxGeometry args={[width, WALL_HEIGHT, WALL_THICKNESS]} />
{wallMaterial(walls.main)} {wallMaterial(walls.main)}
</mesh> </mesh>
<mesh position={[-halfW, WALL_HEIGHT / 2, 0]} rotation={[0, Math.PI / 2, 0]} receiveShadow renderOrder={0}> <mesh position={[-halfW, WALL_HEIGHT / 2, 0]} rotation={[0, Math.PI / 2, 0]} renderOrder={0}>
<boxGeometry args={[depth, WALL_HEIGHT, WALL_THICKNESS]} /> <boxGeometry args={[depth, WALL_HEIGHT, WALL_THICKNESS]} />
{wallMaterial(walls.side)} {wallMaterial(walls.side)}
</mesh> </mesh>
<mesh position={[halfW, WALL_HEIGHT / 2, 0]} rotation={[0, Math.PI / 2, 0]} receiveShadow renderOrder={0}> <mesh position={[halfW, WALL_HEIGHT / 2, 0]} rotation={[0, Math.PI / 2, 0]} renderOrder={0}>
<boxGeometry args={[depth, WALL_HEIGHT, WALL_THICKNESS]} /> <boxGeometry args={[depth, WALL_HEIGHT, WALL_THICKNESS]} />
{wallMaterial(walls.side)} {wallMaterial(walls.side)}
</mesh> </mesh>
@@ -1526,6 +1627,65 @@ function FrameloopSync({ active }: { active: boolean }) {
return null; return null;
} }
/** Fires onReady once Environment (inside Suspense) has resolved and mounted. */
function EnvironmentGate({
onReady,
children,
}: {
onReady: () => void;
children: ReactNode;
}) {
useEffect(() => {
onReady();
}, [onReady]);
return <>{children}</>;
}
/** Compile all scene materials (incl. culled doors) before dismissing the loading overlay. */
function WarmHallGpu({
enabled,
onDone,
}: {
enabled: boolean;
onDone: () => void;
}) {
const { gl, scene, camera } = useThree();
useEffect(() => {
if (!enabled) return;
let cancelled = false;
let settled = false;
const done = () => {
if (cancelled || settled) return;
settled = true;
onDone();
};
const run = async () => {
try {
const compile = typeof gl.compileAsync === 'function'
? gl.compileAsync(scene, camera)
: Promise.resolve(gl.compile(scene, camera));
await Promise.race([
compile,
new Promise<void>((resolve) => {
window.setTimeout(resolve, SHADER_WARM_TIMEOUT_MS);
}),
]);
} catch {
// Still release the overlay if compile fails — better than hanging forever.
}
done();
};
void run();
return () => {
cancelled = true;
};
}, [enabled, gl, scene, camera, onDone]);
return null;
}
function CameraController({ function CameraController({
position, position,
target, target,
@@ -1720,6 +1880,8 @@ export default function VirtualGallery(props: Props) {
const [isLooking, setIsLooking] = useState(false); const [isLooking, setIsLooking] = useState(false);
const [texturesPending, setTexturesPending] = useState(0); const [texturesPending, setTexturesPending] = useState(0);
const [canvasReady, setCanvasReady] = useState(false); const [canvasReady, setCanvasReady] = useState(false);
const [envReady, setEnvReady] = useState(false);
const [shadersWarmed, setShadersWarmed] = useState(false);
const [glEpoch, setGlEpoch] = useState(0); const [glEpoch, setGlEpoch] = useState(0);
const [glLost, setGlLost] = useState(false); const [glLost, setGlLost] = useState(false);
@@ -1731,6 +1893,14 @@ export default function VirtualGallery(props: Props) {
[] []
); );
const handleEnvReady = useCallback(() => {
setEnvReady(true);
}, []);
const handleShadersWarmed = useCallback(() => {
setShadersWarmed(true);
}, []);
const handleCanvasCreated = useCallback((state: { gl: THREE.WebGLRenderer }) => { const handleCanvasCreated = useCallback((state: { gl: THREE.WebGLRenderer }) => {
const canvas = state.gl.domElement; const canvas = state.gl.domElement;
// A freshly created canvas has a healthy context, so clear any lingering // A freshly created canvas has a healthy context, so clear any lingering
@@ -1797,6 +1967,30 @@ export default function VirtualGallery(props: Props) {
setHallIndex(0); setHallIndex(0);
}, [hallKey]); }, [hallKey]);
useEffect(() => {
setEnvReady(false);
setShadersWarmed(false);
setTexturesPending(0);
}, [hallKey, glEpoch]);
useEffect(() => {
setShadersWarmed(false);
}, [hallIndex]);
// HDR Environment can hang or fail (CDN / Suspense). Never block the hall forever.
useEffect(() => {
if (envReady) return;
const t = window.setTimeout(() => setEnvReady(true), 5000);
return () => window.clearTimeout(t);
}, [envReady, hallKey, glEpoch]);
// If shader warm-up never settles, dismiss the overlay anyway.
useEffect(() => {
if (shadersWarmed || !canvasReady || !envReady) return;
const t = window.setTimeout(() => setShadersWarmed(true), SHADER_WARM_TIMEOUT_MS + 1000);
return () => window.clearTimeout(t);
}, [shadersWarmed, canvasReady, envReady, hallKey, glEpoch, hallIndex]);
const layout = useMemo(() => { const layout = useMemo(() => {
if (isWingedHall && movementHalls.length > 0) { if (isWingedHall && movementHalls.length > 0) {
return movementHalls[Math.min(hallIndex, movementHalls.length - 1)]; return movementHalls[Math.min(hallIndex, movementHalls.length - 1)];
@@ -1804,13 +1998,17 @@ export default function VirtualGallery(props: Props) {
return buildHallLayout(paintings, periods); return buildHallLayout(paintings, periods);
}, [isWingedHall, movementHalls, hallIndex, paintings, periods]); }, [isWingedHall, movementHalls, hallIndex, paintings, periods]);
const gallerySceneLoading = active && !glLost && (!canvasReady || texturesPending > 0); // Do not block the hall on every painting texture — large artists (e.g. Duccio, 50+)
// otherwise sit on "Loading paintings…" for a long time. Textures keep loading after.
const gallerySceneLoading =
active &&
!glLost &&
(!canvasReady || !envReady || !shadersWarmed);
const galleryLoadingMessage = !canvasReady const galleryLoadingMessage =
? 'Loading gallery…' canvasReady && texturesPending > 0 ? 'Loading paintings…' : 'Loading gallery…';
: texturesPending > 0
? 'Loading paintings…' const warmGpuEnabled = canvasReady && envReady && !shadersWarmed;
: 'Loading gallery…';
const computedWindows = useMemo(() => { const computedWindows = useMemo(() => {
if (!isMovement || !interiorStyle || !('hallIndex' in layout)) return undefined; if (!isMovement || !interiorStyle || !('hallIndex' in layout)) return undefined;
@@ -2107,7 +2305,6 @@ export default function VirtualGallery(props: Props) {
)} )}
<Canvas <Canvas
key={`${hallKey}-${glEpoch}`} key={`${hallKey}-${glEpoch}`}
shadows
frameloop={active ? 'always' : 'never'} frameloop={active ? 'always' : 'never'}
gl={{ preserveDrawingBuffer: true, powerPreference: 'high-performance' }} gl={{ preserveDrawingBuffer: true, powerPreference: 'high-performance' }}
camera={{ fov: 58, position: [0, EYE_HEIGHT, 2], near: 0.1, far: 80 }} camera={{ fov: 58, position: [0, EYE_HEIGHT, 2], near: 0.1, far: 80 }}
@@ -2121,15 +2318,19 @@ export default function VirtualGallery(props: Props) {
position={interiorStyle ? [2, 10, -4] : [3, 9, 4]} position={interiorStyle ? [2, 10, -4] : [3, 9, 4]}
intensity={interiorStyle ? 0.85 : 0.65} intensity={interiorStyle ? 0.85 : 0.65}
color={interiorStyle?.sunLight ?? '#fff8f0'} color={interiorStyle?.sunLight ?? '#fff8f0'}
castShadow
shadow-mapSize={[1024, 1024]}
/> />
<SceneErrorBoundary fallback={null}> <SceneErrorBoundary
key={`env-${hallKey}-${glEpoch}`}
fallback={null}
onError={handleEnvReady}
>
<Suspense fallback={null}> <Suspense fallback={null}>
<Environment <EnvironmentGate onReady={handleEnvReady}>
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'} <Environment
environmentIntensity={interiorStyle ? 0.35 : 0.15} preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
/> environmentIntensity={interiorStyle ? 0.35 : 0.15}
/>
</EnvironmentGate>
</Suspense> </Suspense>
</SceneErrorBoundary> </SceneErrorBoundary>
<GalleryTextureLoadContext.Provider value={textureLoad}> <GalleryTextureLoadContext.Provider value={textureLoad}>
@@ -2151,6 +2352,7 @@ export default function VirtualGallery(props: Props) {
nearPassage={nearPassage} nearPassage={nearPassage}
/> />
</GalleryTextureLoadContext.Provider> </GalleryTextureLoadContext.Provider>
<WarmHallGpu enabled={warmGpuEnabled} onDone={handleShadersWarmed} />
<CameraController position={camPos} target={camTarget} /> <CameraController position={camPos} target={camTarget} />
</Canvas> </Canvas>
</div> </div>
+8 -1
View File
@@ -11,5 +11,12 @@
"tourNotes": "Tour notes", "tourNotes": "Tour notes",
"tourNotesFor": "Tour notes · {{title}}", "tourNotesFor": "Tour notes · {{title}}",
"tourNotesEmpty": "No notes for this stop.", "tourNotesEmpty": "No notes for this stop.",
"tourStopPosition": "Stop {{current}} of {{total}}" "tourStopPosition": "Stop {{current}} of {{total}}",
"curatorNotes": "Curator notes",
"curatorNotesEmpty": "No curator notes yet.",
"curatorNotesEdit": "Edit",
"curatorNotesSave": "Save",
"curatorNotesSaving": "Saving…",
"curatorNotesCancel": "Cancel",
"curatorNotesSaveFailed": "Could not save curator notes."
} }
+8 -1
View File
@@ -11,5 +11,12 @@
"tourNotes": "Текст экскурсии", "tourNotes": "Текст экскурсии",
"tourNotesFor": "Экскурсия · {{title}}", "tourNotesFor": "Экскурсия · {{title}}",
"tourNotesEmpty": "Для этой остановки нет текста.", "tourNotesEmpty": "Для этой остановки нет текста.",
"tourStopPosition": "Остановка {{current}} из {{total}}" "tourStopPosition": "Остановка {{current}} из {{total}}",
"curatorNotes": "Заметки куратора",
"curatorNotesEmpty": "Заметок куратора пока нет.",
"curatorNotesEdit": "Изменить",
"curatorNotesSave": "Сохранить",
"curatorNotesSaving": "Сохранение…",
"curatorNotesCancel": "Отмена",
"curatorNotesSaveFailed": "Не удалось сохранить заметки куратора."
} }
+54
View File
@@ -444,6 +444,58 @@ export default function HomePage() {
[] []
); );
const handleCuratorNotesUpdated = useCallback((paintingId: number, curatorNotes: string) => {
const patch: Partial<Painting> = { curator_notes: curatorNotes };
setView((current) => {
if (current.type !== 'painting' || current.paintingId !== paintingId) return current;
let returnTo = current.returnTo;
if (returnTo.type === 'gallery') {
returnTo = {
...returnTo,
data: patchPaintingInArtistDetail(returnTo.data, paintingId, patch),
};
}
if (returnTo.type === 'movement-gallery') {
returnTo = {
...returnTo,
data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch),
};
}
if (returnTo.type === 'tour-gallery') {
returnTo = {
...returnTo,
data: patchPaintingInTourDetail(returnTo.data, paintingId, patch),
};
}
return {
...current,
data: {
...current.data,
painting: { ...current.data.painting, ...patch },
},
returnTo,
};
});
setDetailArtistPaintings((list) =>
list.map((p) => (p.id === paintingId ? { ...p, ...patch } : p))
);
setGallerySession((session) => {
if (session?.kind === 'artist') {
return { ...session, data: patchPaintingInArtistDetail(session.data, paintingId, patch) };
}
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;
});
}, []);
const applyArtistPatch = useCallback((artistId: number, patch: Partial<Artist>) => { const applyArtistPatch = useCallback((artistId: number, patch: Partial<Artist>) => {
setArtists((list) => list.map((a) => (a.id === artistId ? { ...a, ...patch } : a))); setArtists((list) => list.map((a) => (a.id === artistId ? { ...a, ...patch } : a)));
@@ -900,11 +952,13 @@ export default function HomePage() {
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo: view }); setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo: view });
}} }}
onInfluenceArtistClick={handleArtistClick} onInfluenceArtistClick={handleArtistClick}
isCurator={isCurator}
debugMode={effectiveDebugMode} debugMode={effectiveDebugMode}
debugShowMore={debugShowMore && isCurator} debugShowMore={debugShowMore && isCurator}
onPaintingImageFixed={handlePaintingImageFixed} onPaintingImageFixed={handlePaintingImageFixed}
onPaintingCheckupFlagsUpdated={handlePaintingCheckupFlagsUpdated} onPaintingCheckupFlagsUpdated={handlePaintingCheckupFlagsUpdated}
onPaintingRemoved={handlePaintingRemoved} onPaintingRemoved={handlePaintingRemoved}
onCuratorNotesUpdated={handleCuratorNotesUpdated}
/> />
</div> </div>
)} )}
+1
View File
@@ -60,6 +60,7 @@ export interface Painting {
year: number; year: number;
year_end?: number; year_end?: number;
description: string; description: string;
curator_notes?: string;
image_path: string | null; image_path: string | null;
thumbnail_path?: string | null; thumbnail_path?: string | null;
image_cache_key?: number | null; image_cache_key?: number | null;
+61 -7
View File
@@ -43,6 +43,9 @@ const MAX_FRAME_H = 1.35;
const MIN_HALL_SIZE = 10; const MIN_HALL_SIZE = 10;
const MIN_HALL_WIDTH = 11; const MIN_HALL_WIDTH = 11;
const WALL_PADDING = 1.4; const WALL_PADDING = 1.4;
/** Exit opening on the far wall — paintings hang on the flanking panels only. */
const DOOR_WIDTH = 2.4;
const DOOR_CLEARANCE = DOOR_WIDTH + 0.55;
const FRAME_MAT_BORDER = 0.1; const FRAME_MAT_BORDER = 0.1;
const FRAME_RAIL = 0.08; const FRAME_RAIL = 0.08;
@@ -110,15 +113,28 @@ export function splitPaintingsIntoMovementHalls(paintings: Painting[]): Painting
} }
/** /**
* First half → left wall, second half → right. * U-shaped visit order: left → far/end wall → right.
* Callers pass paintings already in visit order; first work hangs near the * Callers pass paintings already in visit order; first work hangs near the
* entrance on the left, last work near the entrance on the right. * entrance on the left, last work near the entrance on the right.
*/ */
function distributeToSideWalls(paintings: Painting[]) { function distributeAcrossWalls(paintings: Painting[]) {
const mid = Math.ceil(paintings.length / 2); const n = paintings.length;
if (n < 3) {
const mid = Math.ceil(n / 2);
return {
back: [] as Painting[],
left: paintings.slice(0, mid),
right: paintings.slice(mid),
};
}
const q = Math.floor(n / 3);
const r = n % 3;
const leftCount = q + (r > 0 ? 1 : 0);
const backCount = q + (r > 1 ? 1 : 0);
return { return {
left: paintings.slice(0, mid), left: paintings.slice(0, leftCount),
right: paintings.slice(mid), back: paintings.slice(leftCount, leftCount + backCount),
right: paintings.slice(leftCount + backCount),
}; };
} }
@@ -149,21 +165,59 @@ function layoutSideSlots(
}); });
} }
/** Far wall ahead of the entrance — split across door flanks (exit sits in the center). */
function layoutBackSlots(
paintings: Painting[],
width: number,
halfD: number,
inset: number
): FrameSlot[] {
if (paintings.length === 0) return [];
const flankSpan = Math.max(MIN_FRAME_W + WALL_PADDING, (width - DOOR_CLEARANCE) / 2);
const mid = Math.ceil(paintings.length / 2);
const leftFlank = paintings.slice(0, mid);
const rightFlank = paintings.slice(mid);
const y = EYE_HEIGHT;
const z = -halfD + inset + WALL_STANDOFF;
const leftCenterX = -DOOR_CLEARANCE / 2 - flankSpan / 2;
const rightCenterX = DOOR_CLEARANCE / 2 + flankSpan / 2;
const mapFlank = (group: Painting[], centerX: number): FrameSlot[] => {
if (group.length === 0) return [];
const { slots: rowSlots } = layoutRow(group, flankSpan);
return rowSlots.map((s) => ({
maxW: s.maxW,
maxH: s.maxH,
rotationY: 0,
side: 'back' as const,
position: [centerX + s.offset, y, z] as [number, number, number],
}));
};
return [...mapFlank(leftFlank, leftCenterX), ...mapFlank(rightFlank, rightCenterX)];
}
export function buildMovementHallLayout( export function buildMovementHallLayout(
paintings: Painting[], paintings: Painting[],
hallIndex: number, hallIndex: number,
hallCount: number hallCount: number
): MovementHallLayout { ): MovementHallLayout {
const { left, right } = distributeToSideWalls(paintings); const { left, back, right } = distributeAcrossWalls(paintings);
const leftSpan = layoutRow(left, MIN_HALL_SIZE); const leftSpan = layoutRow(left, MIN_HALL_SIZE);
const rightSpan = layoutRow(right, MIN_HALL_SIZE); const rightSpan = layoutRow(right, MIN_HALL_SIZE);
const depth = Math.max(MIN_HALL_SIZE, leftSpan.spanNeeded, rightSpan.spanNeeded); const depth = Math.max(MIN_HALL_SIZE, leftSpan.spanNeeded, rightSpan.spanNeeded);
const width = MIN_HALL_WIDTH; const width = MIN_HALL_WIDTH;
const halfW = width / 2; const halfW = width / 2;
const halfD = depth / 2;
const inset = WALL_THICKNESS / 2 + MOUNT_OFFSET; const inset = WALL_THICKNESS / 2 + MOUNT_OFFSET;
const segments: WallSegment[] = [ const segments: WallSegment[] = [
{ side: 'back', label: '', paintings: [], slots: [] }, {
side: 'back',
label: back.length > 0 ? `Wing ${hallIndex + 1} · End wall` : '',
paintings: back,
slots: layoutBackSlots(back, width, halfD, inset),
},
{ {
side: 'left', side: 'left',
label: left.length > 0 ? `Wing ${hallIndex + 1} · Left wall` : '', label: left.length > 0 ? `Wing ${hallIndex + 1} · Left wall` : '',
+7
View File
@@ -36,3 +36,10 @@ export function paintingHasInfluenceLinks(
const flag = painting.has_influence_links; const flag = painting.has_influence_links;
return flag === true || flag === 't' || flag === 'true' || flag === 1; return flag === true || flag === 't' || flag === 'true' || flag === 1;
} }
/** True when the painting has public curator notes. */
export function paintingHasCuratorNotes(
painting: Pick<Painting, 'curator_notes'>
): boolean {
return Boolean(painting.curator_notes?.trim());
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 501 KiB

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 410 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 941 KiB

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 584 KiB

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 804 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 486 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 402 KiB

After

Width:  |  Height:  |  Size: 585 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 MiB

After

Width:  |  Height:  |  Size: 701 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 MiB

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 427 KiB

After

Width:  |  Height:  |  Size: 540 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 633 KiB

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 971 KiB

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 877 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Some files were not shown because too many files have changed in this diff Show More