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>
This commit is contained in:
Danila Khodjaef
2026-07-25 14:21:22 +03:00
co-authored by Cursor
parent 5ddc3fd7f0
commit bc8369e373
29 changed files with 648 additions and 70 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` |
+3 -2
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`
+8 -5
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
``` ```
+10 -8
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.
@@ -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,12 +293,13 @@ 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) | | Influence lamps | A golden picture light appears **above frames** whose work has any influence-graph edge (`has_influence_links` from the API) |
| 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 |
@@ -329,8 +330,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 +363,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) |
@@ -382,6 +383,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 |
+15 -2
View File
@@ -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`.
+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` |
+5 -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,7 +283,7 @@ 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` |
+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`),
+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>
+96 -15
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';
@@ -294,17 +294,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 +399,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;
@@ -694,6 +704,61 @@ function InfluencePictureLamp({
); );
} }
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]} castShadow renderOrder={28}>
<boxGeometry args={[plateW + 0.012, plateH + 0.01, plateD]} />
<meshStandardMaterial color={rim} metalness={0.85} roughness={0.28} />
</mesh>
<mesh castShadow 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 +791,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 +803,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
@@ -812,9 +881,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"
+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;
+59 -5
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,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 * 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 { return {
back: [] as Painting[],
left: paintings.slice(0, mid), left: paintings.slice(0, mid),
right: paintings.slice(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( 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( 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());
}
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE paintings
ADD COLUMN IF NOT EXISTS curator_notes TEXT NOT NULL DEFAULT '';
+1
View File
@@ -60,6 +60,7 @@ CREATE TABLE IF NOT EXISTS paintings (
year INTEGER, year INTEGER,
year_end INTEGER, year_end INTEGER,
description TEXT, description TEXT,
curator_notes TEXT NOT NULL DEFAULT '',
image_path VARCHAR(500), image_path VARCHAR(500),
thumbnail_path VARCHAR(500), thumbnail_path VARCHAR(500),
wikipedia_title VARCHAR(300), wikipedia_title VARCHAR(300),
+2 -2
View File
@@ -34,7 +34,7 @@ If deploy fails with **`manifest unknown`**, the image is not in Gitea yet — c
**Where:** Dev PC — **PowerShell as Administrator** (LAN push hosts entry), **Docker Desktop running** **Where:** Dev PC — **PowerShell as Administrator** (LAN push hosts entry), **Docker Desktop running**
```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
``` ```
@@ -83,7 +83,7 @@ Enable SMB share **`Gallery`** → `/mnt/BasePool/Applications/Gallery` (for ima
```powershell ```powershell
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
``` ```
+1
View File
@@ -9,6 +9,7 @@
"dev:client": "npm run dev --prefix client", "dev:client": "npm run dev --prefix client",
"dev:start": "node server/index.js", "dev:start": "node server/index.js",
"dev:migrate": "node server/migrate.js", "dev:migrate": "node server/migrate.js",
"dev:reset-curator": "node scripts/reset-curator-password.js",
"dev:setup": "node server/migrate.js && node scripts/seed-wikipedia.js", "dev:setup": "node server/migrate.js && node scripts/seed-wikipedia.js",
"dev:seed": "node scripts/seed-wikipedia.js", "dev:seed": "node scripts/seed-wikipedia.js",
"dev:fetch-images": "node scripts/fetch-missing-images.js", "dev:fetch-images": "node scripts/fetch-missing-images.js",
+47
View File
@@ -0,0 +1,47 @@
/**
* Upsert the curator account password from .env CURATOR_USERNAME / CURATOR_PASSWORD.
* Use when login fails after changing .env, or after a DB restore with a different hash.
*
* npm run dev:reset-curator
*/
require('dotenv').config();
const bcrypt = require('bcryptjs');
const pool = require('../server/db');
async function main() {
const username = (process.env.CURATOR_USERNAME || 'curator').trim();
const password = process.env.CURATOR_PASSWORD;
if (!password) {
throw new Error('Set CURATOR_PASSWORD in .env before running this script');
}
const passwordHash = await bcrypt.hash(password, 10);
const { rows } = await pool.query(`SELECT id FROM users WHERE LOWER(username) = LOWER($1)`, [
username,
]);
if (rows.length === 0) {
await pool.query(`INSERT INTO users (username, password_hash) VALUES ($1, $2)`, [
username,
passwordHash,
]);
console.log(`Created curator account: ${username}`);
} else {
await pool.query(`UPDATE users SET password_hash = $2 WHERE id = $1`, [
rows[0].id,
passwordHash,
]);
console.log(`Updated password for curator account: ${username}`);
}
// Drop stale sessions so a fresh login is required.
await pool.query('DELETE FROM session');
console.log('Cleared session store. Sign in again with CURATOR_USERNAME / CURATOR_PASSWORD from .env');
}
main()
.then(() => pool.end())
.catch((err) => {
console.error(err.message || err);
pool.end().finally(() => process.exit(1));
});
+39
View File
@@ -836,6 +836,45 @@ app.patch('/api/paintings/:id/checkup-flags', requireCurator, async (req, res) =
} }
}); });
// Update public curator notes on a painting
app.patch('/api/paintings/:id/curator-notes', requireCurator, async (req, res) => {
try {
const paintingId = parseInt(req.params.id, 10);
if (!Number.isFinite(paintingId)) {
return res.status(400).json({ error: 'Invalid painting id' });
}
if (typeof req.body?.curatorNotes !== 'string') {
return res.status(400).json({ error: 'curatorNotes must be a string' });
}
const curatorNotes = req.body.curatorNotes.trim();
const result = await pool.query(
`UPDATE paintings SET curator_notes = $2
WHERE id = $1
RETURNING curator_notes`,
[paintingId, curatorNotes]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Painting not found' });
}
res.json({ curatorNotes: result.rows[0].curator_notes ?? '' });
await logCuratorAction({
userId: req.curatorUser.id,
action: 'painting.update_curator_notes',
resourceType: 'painting',
resourceId: paintingId,
details: { length: curatorNotes.length },
req,
});
} catch (err) {
console.error('Curator notes update error:', err.message);
res.status(500).json({ error: 'Failed to update curator notes' });
}
});
// Painting detail with influences // Painting detail with influences
app.get('/api/paintings/:id', async (req, res) => { app.get('/api/paintings/:id', async (req, res) => {
try { try {
+12 -5
View File
@@ -4,6 +4,17 @@ const pool = require('../db');
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
function resolveCookieSecure() {
const flag = process.env.SESSION_COOKIE_SECURE;
if (flag === '1' || flag === 'true') return true;
if (flag === '0' || flag === 'false') return false;
// Behind Keenetic/HTTPS termination: match the browser scheme via X-Forwarded-Proto.
if (process.env.TRUST_PROXY === '1' || process.env.TRUST_PROXY === 'true') {
return 'auto';
}
return false;
}
function createSessionMiddleware() { function createSessionMiddleware() {
const secret = process.env.SESSION_SECRET; const secret = process.env.SESSION_SECRET;
if (!secret) { if (!secret) {
@@ -12,10 +23,6 @@ function createSessionMiddleware() {
); );
} }
const secureCookie =
process.env.SESSION_COOKIE_SECURE === '1' ||
process.env.SESSION_COOKIE_SECURE === 'true';
return session({ return session({
store: new pgSession({ store: new pgSession({
pool, pool,
@@ -28,7 +35,7 @@ function createSessionMiddleware() {
saveUninitialized: false, saveUninitialized: false,
cookie: { cookie: {
httpOnly: true, httpOnly: true,
secure: secureCookie, secure: resolveCookieSecure(),
sameSite: 'lax', sameSite: 'lax',
maxAge: SEVEN_DAYS_MS, maxAge: SEVEN_DAYS_MS,
}, },
+1
View File
@@ -16,6 +16,7 @@ const INCREMENTAL_MIGRATIONS = [
'migrate-sync-timestamps.sql', 'migrate-sync-timestamps.sql',
'migrate-i18n.sql', 'migrate-i18n.sql',
'migrate-tours.sql', 'migrate-tours.sql',
'migrate-curator-notes.sql',
]; ];
async function bootstrapCurator() { async function bootstrapCurator() {
+8 -1
View File
@@ -38,7 +38,7 @@ router.post('/login', async (req, res) => {
try { try {
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT id, username, password_hash FROM users WHERE username = $1`, `SELECT id, username, password_hash FROM users WHERE LOWER(username) = LOWER($1)`,
[username.trim()] [username.trim()]
); );
if (rows.length === 0) { if (rows.length === 0) {
@@ -56,10 +56,17 @@ router.post('/login', async (req, res) => {
req.session.userId = user.id; req.session.userId = user.id;
req.session.username = user.username; req.session.username = user.username;
// Ensure the store writes before the response finishes (proxy / HTTPS).
req.session.save((err) => {
if (err) {
console.error('Auth session save error:', err.message);
return res.status(500).json({ error: 'Login failed' });
}
res.json({ res.json({
role: 'curator', role: 'curator',
username: user.username, username: user.username,
}); });
});
} catch (err) { } catch (err) {
console.error('Auth login error:', err.message); console.error('Auth login error:', err.message);
res.status(500).json({ error: 'Login failed' }); res.status(500).json({ error: 'Login failed' });