Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d709e98640 | ||
|
|
8e444fa461 | ||
|
|
f6c73c1792 | ||
|
|
bc8369e373 |
@@ -16,8 +16,9 @@ TRUST_PROXY=true
|
||||
IMAGE_DIR=./data/images
|
||||
|
||||
# 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_COOKIE_SECURE=false
|
||||
# Omit SESSION_COOKIE_SECURE for auto (HTTPS via proxy → Secure cookie). Set true/false to force.
|
||||
CURATOR_USERNAME=curator
|
||||
CURATOR_PASSWORD=
|
||||
|
||||
|
||||
@@ -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/debug/image-proxy` | — |
|
||||
| `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` |
|
||||
| `POST /api/paintings/:id/fix-image` | `painting.fix_image` |
|
||||
| `POST /api/paintings/:id/clear-image` | `painting.clear_image` |
|
||||
@@ -593,6 +594,7 @@ Painting detail with influence graph neighbours.
|
||||
"thumbnail_cache_key": 1739123456790,
|
||||
"checkup_checked": false,
|
||||
"checkup_fixed": false,
|
||||
"curator_notes": "",
|
||||
"has_influence_links": true
|
||||
},
|
||||
"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`
|
||||
|
||||
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 |
|
||||
| `api.getPaintingCheckup()` | `GET /api/paintings/checkup` |
|
||||
| `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.getPaintingDebugImageSearchMore(id, limit?)` | `GET /api/paintings/:id/debug-image-search/more` |
|
||||
| `api.fixPaintingImage(id, imageUrl, context?)` | `POST /api/paintings/:id/fix-image` |
|
||||
|
||||
@@ -104,7 +104,8 @@ Phases within an artist’s career (e.g. “Blue Period”, “Roman Period”).
|
||||
| `period_id` | FK → `artist_periods` | Optional grouping |
|
||||
| `title` | VARCHAR(300) | |
|
||||
| `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** |
|
||||
| `thumbnail_path` | VARCHAR(500) | Smaller variant for lists / 3D; nullable after **Clear** |
|
||||
| `wikipedia_title` | VARCHAR(300) | Used by image fetcher |
|
||||
@@ -114,7 +115,7 @@ Phases within an artist’s 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.
|
||||
|
||||
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`
|
||||
|
||||
@@ -160,8 +161,9 @@ Curated guided tours. See [tours.md](tours.md).
|
||||
| `painting_id` | FK → `paintings` | ON DELETE CASCADE; UNIQUE with `tour_id` |
|
||||
| `sort_order` | INTEGER | Visitor / editor order |
|
||||
| `body` | TEXT | English tour notes for the stop (v1) |
|
||||
| `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`
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ Details: [environments.md](environments.md) · Deploy: [../infra/docker/DEPLOY-t
|
||||
### Start — public dev (Keenetic URL)
|
||||
|
||||
```powershell
|
||||
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
|
||||
cd T:\Repo\Gallery
|
||||
npm run dev:web
|
||||
```
|
||||
|
||||
@@ -77,12 +77,13 @@ Stop-Process -Id <PID> -Force
|
||||
## First-time install
|
||||
|
||||
```powershell
|
||||
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
|
||||
cd T:\Repo\Gallery
|
||||
copy .env.example .env # edit DB credentials, PUBLIC_URL
|
||||
npm install
|
||||
cd client; npm install; cd ..
|
||||
|
||||
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)
|
||||
```
|
||||
|
||||
@@ -94,14 +95,16 @@ CURATOR_USERNAME=curator
|
||||
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:**
|
||||
|
||||
| Role | Access |
|
||||
|------|--------|
|
||||
| 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`):**
|
||||
|
||||
@@ -152,7 +155,7 @@ DB_NAME=gallery_dev
|
||||
PORT=3451
|
||||
PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro
|
||||
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_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:thumbnails # rebuild thumb files + DB paths on dev (before backup)
|
||||
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: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 **0–7** = success. Deploy scripts print a final **`===== SUCCESS =====`** or **`===== FAILED =====`** banner.
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
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.
|
||||
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 **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**).
|
||||
|
||||
**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 |
|
||||
| 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 **“Loading gallery…”** | While the 3D canvas initializes after the hall opens (center area; header and controls stay visible) |
|
||||
| Overlay **“Loading paintings…”** | While wall textures are still downloading in the 3D hall |
|
||||
| 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 painting textures are still downloading / uploading to the GPU |
|
||||
| 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.
|
||||
@@ -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)).
|
||||
|
||||
**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
|
||||
|
||||
@@ -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 artist’s paintings |
|
||||
| Catalog depth | Most artists target **≥ 6** notable works via `npm run dev:expand-catalog` and `famous-paintings-data.js`; some masters have larger museum dumps |
|
||||
| Paintings on walls | Works hang on the **left and right** walls in **one row per wall**; room **depth grows** when the catalog is large (back wall is for the exit only) |
|
||||
| Wall order | Chronological visit order: first half on the **left** (entrance → back), second half on the **right** (back → entrance); first work left of the opening view, last on the right |
|
||||
| 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 U-path: first third **left** (entrance → end), middle third **end wall**, last third **right** (end → entrance) |
|
||||
| Floor | Herringbone **parquet** procedural texture (`parquetFloorTexture.ts`) |
|
||||
| Wall tint | Gallery walls blend the artist’s **movement colour** into cream plaster tones |
|
||||
| Frame finish | **Unchecked** works: black moulding; **Reviewed** (`checkup_checked`): bright gold moulding at **double width** |
|
||||
| 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) |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -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 |
|
||||
| Wings | Catalog split into wings of up to **55 works** (`movementHallLayout.ts`); large movements (e.g. Baroque) use multiple wings |
|
||||
| Paintings on walls | **Left and right walls only** — back wall reserved for exit, front for passage to the next wing |
|
||||
| Wall order | Same shared hang as artist halls: first half left (entrance → back), second half right (back → entrance) |
|
||||
| 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 U-shaped hang as artist halls (left → end → right) |
|
||||
| Frame captions | **Year · artist** label below each frame |
|
||||
| Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, NYC loft, white cube, etc.) |
|
||||
| Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco — plus **single-colour painted walls** (`painted-lime`, `painted-oil-matte`, `painted-oil-satin`, `painted-emulsion`, `painted-flat`) tinted per movement for Renaissance salons through modern white cubes |
|
||||
@@ -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) |
|
||||
| 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 |
|
||||
| Exit | Wing navigator / **Exit to Timeline** (no influence picker) |
|
||||
|
||||
@@ -370,7 +372,9 @@ Full guide: [tours.md](tours.md).
|
||||
|
||||
### Shared 3D behaviour
|
||||
|
||||
**3D images** use locally cached files only (`galleryImageUrl` in `client/src/api/client.ts`). Remote fetches are too slow for realtime WebGL textures; the client calls `POST /api/artists/:id/preload-images` automatically when entering an **artist** hall (public route — links disk files only). Movement galleries load painting lists from the API without a separate preload step. While a texture is loading, the frame shows the canvas cover instead of a white placeholder. The center of the hall shows **“Loading gallery…”** until the WebGL canvas is ready, then **“Loading paintings…”** until every wall texture has resolved (tracked through `GalleryTextureLoadContext`). The 3D hall stays mounted while painting detail or bio overlays are open; returning remounts the canvas when the hall becomes active again.
|
||||
**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.
|
||||
|
||||
@@ -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`) |
|
||||
| **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) |
|
||||
| **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 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.
|
||||
- **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.
|
||||
|
||||
## User roles and access
|
||||
|
||||
@@ -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 |
|
||||
| **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 artist’s `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
|
||||
|
||||
@@ -265,7 +265,7 @@ To add or fix a branch, edit `MOVEMENT_LINEAGE` in that file and rebuild the cli
|
||||
|
||||
Separate from movement lineage layout, `client/src/data/movement-interior-styles.ts` defines a **unique 3D interior** for each seeded art movement (26 styles): wall/floor/ceiling textures, trim colours, window style, and architectural details (columns, coffered ceilings, etc.). Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts`.
|
||||
|
||||
Wing layout (up to 55 works per wing, side-wall-only hang, window gap placement) lives in `client/src/utils/movementHallLayout.ts`. Visit order fills the **left wall** (first work at the entrance), then the **right** (last work at the entrance). The same hang applies to artist halls and guided tours. To change a movement’s look, edit its entry in `movement-interior-styles.ts` and rebuild the client.
|
||||
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 movement’s look, edit its entry in `movement-interior-styles.ts` and rebuild the client.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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)
|
||||
|
||||
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 |
|
||||
|--------|--------|
|
||||
|
||||
@@ -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 |
|
||||
| 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**.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -90,14 +90,14 @@ If STEP 2 fails with “database already exists”, STEP 0 likely already shows
|
||||
**Alternative (dev PC with psql installed):**
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
**Alternative (Node, postgres password in env):**
|
||||
|
||||
```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"
|
||||
npm run infra:db:split-dev-prod
|
||||
```
|
||||
@@ -113,17 +113,16 @@ npm run infra:db:split-dev-prod
|
||||
PORT=3451
|
||||
PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro
|
||||
SESSION_SECRET=your-long-random-secret
|
||||
SESSION_COOKIE_SECURE=false
|
||||
CURATOR_USERNAME=curator
|
||||
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:
|
||||
|
||||
```powershell
|
||||
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
|
||||
cd T:\Repo\Gallery
|
||||
npm run dev:migrate
|
||||
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)
|
||||
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
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
```powershell
|
||||
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
|
||||
cd T:\Repo\Gallery
|
||||
docker login gitea.mysuperlab.netcraze.pro
|
||||
npm run prod:docker:publish
|
||||
```
|
||||
@@ -254,10 +253,9 @@ Expect **HTTP 200** (not 502).
|
||||
PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro
|
||||
TRUST_PROXY=true
|
||||
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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -107,7 +109,9 @@ Reports are written to `db/SyncReports/harmonize_db_*.json` and `harmonize_image
|
||||
|
||||
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 |
|
||||
| Only images changed on one side | `npm run harmonize:images` |
|
||||
| Only DB metadata changed | `npm run harmonize:db` |
|
||||
| Pull **one artist’s** 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
|
||||
|
||||
- Pre-flight backups of dev and prod DB (configurable; on by default)
|
||||
|
||||
@@ -69,7 +69,7 @@ API (curator-only): see [API.md](API.md#translations-curator).
|
||||
| `era`, `movement` | `name`, `description` |
|
||||
| `artist` | `name`, `bio_short`, `bio_full` |
|
||||
| `artist_period` | `name`, `description` |
|
||||
| `painting` | `title`, `description` |
|
||||
| `painting` | `title`, `description` (not `curator_notes` — English-only for now) |
|
||||
| `annotation` | `label`, `body` |
|
||||
| `influence_source` | `notes`, `aspects`, `quote`, `period_note` |
|
||||
|
||||
|
||||
@@ -29,9 +29,9 @@ cp .env.example .env
|
||||
| `TRUST_PROXY` | Set to `true` when behind nginx/reverse proxy (honours `X-Forwarded-*`) |
|
||||
| `IMAGE_DIR` | Root for cached images (default `./data/images`) |
|
||||
| `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 |
|
||||
| `CURATOR_USERNAME` | Bootstrap only — first curator account name (default `curator`) |
|
||||
| `CURATOR_PASSWORD` | Bootstrap only — password for first curator when `users` table is empty |
|
||||
| `SESSION_COOKIE_SECURE` | Optional — omit for auto (HTTPS via proxy → Secure); set `true`/`false` to force |
|
||||
| `CURATOR_USERNAME` | Bootstrap / reset — curator account name (default `curator`) |
|
||||
| `CURATOR_PASSWORD` | Bootstrap when `users` is empty; also used by `npm run dev:reset-curator` |
|
||||
|
||||
`.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)
|
||||
|
||||
`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.
|
||||
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| **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 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` |
|
||||
| **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 |
|
||||
|
||||
@@ -64,6 +64,14 @@ export async function loginCurator(username: string, password: string): Promise<
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
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(() => ({}));
|
||||
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 }>;
|
||||
}),
|
||||
|
||||
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) =>
|
||||
fetchJson<DebugImageSearchResult>(`${API}/artists/${id}/debug-portrait-search`),
|
||||
|
||||
|
||||
@@ -181,7 +181,6 @@ function SingleWindow({
|
||||
intensity={spec.lightIntensity}
|
||||
distance={14}
|
||||
color={spec.lightColor}
|
||||
castShadow={false}
|
||||
/>
|
||||
<pointLight
|
||||
position={[0, 0, 0.25]}
|
||||
@@ -250,7 +249,6 @@ export function GalleryTrackLights({
|
||||
intensity={intensity}
|
||||
distance={8}
|
||||
color={color}
|
||||
castShadow={false}
|
||||
/>
|
||||
</group>
|
||||
))}
|
||||
|
||||
@@ -46,14 +46,14 @@ export default function HallPassage({
|
||||
|
||||
{/* Side jambs */}
|
||||
{([-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]} />
|
||||
<meshStandardMaterial color={trimColor} roughness={0.45} metalness={0.25} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{/* 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]} />
|
||||
<meshStandardMaterial color={trimColor} roughness={0.4} metalness={0.3} />
|
||||
</mesh>
|
||||
|
||||
@@ -26,7 +26,7 @@ function PalazzoDetails({ width, depth, halfW, halfD, trim }: Props & { trim: st
|
||||
<group>
|
||||
{pilasterPositions.map(([x, z], i) => (
|
||||
<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]} />
|
||||
<meshStandardMaterial color="#e8dcc8" roughness={0.85} metalness={0.05} />
|
||||
</mesh>
|
||||
|
||||
@@ -441,6 +441,115 @@
|
||||
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 {
|
||||
max-width: 700px;
|
||||
margin-top: 20px;
|
||||
|
||||
@@ -21,6 +21,7 @@ interface Props {
|
||||
onCatalogNavigate: (paintingId: number) => void;
|
||||
onArtistBio: () => void;
|
||||
onInfluenceArtistClick?: (artistId: number) => void;
|
||||
isCurator?: boolean;
|
||||
debugMode?: boolean;
|
||||
debugShowMore?: boolean;
|
||||
onPaintingImageFixed?: (
|
||||
@@ -32,6 +33,7 @@ interface Props {
|
||||
flags: { checked: boolean; fixed: boolean }
|
||||
) => void | Promise<void>;
|
||||
onPaintingRemoved?: (paintingId: number, artistId: number) => void | Promise<void>;
|
||||
onCuratorNotesUpdated?: (paintingId: number, curatorNotes: string) => void;
|
||||
}
|
||||
|
||||
function influenceKey(inf: InfluenceLink, index: number): string {
|
||||
@@ -203,15 +205,22 @@ export default function PaintingDetailView({
|
||||
onCatalogNavigate,
|
||||
onArtistBio,
|
||||
onInfluenceArtistClick,
|
||||
isCurator = false,
|
||||
debugMode = false,
|
||||
debugShowMore = false,
|
||||
onPaintingImageFixed,
|
||||
onPaintingCheckupFlagsUpdated,
|
||||
onPaintingRemoved,
|
||||
onCuratorNotesUpdated,
|
||||
}: Props) {
|
||||
const { t } = useTranslation('painting');
|
||||
const { painting, influencedBy, influenced, annotations = [] } = data;
|
||||
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 [imageVersion, setImageVersion] = useState(0);
|
||||
const [debugSearch, setDebugSearch] = useState<DebugImageSearchResult | null>(null);
|
||||
@@ -257,7 +266,13 @@ export default function PaintingDetailView({
|
||||
setMarkingChecked(false);
|
||||
setApplyingUrl(null);
|
||||
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(() => {
|
||||
if (!debugMode) {
|
||||
@@ -291,6 +306,34 @@ export default function PaintingDetailView({
|
||||
};
|
||||
}, [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) => {
|
||||
setImageVersion((v) => v + 1);
|
||||
if (onPaintingImageFixed) {
|
||||
@@ -586,6 +629,57 @@ export default function PaintingDetailView({
|
||||
)}
|
||||
</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 && (
|
||||
<div className="painting-description">
|
||||
<p>{painting.description}</p>
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
TourGalleryDetail,
|
||||
} from '../types';
|
||||
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 { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles';
|
||||
import { useTexturedMaterial } from '../hooks/useTexturedMaterial';
|
||||
@@ -38,7 +38,7 @@ const GalleryTextureLoadContext = createContext<{
|
||||
* from unmounting the whole 3D scene and leaving a dark window.
|
||||
*/
|
||||
class SceneErrorBoundary extends Component<
|
||||
{ children: ReactNode; fallback?: ReactNode },
|
||||
{ children: ReactNode; fallback?: ReactNode; onError?: () => void },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
state = { hasError: false };
|
||||
@@ -49,6 +49,7 @@ class SceneErrorBoundary extends Component<
|
||||
|
||||
componentDidCatch(error: unknown) {
|
||||
console.warn('Gallery scene subtree failed, continuing without it.', error);
|
||||
this.props.onError?.();
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -97,6 +98,9 @@ const REVIEWED_MAT_BORDER = FRAME_MAT_BORDER * 2;
|
||||
const REVIEWED_RAIL = FRAME_RAIL * 2;
|
||||
const EYE_HEIGHT = 1.65;
|
||||
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 MAX_FRAME_W = 1.05;
|
||||
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
|
||||
* entrance, left of the opening view), second half on the right (ending at the
|
||||
* entrance). Back wall stays empty so first/last always sit on left/right.
|
||||
* U-shaped visit order:
|
||||
* left wall (first, near entrance) → far/end wall → right wall (last, near entrance).
|
||||
* 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[]) {
|
||||
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 [
|
||||
[] as Painting[],
|
||||
ordered.slice(0, mid),
|
||||
ordered.slice(mid),
|
||||
ordered.slice(leftCount, leftCount + backCount),
|
||||
ordered.slice(0, leftCount),
|
||||
ordered.slice(leftCount + backCount),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -390,18 +403,19 @@ function layoutWallSlots(
|
||||
function buildHallLayout(paintings: Painting[], periods: ArtistPeriod[]): HallLayout {
|
||||
const walls: WallSide[] = ['back', 'left', 'right'];
|
||||
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(
|
||||
minSpanForWall(wallPaintings[1], MAX_WALL_ROWS),
|
||||
minSpanForWall(wallPaintings[2], MAX_WALL_ROWS)
|
||||
minSpanForWall(leftPaintings, MAX_WALL_ROWS),
|
||||
minSpanForWall(rightPaintings, MAX_WALL_ROWS)
|
||||
);
|
||||
|
||||
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(
|
||||
minSpanForWall(wallPaintings[1], MAX_WALL_ROWS),
|
||||
minSpanForWall(wallPaintings[2], MAX_WALL_ROWS)
|
||||
minSpanForWall(leftPaintings, MAX_WALL_ROWS),
|
||||
minSpanForWall(rightPaintings, MAX_WALL_ROWS)
|
||||
);
|
||||
if (nextWidth === width && nextDepth === depth) break;
|
||||
width = nextWidth;
|
||||
@@ -568,6 +582,7 @@ function usePaintingTexture(url: string | null) {
|
||||
const [texture, setTexture] = useState<THREE.Texture | null>(null);
|
||||
const [failed, setFailed] = useState(!url);
|
||||
const textureLoad = useContext(GalleryTextureLoadContext);
|
||||
const { gl } = useThree();
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) {
|
||||
@@ -590,27 +605,44 @@ function usePaintingTexture(url: string | null) {
|
||||
};
|
||||
|
||||
textureLoad?.begin();
|
||||
const loadTimeout = window.setTimeout(() => {
|
||||
if (settled || disposed) return;
|
||||
setFailed(true);
|
||||
finish();
|
||||
}, TEXTURE_LOAD_TIMEOUT_MS);
|
||||
|
||||
loader.load(
|
||||
url,
|
||||
(tex) => {
|
||||
finish();
|
||||
window.clearTimeout(loadTimeout);
|
||||
if (disposed) {
|
||||
tex.dispose();
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
const img = tex.image as HTMLImageElement | undefined;
|
||||
if (!img || img.width < 4 || img.height < 4) {
|
||||
tex.dispose();
|
||||
setFailed(true);
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
loaded = tex;
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
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,
|
||||
() => {
|
||||
window.clearTimeout(loadTimeout);
|
||||
finish();
|
||||
if (!disposed) setFailed(true);
|
||||
}
|
||||
@@ -618,11 +650,12 @@ function usePaintingTexture(url: string | null) {
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearTimeout(loadTimeout);
|
||||
finish();
|
||||
loaded?.dispose();
|
||||
setTexture(null);
|
||||
};
|
||||
}, [url, textureLoad]);
|
||||
}, [url, textureLoad, gl]);
|
||||
|
||||
return { texture, failed };
|
||||
}
|
||||
@@ -644,11 +677,11 @@ function InfluencePictureLamp({
|
||||
|
||||
return (
|
||||
<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]} />
|
||||
<meshStandardMaterial color="#2f2f2f" metalness={0.9} roughness={0.2} />
|
||||
</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]} />
|
||||
<meshStandardMaterial color="#555555" metalness={0.8} roughness={0.3} />
|
||||
</mesh>
|
||||
@@ -687,13 +720,67 @@ function InfluencePictureLamp({
|
||||
intensity={2.2 * glow}
|
||||
distance={5}
|
||||
color="#fff0c8"
|
||||
castShadow={false}
|
||||
/>
|
||||
</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({
|
||||
painting,
|
||||
position,
|
||||
@@ -726,6 +813,7 @@ function PaintingFrame({
|
||||
const showImage = hasImage && !failed && !!texture;
|
||||
const showCanvas = !showImage;
|
||||
const hasInfluenceLinks = paintingHasInfluenceLinks(painting);
|
||||
const hasCuratorNotes = paintingHasCuratorNotes(painting);
|
||||
const finish = frameFinish(reviewed, hovered);
|
||||
const faceZ = FRAME_FACE_Z + (wallSide === 'back' ? 0.012 : 0);
|
||||
|
||||
@@ -737,6 +825,9 @@ function PaintingFrame({
|
||||
}
|
||||
}, [texture, showImage]);
|
||||
|
||||
const captionY =
|
||||
-height / 2 - matBorder - rail - (hasCuratorNotes ? 0.18 : 0.1);
|
||||
|
||||
return (
|
||||
<group position={position} rotation={[0, rotationY, 0]}>
|
||||
<spotLight
|
||||
@@ -750,7 +841,6 @@ function PaintingFrame({
|
||||
|
||||
<mesh
|
||||
position={[0, 0, frameDepth / 2]}
|
||||
castShadow
|
||||
renderOrder={1}
|
||||
onClick={(e) => {
|
||||
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 && (
|
||||
<Text
|
||||
position={[0, -height / 2 - matBorder - rail - 0.1, frameDepth + faceZ + 0.02]}
|
||||
position={[0, captionY, frameDepth + faceZ + 0.02]}
|
||||
fontSize={0.085}
|
||||
maxWidth={Math.max(width + matBorder * 2, 0.55)}
|
||||
color="#4a3828"
|
||||
@@ -842,7 +944,7 @@ function GalleryWall({
|
||||
}) {
|
||||
const [w, h] = size;
|
||||
return (
|
||||
<mesh position={position} rotation={rotation} receiveShadow renderOrder={0}>
|
||||
<mesh position={position} rotation={rotation} renderOrder={0}>
|
||||
<boxGeometry args={[w, h, WALL_THICKNESS]} />
|
||||
<meshStandardMaterial color={color} roughness={0.92} />
|
||||
</mesh>
|
||||
@@ -901,7 +1003,7 @@ function ExitPortal({
|
||||
const panelInset = 0.06;
|
||||
return (
|
||||
<group key={side} position={[x, leafY, faceZ]}>
|
||||
<mesh castShadow>
|
||||
<mesh>
|
||||
<boxGeometry args={[leafW, leafH, 0.055]} />
|
||||
<meshStandardMaterial color={woodMid} roughness={0.62} metalness={0.04} />
|
||||
</mesh>
|
||||
@@ -955,7 +1057,7 @@ function ExitPortal({
|
||||
</mesh>
|
||||
|
||||
{/* 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]} />
|
||||
<meshStandardMaterial color="#e8e4dc" roughness={0.28} metalness={0.08} />
|
||||
</mesh>
|
||||
@@ -982,14 +1084,14 @@ function ExitPortal({
|
||||
|
||||
{/* Side jambs */}
|
||||
{([-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]} />
|
||||
<meshStandardMaterial color={woodDark} roughness={0.55} metalness={0.06} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{/* 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]} />
|
||||
<meshStandardMaterial color={woodMid} roughness={0.58} metalness={0.05} />
|
||||
</mesh>
|
||||
@@ -1020,13 +1122,12 @@ function ExitPortal({
|
||||
<mesh
|
||||
key={`sur-${sign}`}
|
||||
position={[sign * (openingW / 2 + jamb + surround / 2), frameFullH / 2, faceZ - 0.01]}
|
||||
castShadow
|
||||
>
|
||||
<boxGeometry args={[surround, frameFullH + 0.08, 0.08]} />
|
||||
<meshStandardMaterial color={stone} roughness={0.88} />
|
||||
</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]} />
|
||||
<meshStandardMaterial color={stoneDark} roughness={0.82} metalness={0.08} />
|
||||
</mesh>
|
||||
@@ -1139,7 +1240,7 @@ function TexturedWall({
|
||||
const [w, h, d] = size;
|
||||
const mat = useTexturedMaterial(kind, tint, Math.max(w, d), h);
|
||||
return (
|
||||
<mesh position={position} rotation={rotation} receiveShadow material={mat} renderOrder={0}>
|
||||
<mesh position={position} rotation={rotation} material={mat} renderOrder={0}>
|
||||
<boxGeometry args={size} />
|
||||
</mesh>
|
||||
);
|
||||
@@ -1158,7 +1259,7 @@ function TexturedCeiling({
|
||||
}) {
|
||||
const mat = useTexturedMaterial(kind, tint, width + 0.4, depth + 0.4);
|
||||
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]} />
|
||||
</mesh>
|
||||
);
|
||||
@@ -1190,7 +1291,7 @@ function GalleryFloor({
|
||||
);
|
||||
|
||||
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]} />
|
||||
<meshStandardMaterial
|
||||
map={texture.map}
|
||||
@@ -1208,7 +1309,7 @@ function GalleryFloor({
|
||||
function ParquetFloor({ width, depth }: { width: number; depth: number }) {
|
||||
const mat = useTexturedMaterial('parquet-herringbone', '#c8a882', width + 0.4, depth + 0.4);
|
||||
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]} />
|
||||
</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]} />
|
||||
{wallMaterial(walls.main)}
|
||||
</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]} />
|
||||
{wallMaterial(walls.side)}
|
||||
</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]} />
|
||||
{wallMaterial(walls.side)}
|
||||
</mesh>
|
||||
@@ -1526,6 +1627,65 @@ function FrameloopSync({ active }: { active: boolean }) {
|
||||
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({
|
||||
position,
|
||||
target,
|
||||
@@ -1720,6 +1880,8 @@ export default function VirtualGallery(props: Props) {
|
||||
const [isLooking, setIsLooking] = useState(false);
|
||||
const [texturesPending, setTexturesPending] = useState(0);
|
||||
const [canvasReady, setCanvasReady] = useState(false);
|
||||
const [envReady, setEnvReady] = useState(false);
|
||||
const [shadersWarmed, setShadersWarmed] = useState(false);
|
||||
const [glEpoch, setGlEpoch] = useState(0);
|
||||
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 canvas = state.gl.domElement;
|
||||
// A freshly created canvas has a healthy context, so clear any lingering
|
||||
@@ -1797,6 +1967,30 @@ export default function VirtualGallery(props: Props) {
|
||||
setHallIndex(0);
|
||||
}, [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(() => {
|
||||
if (isWingedHall && movementHalls.length > 0) {
|
||||
return movementHalls[Math.min(hallIndex, movementHalls.length - 1)];
|
||||
@@ -1804,13 +1998,17 @@ export default function VirtualGallery(props: Props) {
|
||||
return buildHallLayout(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
|
||||
? 'Loading gallery…'
|
||||
: texturesPending > 0
|
||||
? 'Loading paintings…'
|
||||
: 'Loading gallery…';
|
||||
const galleryLoadingMessage =
|
||||
canvasReady && texturesPending > 0 ? 'Loading paintings…' : 'Loading gallery…';
|
||||
|
||||
const warmGpuEnabled = canvasReady && envReady && !shadersWarmed;
|
||||
|
||||
const computedWindows = useMemo(() => {
|
||||
if (!isMovement || !interiorStyle || !('hallIndex' in layout)) return undefined;
|
||||
@@ -2107,7 +2305,6 @@ export default function VirtualGallery(props: Props) {
|
||||
)}
|
||||
<Canvas
|
||||
key={`${hallKey}-${glEpoch}`}
|
||||
shadows
|
||||
frameloop={active ? 'always' : 'never'}
|
||||
gl={{ preserveDrawingBuffer: true, powerPreference: 'high-performance' }}
|
||||
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]}
|
||||
intensity={interiorStyle ? 0.85 : 0.65}
|
||||
color={interiorStyle?.sunLight ?? '#fff8f0'}
|
||||
castShadow
|
||||
shadow-mapSize={[1024, 1024]}
|
||||
/>
|
||||
<SceneErrorBoundary fallback={null}>
|
||||
<SceneErrorBoundary
|
||||
key={`env-${hallKey}-${glEpoch}`}
|
||||
fallback={null}
|
||||
onError={handleEnvReady}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<EnvironmentGate onReady={handleEnvReady}>
|
||||
<Environment
|
||||
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
|
||||
environmentIntensity={interiorStyle ? 0.35 : 0.15}
|
||||
/>
|
||||
</EnvironmentGate>
|
||||
</Suspense>
|
||||
</SceneErrorBoundary>
|
||||
<GalleryTextureLoadContext.Provider value={textureLoad}>
|
||||
@@ -2151,6 +2352,7 @@ export default function VirtualGallery(props: Props) {
|
||||
nearPassage={nearPassage}
|
||||
/>
|
||||
</GalleryTextureLoadContext.Provider>
|
||||
<WarmHallGpu enabled={warmGpuEnabled} onDone={handleShadersWarmed} />
|
||||
<CameraController position={camPos} target={camTarget} />
|
||||
</Canvas>
|
||||
</div>
|
||||
|
||||
@@ -11,5 +11,12 @@
|
||||
"tourNotes": "Tour notes",
|
||||
"tourNotesFor": "Tour notes · {{title}}",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -11,5 +11,12 @@
|
||||
"tourNotes": "Текст экскурсии",
|
||||
"tourNotesFor": "Экскурсия · {{title}}",
|
||||
"tourNotesEmpty": "Для этой остановки нет текста.",
|
||||
"tourStopPosition": "Остановка {{current}} из {{total}}"
|
||||
"tourStopPosition": "Остановка {{current}} из {{total}}",
|
||||
"curatorNotes": "Заметки куратора",
|
||||
"curatorNotesEmpty": "Заметок куратора пока нет.",
|
||||
"curatorNotesEdit": "Изменить",
|
||||
"curatorNotesSave": "Сохранить",
|
||||
"curatorNotesSaving": "Сохранение…",
|
||||
"curatorNotesCancel": "Отмена",
|
||||
"curatorNotesSaveFailed": "Не удалось сохранить заметки куратора."
|
||||
}
|
||||
|
||||
@@ -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>) => {
|
||||
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 });
|
||||
}}
|
||||
onInfluenceArtistClick={handleArtistClick}
|
||||
isCurator={isCurator}
|
||||
debugMode={effectiveDebugMode}
|
||||
debugShowMore={debugShowMore && isCurator}
|
||||
onPaintingImageFixed={handlePaintingImageFixed}
|
||||
onPaintingCheckupFlagsUpdated={handlePaintingCheckupFlagsUpdated}
|
||||
onPaintingRemoved={handlePaintingRemoved}
|
||||
onCuratorNotesUpdated={handleCuratorNotesUpdated}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface Painting {
|
||||
year: number;
|
||||
year_end?: number;
|
||||
description: string;
|
||||
curator_notes?: string;
|
||||
image_path: string | null;
|
||||
thumbnail_path?: string | null;
|
||||
image_cache_key?: number | null;
|
||||
|
||||
@@ -43,6 +43,9 @@ const MAX_FRAME_H = 1.35;
|
||||
const MIN_HALL_SIZE = 10;
|
||||
const MIN_HALL_WIDTH = 11;
|
||||
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_RAIL = 0.08;
|
||||
@@ -110,16 +113,29 @@ 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
|
||||
* entrance on the left, last work near the entrance on the right.
|
||||
*/
|
||||
function distributeToSideWalls(paintings: Painting[]) {
|
||||
const mid = Math.ceil(paintings.length / 2);
|
||||
function distributeAcrossWalls(paintings: Painting[]) {
|
||||
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 {
|
||||
left: paintings.slice(0, leftCount),
|
||||
back: paintings.slice(leftCount, leftCount + backCount),
|
||||
right: paintings.slice(leftCount + backCount),
|
||||
};
|
||||
}
|
||||
|
||||
function layoutSideSlots(
|
||||
@@ -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(
|
||||
paintings: Painting[],
|
||||
hallIndex: number,
|
||||
hallCount: number
|
||||
): MovementHallLayout {
|
||||
const { left, right } = distributeToSideWalls(paintings);
|
||||
const { left, back, right } = distributeAcrossWalls(paintings);
|
||||
const leftSpan = layoutRow(left, MIN_HALL_SIZE);
|
||||
const rightSpan = layoutRow(right, MIN_HALL_SIZE);
|
||||
const depth = Math.max(MIN_HALL_SIZE, leftSpan.spanNeeded, rightSpan.spanNeeded);
|
||||
const width = MIN_HALL_WIDTH;
|
||||
const halfW = width / 2;
|
||||
const halfD = depth / 2;
|
||||
const inset = WALL_THICKNESS / 2 + MOUNT_OFFSET;
|
||||
|
||||
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',
|
||||
label: left.length > 0 ? `Wing ${hallIndex + 1} · Left wall` : '',
|
||||
|
||||
@@ -36,3 +36,10 @@ export function paintingHasInfluenceLinks(
|
||||
const flag = painting.has_influence_links;
|
||||
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());
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 501 KiB After Width: | Height: | Size: 273 KiB |
|
Before Width: | Height: | Size: 410 KiB After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 941 KiB After Width: | Height: | Size: 338 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 314 KiB |
|
Before Width: | Height: | Size: 584 KiB After Width: | Height: | Size: 256 KiB |
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 804 KiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 484 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 493 KiB |
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 397 KiB |
|
Before Width: | Height: | Size: 486 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 580 KiB |
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
Before Width: | Height: | Size: 402 KiB After Width: | Height: | Size: 585 KiB |
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 298 KiB |
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 314 KiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 320 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 6.2 MiB After Width: | Height: | Size: 701 KiB |
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 431 KiB |
|
Before Width: | Height: | Size: 5.4 MiB After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 427 KiB After Width: | Height: | Size: 540 KiB |
|
Before Width: | Height: | Size: 238 KiB After Width: | Height: | Size: 3.3 MiB |
|
Before Width: | Height: | Size: 633 KiB After Width: | Height: | Size: 510 KiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 510 KiB |
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 971 KiB After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 736 KiB |
|
After Width: | Height: | Size: 533 KiB |
|
After Width: | Height: | Size: 877 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 29 KiB |