From bc8369e373679df8fac09c8a80ded131be97d2c5 Mon Sep 17 00:00:00 2001 From: Danila Khodjaef Date: Sat, 25 Jul 2026 14:21:22 +0300 Subject: [PATCH] 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 --- .env.example | 3 +- Documentation/API.md | 19 ++++ Documentation/DB_structure.md | 5 +- Documentation/FAC.md | 13 ++- Documentation/basics.md | 18 ++-- Documentation/data-and-images.md | 17 +++- Documentation/deploy-dev-to-prod.md | 2 +- Documentation/environments.md | 16 ++-- Documentation/i18n-russian.md | 2 +- Documentation/setup.md | 10 +- client/src/api/client.ts | 22 +++++ client/src/components/PaintingDetail.css | 109 ++++++++++++++++++++++ client/src/components/PaintingDetail.tsx | 96 +++++++++++++++++++- client/src/components/VirtualGallery.tsx | 111 ++++++++++++++++++++--- client/src/locales/en/painting.json | 9 +- client/src/locales/ru/painting.json | 9 +- client/src/pages/HomePage.tsx | 54 +++++++++++ client/src/types/index.ts | 1 + client/src/utils/movementHallLayout.ts | 68 ++++++++++++-- client/src/utils/paintingUtils.ts | 7 ++ db/migrate-curator-notes.sql | 2 + db/schema.sql | 1 + infra/docker/DEPLOY-truenas.md | 4 +- package.json | 1 + scripts/reset-curator-password.js | 47 ++++++++++ server/index.js | 39 ++++++++ server/middleware/session.js | 17 +++- server/migrate.js | 1 + server/routes/auth.js | 15 ++- 29 files changed, 648 insertions(+), 70 deletions(-) create mode 100644 db/migrate-curator-notes.sql create mode 100644 scripts/reset-curator-password.js diff --git a/.env.example b/.env.example index 776df1c..506775f 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/Documentation/API.md b/Documentation/API.md index 3525435..22bb529 100644 --- a/Documentation/API.md +++ b/Documentation/API.md @@ -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` | diff --git a/Documentation/DB_structure.md b/Documentation/DB_structure.md index b179511..e5e237b 100644 --- a/Documentation/DB_structure.md +++ b/Documentation/DB_structure.md @@ -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` diff --git a/Documentation/FAC.md b/Documentation/FAC.md index daab03b..654d4ee 100644 --- a/Documentation/FAC.md +++ b/Documentation/FAC.md @@ -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 -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 ``` diff --git a/Documentation/basics.md b/Documentation/basics.md index 3e1ba4f..a0c7148 100644 --- a/Documentation/basics.md +++ b/Documentation/basics.md @@ -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. @@ -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,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 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) | +| 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 | @@ -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 | | 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 +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) | | 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) | @@ -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`) | | **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 | diff --git a/Documentation/data-and-images.md b/Documentation/data-and-images.md index e31cffc..8e5b1ae 100644 --- a/Documentation/data-and-images.md +++ b/Documentation/data-and-images.md @@ -265,7 +265,7 @@ To add or fix a branch, edit `MOVEMENT_LINEAGE` in that file and rebuild the cli Separate from movement lineage layout, `client/src/data/movement-interior-styles.ts` defines a **unique 3D interior** for each seeded art movement (26 styles): wall/floor/ceiling textures, trim colours, window style, and architectural details (columns, coffered ceilings, etc.). Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts`. -Wing layout (up to 55 works per wing, side-wall-only hang, window gap placement) lives in `client/src/utils/movementHallLayout.ts`. 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 | |--------|--------| diff --git a/Documentation/deploy-dev-to-prod.md b/Documentation/deploy-dev-to-prod.md index 956f0ba..0b0b6e4 100644 --- a/Documentation/deploy-dev-to-prod.md +++ b/Documentation/deploy-dev-to-prod.md @@ -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**. --- diff --git a/Documentation/environments.md b/Documentation/environments.md index a0b9e49..670e9dc 100644 --- a/Documentation/environments.md +++ b/Documentation/environments.md @@ -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`. diff --git a/Documentation/i18n-russian.md b/Documentation/i18n-russian.md index e8895ee..165d05b 100644 --- a/Documentation/i18n-russian.md +++ b/Documentation/i18n-russian.md @@ -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` | diff --git a/Documentation/setup.md b/Documentation/setup.md index 6ce04b1..17d83fb 100644 --- a/Documentation/setup.md +++ b/Documentation/setup.md @@ -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,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 | | 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` | diff --git a/client/src/api/client.ts b/client/src/api/client.ts index cf3ad86..b3eec26 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -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(`${API}/artists/${id}/debug-portrait-search`), diff --git a/client/src/components/PaintingDetail.css b/client/src/components/PaintingDetail.css index cc17500..b06b76d 100644 --- a/client/src/components/PaintingDetail.css +++ b/client/src/components/PaintingDetail.css @@ -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; diff --git a/client/src/components/PaintingDetail.tsx b/client/src/components/PaintingDetail.tsx index 5c2f964..9d64945 100644 --- a/client/src/components/PaintingDetail.tsx +++ b/client/src/components/PaintingDetail.tsx @@ -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; onPaintingRemoved?: (paintingId: number, artistId: number) => void | Promise; + 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(null); const [fullscreen, setFullscreen] = useState(false); const [imageVersion, setImageVersion] = useState(0); const [debugSearch, setDebugSearch] = useState(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({ )} )} + {(isCurator || curatorNotes.trim()) && ( +