Author SHA1 Message Date
Danila KhodjaefandClaude Opus 5 1cb9eef935 Rework Byzantine hall as a late-antique basilica interior.
Replace the flat gold-mosaic chapel with marble revetment walls, a
Cosmatesque stone floor, and a coffered timber ceiling, matching
Santa Sabina / San Vitale reference interiors.

New procedural surfaces in galleryProceduralTextures.ts:
- marble-revetment: book-matched veined panels in moulded travertine
  surrounds (each panel mirrored about its centre)
- marble-opus-sectile: cut-stone bays with diagonal diamond inlays and
  corner triangles in porphyry, verd-antique, giallo, and grey
- coffered-wood: recessed walnut coffers with lit beam chamfers

Add a byzantine detail set to MovementHallDetails.tsx: engaged porphyry
colonnettes with white-marble basket capitals and impost blocks carrying
the ceiling, a marble revetment dado, and hanging brass polycandela.
Colonnettes stay flush in the corner pockets so they do not clip the
~0.7m frame hang margin.

Basilicas are lit by shafts from high windows rather than evenly
flooded, but the shared hall lights carry a brightness floor that
prevented dimming. Add an opt-in lightScale to the interior style that
scales the scene, hall, track, and HDR environment lights. It defaults
to 1, so all other halls are unchanged; only Byzantine opts down. Window
fill lights are left unscaled so the daylight shafts still read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:59:56 +03:00
Danila KhodjaefandCursor 1853222e01 Fix movement-hall black walls and column clipping.
Cap shared hall lights (no per-painting spots) so MeshStandard walls stay within WebGL limits, tuck classical/neoclassical details into engaged corner pilasters, and document the light budget.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 00:26:14 +03:00
Danila KhodjaefandCursor 710d262516 Speed up large movement gallery entry (Byzantine)
Hall frames use thumbs only (never multi-MB originals). Preload no longer blocks open; texture downloads are queued; boot overlay no longer waits on HDR Environment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 20:40:06 +03:00
Danila KhodjaefandCursor 0a5918c4dd Add movement artist filter modal and fix large-hall texture blanks
Clicking a movement opens ArtistFilterModal to choose artists before the gallery. Large halls no longer permanently blank frames when texture loads exceed the overlay deadline; fall back thumb to full to on-demand API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 19:05:54 +03:00
Danila KhodjaefandCursor ad1572b3aa Improve movement label placement and lane corridor clearing
Label priority: center, right-end inside, overflow right/left (prefer side with fewer transition-line overlaps), adjacent gap, hover-only. Exclude own portraits from collision. Min stream height 25px. Corridor clearing: 20 passes, prefer above, re-attract after move.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 12:13:55 +03:00
Danila KhodjaefandCursor 8111cd0163 Improve movement flow layout/animation and fix Byzantine hall doors, styles, and preload.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 20:36:55 +03:00
Danila Khodjaef 23e1210e86 Release: weekly deploy 2026-07-27 20:14:51 +03:00
Danila Khodjaef 485c7d3e6f Release: weekly deploy 2026-07-27 19:29:19 +03:00
Danila KhodjaefandCursor 08ff4651e2 Enlarge movement-flow artist portraits to 150% size.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 18:07:58 +03:00
Danila KhodjaefandCursor 0466b77328 Add curator roles/permissions with Users admin, and fix lineage branch joins.
Staff accounts use admin/curator roles and fine-grained flags; transitions connect source-to-target with color gradients and stream cutout masks so overlaps stay seamless.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 18:04:17 +03:00
Danila KhodjaefandCursor bfa21989c9 Require painting thumb regen on every picture change and document the invariant.
Fix disk sync, ensure, and preload paths that previously reused the full image as a thumb.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 14:45:46 +03:00
Danila KhodjaefandCursor 67594ea4d3 Sync checkup flags with image harmonize; tighten gallery collision and exit-to-timeline.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 14:33:36 +03:00
Danila Khodjaef 41d2d5d844 Release: weekly deploy 2026-07-27 13:25:43 +03:00
Danila KhodjaefandCursor 8d3ebcce60 Improve movement flow layout: smaller hover portraits, on-band labels, and clear lineage corridors.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 13:16:20 +03:00
Danila KhodjaefandCursor 89e39d408a Fix harmonize:images thumb rebuild and allow oversized museum scans.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 12:23:25 +03:00
Danila KhodjaefandCursor 6656f91f25 Document Gariff influence workbook and pack timeline lanes by time span.
Add Inputs/gariff_influential_painters_influences.xlsx for curator import, document Inputs/Output conventions, and let non-overlapping movements share horizontal lanes on the timeline.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 10:32:59 +03:00
Danila Khodjaef 8005252881 Release: weekly deploy 2026-07-26 12:14:41 +03:00
Danila KhodjaefandCursor d709e98640 Merge remote main: upside-down influence lamps with local hall fixes.
Keep remote lamp orientation, drop castShadow (shadows stay disabled), and retain curator-note plates plus large-hall loading docs.

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 14:21:22 +03:00
Danila Khodjaef 77befd94b2 Merge branch 'main' of https://gitea.mysuperlab.netcraze.pro/Danilka/Art-gallery 2026-07-22 23:14:09 +03:00
Danila KhodjaefandCursor cc39c2b138 Flip influence-link picture lamps upside down in the 3D hall.
Document the inverted InfluencePictureLamp mount so link indicators match the intended fixture orientation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 23:12:54 +03:00
183 changed files with 5698 additions and 943 deletions
+2 -1
View File
@@ -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=
+9
View File
@@ -13,9 +13,18 @@ infra/deploy/last-docker-release.json
# DB backups (may contain data)
db/DataBackup/
# Generated exports / scratch (CSV dumps, PDF text extracts)
Output/
__pycache__/
# Large local book PDFs (keep workbooks under Inputs/*.xlsx)
Inputs/*.pdf
# Recovery / temp files from local restore
_extracted/
_parse_*.js
tmp-*.js
tmp-*.py
# Logs
*.log
+124 -26
View File
@@ -30,7 +30,7 @@ curl.exe -sk https://devgallery.mysuperlab.netcraze.pro/api/bounds
## Authentication
Anonymous visitors have implicit role **`user`** (browse only). **Curator** accounts unlock debug mode, the Checkup page, and all mutating audit routes.
Anonymous visitors have implicit role **`user`** (browse only). Staff accounts in `users` are **`admin`** or **`curator`** with fine-grained **permissions**. Admins have all tools; curators only the flags assigned to them. Mutations are logged in `curator_audit_log` with `user_id`.
Sessions use an HTTP-only cookie (`gallery.sid`). The client sends `credentials: 'include'` on API requests.
@@ -42,19 +42,25 @@ Sessions use an HTTP-only cookie (`gallery.sid`). The client sends `credentials:
{ "role": "user" }
```
**Response (curator session)**
**Response (staff session)**
```json
{ "role": "curator", "username": "curator" }
{
"role": "admin",
"username": "curator",
"permissions": ["images", "checkup", "curator_notes", "translations", "influences", "tours", "users"]
}
```
`permissions` is the effective set (admins always receive the full list).
### `POST /api/auth/login`
**Body:** `{ "username": "curator", "password": "…" }`
**Response:** `{ "role": "curator", "username": "curator" }`
**Response:** same shape as `/me` for staff (`role`, `username`, `permissions`).
**Errors:** `401` invalid credentials, `400` missing fields.
**Errors:** `401` invalid credentials or disabled account, `400` missing fields.
### `POST /api/auth/logout`
@@ -62,29 +68,57 @@ Destroys the session cookie.
**Response:** `{ "ok": true }`
### Curator-only routes
### Permission flags
These return **`401`** with `{ "error": "Curator login required" }` without a valid curator session:
| Permission | Gates |
|------------|--------|
| `images` | Debug image/portrait fix/clear/upload/delete, debug search/proxy |
| `checkup` | Checkup page + checkup flag patches |
| `curator_notes` | `PATCH …/curator-notes` |
| `translations` | `/api/translations/*` |
| `influences` | `/api/influences/*` |
| `tours` | Tour admin CRUD |
| `users` | `/api/users/*` (Users page) |
| Route | Audit action (mutations only) |
|-------|-------------------------------|
| `GET /api/paintings/checkup` | — (read) |
| `GET /api/paintings/:id/debug-image-search` (+ `/more`) | — |
| `GET /api/artists/:id/debug-portrait-search` (+ `/more`) | — |
| `GET /api/debug/image-proxy` | — |
| `PATCH /api/paintings/:id/checkup-flags` | `painting.checkup_flags` |
| `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` |
| `POST /api/paintings/:id/upload-image` | `painting.upload_image` |
| `DELETE /api/paintings/:id` | `painting.delete` |
| `POST /api/artists/:id/fix-portrait` | `artist.fix_portrait` |
| `POST /api/artists/:id/clear-portrait` | `artist.clear_portrait` |
| `POST /api/artists/:id/upload-portrait` | `artist.upload_portrait` |
Missing session → **`401`** `{ "error": "Curator login required" }`. Missing permission → **`403`** `{ "error": "Permission denied" }`.
**Public** (no login): all catalog `GET` routes, `POST /api/artists/:id/preload-images` (local file linking for 3D halls), `/images`, SPA static.
### Users (admin / `users` permission)
Curator mutations are recorded in `curator_audit_log` (see [DB_structure.md](DB_structure.md)).
| Method | Path | Notes |
|--------|------|-------|
| `GET` | `/api/users` | List users + known permission keys |
| `POST` | `/api/users` | Create `{ username, password, role, permissions }` |
| `PATCH` | `/api/users/:id` | Update `role`, `permissions`, `is_active` |
| `POST` | `/api/users/:id/password` | Set new password; clears that users sessions |
Only **admins** can create or promote **admin** accounts. Cannot deactivate/demote the last active admin. Audit: `user.create`, `user.update`, `user.reset_password`.
### Staff-gated routes
| Route | Permission | Audit action (mutations only) |
|-------|------------|-------------------------------|
| `GET /api/paintings/checkup` | `checkup` | — (read) |
| `GET /api/paintings/:id/debug-image-search` (+ `/more`) | `images` | — |
| `GET /api/artists/:id/debug-portrait-search` (+ `/more`) | `images` | — |
| `GET /api/debug/image-proxy` | `images` | — |
| `PATCH /api/paintings/:id/checkup-flags` | `checkup` | `painting.checkup_flags` |
| `PATCH /api/paintings/:id/curator-notes` | `curator_notes` | `painting.update_curator_notes` |
| `PATCH /api/artists/:id/checkup-flags` | `checkup` | `artist.checkup_flags` |
| `POST /api/paintings/:id/fix-image` | `images` | `painting.fix_image` |
| `POST /api/paintings/:id/clear-image` | `images` | `painting.clear_image` |
| `POST /api/paintings/:id/upload-image` | `images` | `painting.upload_image` |
| `DELETE /api/paintings/:id` | `images` | `painting.delete` |
| `POST /api/artists/:id/fix-portrait` | `images` | `artist.fix_portrait` |
| `POST /api/artists/:id/clear-portrait` | `images` | `artist.clear_portrait` |
| `POST /api/artists/:id/upload-portrait` | `images` | `artist.upload_portrait` |
| `/api/translations/*` | `translations` | `translation.*` |
| `/api/influences/*` | `influences` | `influence.*` |
| Tour admin (`/api/tours/admin`, POST/PATCH/DELETE, stops) | `tours` | `tour.*` |
| `/api/users/*` | `users` | `user.*` |
**Public** (no login): all catalog `GET` routes, `POST /api/artists/:id/preload-images`, `POST /api/movements/:id/preload-images` (local file linking for 3D halls), `/images`, SPA static.
Staff mutations are recorded in `curator_audit_log` with `user_id` (see [DB_structure.md](DB_structure.md)).
---
@@ -339,9 +373,38 @@ Artists belonging to a single movement.
---
## `GET /api/movements/:id/artists-summary`
Lightweight artist list for the **movement gallery entry filter** modal (portraits + painting counts).
**Response** — array of:
```json
{
"id": 6,
"name": "Andrei Rublev",
"birth_year": 1360,
"death_year": 1430,
"portrait_path": "portraits/...",
"portrait_thumb_path": "portraits/thumbs/...",
"portrait_cache_key": 1710000000000,
"portrait_thumb_cache_key": 1710000000000,
"painting_count": 12
}
```
| Field | Meaning |
|-------|---------|
| `painting_count` | Number of paintings by this artist (any movement filter is by `artists.movement_id`) |
| `portrait_*_cache_key` | File mtimes for cache-busting (from `enrichArtistRow`) |
Ordered by `birth_year`, then name. Localized when `?lang=` / locale headers request a non-default locale.
---
## `GET /api/movements/:id/gallery`
Full **movement gallery** payload for the 3D movement wings view (opened by clicking a movement name on the home page).
Full **movement gallery** payload for the 3D movement wings view (opened by clicking a movement name on the home page, after the artist-filter modal).
**Response**
@@ -563,7 +626,7 @@ Both lists are grouped by art movement and exclude the current artist. Each arti
**Public** — no curator login required.
Fast local scan: links paintings to files already on disk. Does **not** download from the internet. The 3D client calls this automatically when entering an **artist** hall.
Fast local scan: links paintings to files already on disk and regenerates missing thumbs. Does **not** download from the internet. The 3D client calls this automatically when entering an **artist** hall.
**Response**
@@ -575,6 +638,20 @@ Fast local scan: links paintings to files already on disk. Does **not** download
---
## `POST /api/movements/:id/preload-images`
**Public** — no curator login required.
Same local-only scan as the artist preload, for every painting by artists in the movement. The 3D client calls this automatically when entering a **movement** hall.
**Response**
```json
{ "fetched": 40, "total": 45 }
```
---
## `GET /api/paintings/:id`
Painting detail with influence graph neighbours.
@@ -593,6 +670,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 +776,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.
@@ -733,6 +827,8 @@ Google-family image search for debug / checkup (Custom Search → Google Arts &
Download a remote URL and replace the paintings local full image + thumbnail. Sets `checkup_fixed = true` and `checkup_checked = true`.
**Invariant:** every painting picture write (fix, upload, fetch, disk sync) must regenerate a dedicated `paintings/thumbs/*_thumb.jpg` from the full file — never reuse a remote thumb URL or point `thumbnail_path` at the full image. See [data-and-images.md — Painting thumbnail invariant](data-and-images.md#painting-thumbnail-invariant).
Uses `downloadImageForFix` in `scripts/image-fetcher.js` (browser User-Agent, referer fallbacks, Wikimedia URL upgrades) for reliable downloads from Google Arts, Commons, etc.
**Body**
@@ -868,6 +964,7 @@ The React client wraps these endpoints in `client/src/api/client.ts`. All reques
| `api.getArtistNavigation(id)` | `GET /api/artists/:id/navigation` |
| `api.getPainting(id)` | `GET /api/paintings/:id` |
| `preloadArtistImages(id)` | `POST /api/artists/:id/preload-images` |
| `preloadMovementImages(id)` | `POST /api/movements/:id/preload-images` |
| `imageUrl(path, revision?)` | `/images/<path>` or placeholder; optional `?v=` cache buster |
| `paintingImageRevision(painting, sessionRevision?)` | Prefer API `image_cache_key` / `thumbnail_cache_key`, else in-session counter |
| `galleryImageUrl(painting, sessionRevision?)` | Local thumb/full only (3D); auto `?v=` from cache keys |
@@ -877,6 +974,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` |
+18 -9
View File
@@ -104,7 +104,8 @@ Phases within an artists 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 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.
Applied by `npm run dev:migrate:checkup-flags` (`db/migrate-checkup-flags.sql`).
Applied by `npm run dev:migrate:checkup-flags` (`db/migrate-checkup-flags.sql`). `curator_notes` is applied by `db/migrate-curator-notes.sql` via `npm run dev:migrate`.
### `painting_annotations`
@@ -160,8 +161,9 @@ Curated guided tours. See [tours.md](tours.md).
| `painting_id` | FK → `paintings` | ON DELETE CASCADE; UNIQUE with `tour_id` |
| `sort_order` | INTEGER | Visitor / editor order |
| `body` | TEXT | English tour notes for the stop (v1) |
| `updated_at` | TIMESTAMPTZ | Trigger on UPDATE; required for `npm run harmonize:db` |
Applied by `npm run dev:migrate` (`db/migrate-tours.sql`).
Applied by `npm run dev:migrate` (`db/migrate-tours.sql`; `updated_at` also covered by `migrate-sync-timestamps.sql` when the table already exists).
### `painting_influences`
@@ -213,34 +215,41 @@ Unique index on `(painting_id, source_type, source_painting_id, source_artist_id
### `users`
Curator accounts (named logins). Anonymous site visitors do not have rows here.
Staff accounts (named logins). Anonymous site visitors do not have rows here. Migration: `db/migrate-auth.sql` + `db/migrate-user-roles.sql`.
| Column | Type | Notes |
|--------|------|-------|
| `id` | SERIAL PK | |
| `username` | VARCHAR(64) UNIQUE | Login name |
| `password_hash` | VARCHAR(255) | bcrypt hash |
| `role` | VARCHAR(32) | `admin` or `curator` (`users_role_check`) |
| `permissions` | TEXT[] | Fine-grained flags for `curator` accounts; admins are treated as having all |
| `is_active` | BOOLEAN | Soft-disable; inactive users cannot log in |
| `created_at` | TIMESTAMPTZ | |
| `last_login_at` | TIMESTAMPTZ | Updated on successful login |
First curator is bootstrapped on `npm run dev:migrate` when `users` is empty and `CURATOR_USERNAME` / `CURATOR_PASSWORD` are set in env.
**Permission keys:** `images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`.
- **`admin`** — all curator tools + **Users** management (role bypasses permission checks).
- **`curator`** — only assigned permission flags.
- First account is bootstrapped as **admin** on `npm run dev:migrate` when `users` is empty and `CURATOR_USERNAME` / `CURATOR_PASSWORD` are set. Manage additional accounts via the in-app **Users** page or `/api/users`.
### `curator_audit_log`
Append-only log of curator debug mutations (fix/clear/upload/delete, checkup flag changes).
Append-only log of staff mutations (fix/clear/upload/delete, checkup flags, translations, influences, tours, user management).
| Column | Type | Notes |
|--------|------|-------|
| `id` | BIGSERIAL PK | |
| `user_id` | FK → `users` | Who performed the action |
| `action` | VARCHAR(64) | e.g. `painting.fix_image`, `artist.upload_portrait` |
| `resource_type` | VARCHAR(32) | `painting` or `artist` |
| `action` | VARCHAR(64) | e.g. `painting.fix_image`, `user.create` |
| `resource_type` | VARCHAR(32) | `painting`, `artist`, `tour`, `user`, etc. |
| `resource_id` | INTEGER | Target row id |
| `details` | JSONB | Optional metadata (URL, mime type, flag values) |
| `ip_address` | VARCHAR(45) | Client IP (respects `TRUST_PROXY`) |
| `created_at` | TIMESTAMPTZ | |
**Logged `action` values:** `painting.fix_image`, `painting.clear_image`, `painting.upload_image`, `painting.delete`, `painting.checkup_flags`, `artist.fix_portrait`, `artist.clear_portrait`, `artist.upload_portrait`, `artist.checkup_flags`, `translation.upsert`, `translation.publish`, `influence.create`, `influence.update`, `influence.delete`, `influence.import`.
**Logged `action` values:** `painting.fix_image`, `painting.clear_image`, `painting.upload_image`, `painting.delete`, `painting.checkup_flags`, `painting.update_curator_notes`, `artist.fix_portrait`, `artist.clear_portrait`, `artist.upload_portrait`, `artist.checkup_flags`, `translation.upsert`, `translation.publish`, `influence.create`, `influence.update`, `influence.delete`, `influence.import`, `tour.create`, `tour.update`, `tour.delete`, `tour.stops`, `user.create`, `user.update`, `user.reset_password`.
Example query in pgAdmin:
+25 -8
View File
@@ -23,7 +23,7 @@ Details: [environments.md](environments.md) · Deploy: [../infra/docker/DEPLOY-t
### Start — public dev (Keenetic URL)
```powershell
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
cd T:\Repo\Gallery
npm run dev:web
```
@@ -77,12 +77,13 @@ Stop-Process -Id <PID> -Force
## First-time install
```powershell
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
cd T:\Repo\Gallery
copy .env.example .env # edit DB credentials, PUBLIC_URL
npm install
cd client; npm install; cd ..
npm run dev:migrate # schema + incremental SQL (+ auth tables, bootstrap curator)
npm run dev:reset-curator # upsert curator password from .env CURATOR_* (clears sessions)
npm run dev:setup # migrate + seed (fresh empty DB only)
```
@@ -94,14 +95,19 @@ 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 tools allowed by your role/permissions (debug, Checkup, Translations, Influences, Tour editor, **Users**). Mutations are logged in `curator_audit_log` per user (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; reset upserts the env account as **admin**).
**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 | Public browse + assigned permission flags (`images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`) |
| Admin | All curator tools + **Users** page to create accounts with individual passwords and permissions |
**Users page:** after admin login, header → **Users** — create/edit staff, reset passwords, disable accounts.
**Audit log (pgAdmin on `gallery_dev` or `gallery_prod`):**
@@ -140,7 +146,7 @@ Run `npm run dev:migrate` against prod DB after first deploy with auth vars set
| `npm run devtoprod:db:restore -- --file <path>` | Restore into **prod** (requires confirmation) |
| `npm run harmonize` | Bidirectional catalog DB + image merge by `updated_at` / file mtime — [harmonize-dev-prod.md](harmonize-dev-prod.md) |
| `npm run harmonize:schema` | Apply dev migrations to prod schema only (dev → prod) |
| `npm run harmonize:db` / `harmonize:images` | DB or image merge only |
| `npm run harmonize:db` / `harmonize:images` | DB or image merge only (`harmonize:images` also merges artists/paintings checkup flags + image paths, then regenerates thumbs on both sides) |
| `npm run dev:backfill-updated-at` | Backfill catalog `updated_at` from image mtimes (dev) |
**One-time split (recommended):** pgAdmin on dev PC → open [`db/split-dev-prod-pgadmin.sql`](../db/split-dev-prod-pgadmin.sql) → run each STEP on database `postgres`, then verify on `gallery_dev`.
@@ -152,7 +158,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
```
@@ -210,7 +216,9 @@ npm run dev:discover-influences # discovery only, no curated insert
| `npm run dev:find-duplicates` | Duplicate / near-duplicate painting rows |
| `npm run dev:audit-influence-duplicates` | Duplicate influence edges |
| `npm run dev:analyze-painter-palette` | CSV ↔ artist name match report |
| `npm run dev:export-paintings` | Write `Output/paintings.csv` |
| `npm run dev:export-paintings` | Write `Output/paintings.csv` (gitignored folder) |
**Influence workbooks** (curator import): see [influence-import.md](influence-import.md) — e.g. `Inputs/gariff_influential_painters_influences.xlsx`, `Inputs/story_of_art_influences.xlsx`.
---
@@ -240,11 +248,20 @@ net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER
npm run devtoprod:release # full promote from infra/deploy/devtoprod.config.json
npm run devtoprod:thumbnails # rebuild thumb files + DB paths on dev (before backup)
npm run devtoprod:images # dev repo → TrueNAS (promote / first deploy)
npm run prodto:dev:images # TrueNAS → dev repo
npm run prodto:dev:images # TrueNAS → dev repo (all images)
npm run harmonize # bidirectional merge (newer wins) — see harmonize-dev-prod.md
npm run harmonize:dry-run # preview DB + image changes only
```
**One artist only (prod → dev):** after `net use`, robocopy the artist file prefix (example: Duccio):
```powershell
$src = "\\192.168.10.122\Gallery\data\images\paintings"
$dst = "T:\Repo\Gallery\data\images\paintings"
robocopy $src $dst "Duccio*" /XO /R:2 /W:3
robocopy "$src\thumbs" "$dst\thumbs" "Duccio*" /XO /R:2 /W:3
```
Type `yes` when prompted (or set `autoConfirm: true` in release config). Robocopy exit codes **07** = success. Deploy scripts print a final **`===== SUCCESS =====`** or **`===== FAILED =====`** banner.
---
+8
View File
@@ -1,5 +1,12 @@
this file contains draft for future releases and features
## Standing requirements (do not regress)
- **Painting thumbnails:** any create/replace/clear of a paintings full picture must regenerate or remove its dedicated `paintings/thumbs/` file — never use the full image or a remote thumb URL as `thumbnail_path`. See [data-and-images.md — Painting thumbnail invariant](data-and-images.md#painting-thumbnail-invariant).
- **Staff auth:** mutating curator tools must check session + permission flags (`admin` bypasses flags); actions must log to `curator_audit_log` with `user_id`. See [basics.md — User roles](basics.md#user-roles-and-access) and [API.md — Authentication](API.md#authentication).
## Feature backlog
1. ~~Multi language support, russian version at least~~ — done: UI i18n (EN/RU) + `entity_translations` DB + curator Translations tool — [i18n-russian.md](i18n-russian.md)
2. ~~tool to manage links (influence/influenced by ) import csv's ( define format), edit ,add, delete, visualize, map to pictures/ entities~~ — done: curator Influences page (list CRUD + import wizard CSV/JSON/XLSX + neighborhood graph) — [influence-import.md](influence-import.md)
3. tool to monitor/manage (plan actions) of curator actions, markers to check painting/text ?
@@ -7,4 +14,5 @@ this file contains draft for future releases and features
5. ~~curator_audit_log should contain log of actions like fixit, checked, upload etc with details for which entity it was made and details what was the action and outcome~~ — done: table + `logCuratorAction` on fix/clear/upload/delete/checkup flags (and translation upsert/publish); see [DB_structure.md](DB_structure.md#curator_audit_log). (UI to browse logs is still item 3.)
6. ~~create search by entity (painting, artist, movement)~~ — done: timeline header + `GET /api/search`
7. ~~create guided tours (with text/extra infor, set of entities)~~ — done: `tours` / `tour_stops`, public Tours popup + 3D tour hall, curator Tour editor — [tours.md](tours.md)
8. ~~curator role + multi-user accounts with permissions~~ — done: `admin`/`curator` roles, permission flags, Users page + `/api/users`, per-user audit — [API.md](API.md#authentication) / [basics.md](basics.md#user-roles-and-access)
+68 -47
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.
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 → artist filter → hall), 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.
@@ -76,8 +76,8 @@ Gallery/
│ ├── painter-palette-lib.js
│ ├── import-painter-palette.js
│ └── image-fetcher.js
├── Inputs/ # External datasets (e.g. PainterPalette.csv)
├── Output/ # Generated exports (e.g. paintings.csv)
├── Inputs/ # External datasets & influence workbooks (PainterPalette, book extracts, …)
├── Output/ # Generated exports (CSV dumps, scratch extracts) — gitignored
├── data/images/ # Local portraits and paintings (+ thumbs/)
├── db/ # schema.sql, setup-admin.sql, migrate-*.sql
├── server/migrate.js # npm run dev:migrate — schema + incremental migrations
@@ -209,9 +209,9 @@ Pan, zoom, and era/event click-to-zoom only update **local** `viewStart` / `view
|--------|------|
| Overlay **“Loading art history…”** | Until the first catalog fetch (`bounds` + `timeline` + `artists`) completes |
| Bottom banner **“Loading portraits…”** | While artist portrait thumbnails are still downloading on the movement flow (timeline stays interactive) |
| Overlay **“Opening artist/movement gallery…”** | Between clicking a portrait/movement and the 3D hall data being ready |
| Overlay **“Loading gallery…”** | While the 3D canvas initializes after the hall opens (center area; header and controls stay visible) |
| Overlay **“Loading paintings…”** | While wall textures are still downloading in the 3D hall |
| Overlay **“Opening artist/movement gallery…”** / **“Loading artists…”** | Between clicking a portrait/movement and the 3D hall (or artist-filter modal) data being ready |
| Overlay **“Loading gallery…”** | While the 3D canvas initializes and door/hall shaders warm up after the hall opens (HDR Environment and painting textures continue in the background) |
| Overlay **“Loading paintings…”** | Brief counter while wall painting textures start downloading; large halls (e.g. Byzantine) keep loading after the overlay dismisses — a slow image no longer permanently blanks the frame |
| Overlay **“Restoring gallery…”** | Briefly after WebGL context loss while the canvas remounts |
View updates are **batched to one commit per animation frame** via `createViewChangeScheduler()` in `timelineView.ts` (`HomePage.tsx``handleViewChange`), so rapid scroll-wheel events do not flood React with separate renders.
@@ -241,16 +241,17 @@ Major world events appear on the era bar as pin markers (single years) or shaded
### Movement flow
Each visible movement is drawn as a **portrait-width curved stream** (~54 px stroke, compressed when many rows are visible) from its start year to its end year.
Each visible movement is drawn as a **portrait-width curved stream** (~54 px stroke, compressed when many rows are visible) in a **solid vivid colour** from its start year to its end year.
| Feature | Implementation |
|---------|----------------|
| Lineage layout | `client/src/data/movement-lineage.ts` — curated predecessor→successor pairs (Met / ArtStory / museum essays); multiple parents allowed |
| Vertical depth | Successor movements sit on rows below their deepest parent; sibling movements at the same depth are spread into lanes to limit overlap |
| Branch connectors | Smooth curves from the **centre** of a parent stream to the **centre** of each child stream (siblings fan out along the parents length) |
| Visual blending | Path-aligned SVG gradients with transparent fades at stream ends and branch junctions; streams draw on top of branches so overlap brightness stays uniform |
| Vertical lanes | Movements share a horizontal lane only when one ends at least **10 years** before the next starts (in the current zoom); closer or overlapping spans stack into extra rows (`assignTemporalLanes`). Lane choice **minimizes vertical branch length**: children prefer parent lanes (same row when years allow), corridor clearing (up to 20 passes) evicts unrelated streams preferring **rows above** the parent/child strip, then re-attracts the child toward the parent |
| Branch connectors | Smooth curves from fan-out points along a parent stream to the **left edge (start)** of each child stream; origin X is always strictly left of the target (time-forward only — never right→left); stroke thickness matches the **target** (child) band height; color gradients from parent → child at constant opacity; a stream-shaped mask hides branch ink under movements so translucent overlaps do not brighten the bands; pan/zoom layout changes **animate** (streams + branches chase new geometry) so shifts stay trackable |
| Band thickness | Stream height is **proportional to `influence_link_count`** (edges on paintings by artists in that movement): thicker bands for denser influence graphs; lane rows share vertical space weighted by the thickest band in each lane |
| Filtering | A movement is drawn when its **span overlaps** the visible year range **and** it has at least one catalogued artist — artists whose lifespan falls outside the window still keep their movement visible (their portraits simply do not render). Filtered client-side after initial load |
| Viewport layout | Row height and stream width scale from measured canvas size so every visible movement row fits in the remaining screen space |
| Name labels | On-band **movement name** only. Label placement priority: **(1)** center of movement if label fits and no portrait collision; **(2)** right end inside band borders; **(3)** overflow right or left (up to 100% outside the band border), choosing the side with less transition-line overlap; **(4)** adjacent free gap before/after the band; **(5)** hidden until hover. Collision checks exclude the movement's own portraits (labels may overlap their own band's artists). Movements with overflow labels are packed onto **exclusive horizontal lanes** before vertical stacking so they never share a row with other movements. Minimum movement height = 125% of label height |
### Artists on movement streams
@@ -271,9 +272,9 @@ Each artist appears as a **portrait circle** on their movements stream row:
| Scroll wheel anywhere on flow canvas | Zoom (same year range as timeline; works over portraits and labels too) |
| Drag on flow canvas | Pan |
| Click portrait | Open artist biography |
| Click **movement name** (label on stream) | Open **movement gallery** for that movement |
| Click **movement name** (label on stream) | Open the **artist filter** modal for that movement, then the movement gallery |
**Zoom/pan performance:** The movement canvas uses a **capture-phase** wheel listener so scroll zoom works even when the cursor is over a portrait. While scrolling or dragging, a short **interaction mode** (~120 ms after the last input) draws simplified solid SVG strokes and temporarily hides portrait/label DOM so zoom stays responsive; full gradients and portraits return when you stop.
**Zoom/pan performance:** The movement canvas uses a **capture-phase** wheel listener so scroll zoom works even when the cursor is over a portrait. While scrolling or dragging, a short **interaction mode** (~120 ms after the last input) draws simplified solid SVG strokes and temporarily hides portrait/label DOM so zoom stays responsive; full stream styling and portraits return when you stop.
Only **mousedown** on portraits and movement labels stops propagation (so drag-to-pan does not start when clicking them). Hovering a portrait highlights the artists lifespan on the era bar and brightens their segment on the movement stream.
@@ -283,7 +284,7 @@ Only **mousedown** on portraits and movement labels stops propagation (so drag-t
The 3D scene supports three modes in `VirtualGallery.tsx`: **artist halls** (personal catalog), **movement galleries** (full movement collection, chronological), and **guided tours** (curator-ordered stops — [tours.md](tours.md)).
**Shared wall hang (all modes):** visit order fills the **left wall first**, then the **right**. The **first** work hangs near the entrance on the left (immediately left of the opening view); the **last** hangs near the entrance on the right. Artist halls use chronological order; movement wings use chronological order within each wing; tours use stop order.
**Shared wall hang (all modes):** visit order is a **U-shape****left wall**, then the **far/end wall** ahead when entering, then the **right wall**. The **first** work hangs near the entrance on the left (immediately left of the opening view); the **last** hangs near the entrance on the right. With fewer than three works, only left/right are used. Artist halls use chronological order; movement wings use chronological order within each wing; tours use stop order.
### Artist halls
@@ -293,15 +294,17 @@ Each artist has **exactly one hall**. The hall is a rectangular room sized to fi
|------|----------------|
| One hall per artist | `VirtualGallery.tsx` builds a single room from that artists paintings |
| 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 artists **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) |
| Influence lamps | A golden picture light appears **above frames** whose work has any influence-graph edge (`has_influence_links` from the API); fixture is mounted **upside down** and is **emissive-only** (no per-frame lights — see light budget below) |
| 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 |
| Open centre | Floor and ceiling only — no freestanding columns or pedestals in the walkway |
| Museum exit | Front-wall **double doors** with transom, brass hardware, sconces, marble threshold, and warm vestibule glow |
| Shadows | **Disabled** — no Canvas shadow maps / `castShadow` (performance; flat lighting only) |
| Hall-to-hall travel | Exit opens a panel: **Predecessors** (left) and **Successors** (right), each grouped by art movement |
| Missing images | Works without a local file show a **draped canvas cover** in the frame (not a blank white rectangle) |
| Detail view return | Opening a painting close-up **keeps the 3D hall mounted** in the background so position and view direction are preserved when you go back |
@@ -323,23 +326,24 @@ Predecessors and successors come from **`painting_influence_sources`** (painting
### Movement galleries
Enter from the home page by clicking a **movement name** on the movement flow (`MovementBands.tsx``GET /api/movements/:id/gallery`).
Enter from the home page by clicking a **movement name** on the movement flow. First a centered **artist filter** modal (`ArtistFilterModal.tsx`) loads `GET /api/movements/:id/artists-summary` (portrait, lifespan, painting count). All artists are selected by default; deselect any to exclude their works, then **Enter gallery**. The client loads `GET /api/movements/:id/gallery` and filters paintings to the selected artist IDs before opening `VirtualGallery`.
| Rule | Implementation |
|------|----------------|
| One gallery per movement | All paintings by artists in that movement, sorted chronologically |
| One gallery per movement | Paintings by **selected** 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** — single-wing halls keep the far wall solid (full span); multi-wing halls hang end-wall works on panels beside the back exit |
| 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 |
| Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, Byzantine basilica, NYC loft, white cube, etc.). Style keys use a legacy id map (`DB_ID_TO_STYLE_KEY`: gallery ids **126** → authored keys **2752**, with Northern/High Renaissance swapped) so Byzantine gets the basilica, not Gothic stone |
| Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco, **marble revetment** (framed book-matched panels), **Cosmatesque paving** (`marble-opus-sectile`), **coffered timber** ceilings — 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 |
| Textures | Wall/floor/ceiling maps applied via `useTexturedMaterial`; movement **tints** drive painted-wall hue |
| Windows | **Side walls only** — placed in gaps between frames (high on the wall, no overlap with paintings); style matches the movement era |
| Lighting | Daylight from windows + ceiling track lights + ambient/sun fill |
| Back wall | **Exit double doors****Wing navigator** (jump to any wing) or **Exit to Timeline** |
| Front wall | Open **“Next wing →”** archway when a later wing exists; walk through or press `E` when near |
| Influence lamps | Same golden lamps as artist halls when `has_influence_links` is true |
| Windows | **Side walls only** — placed in gaps between frames when possible; if a wall is packed, high **clerestory** windows are still added so the hall keeps daylight (`computeSideWallWindows`) |
| Lighting | Shared hall lights only (ambient / hemisphere / directional + capped ceiling track spots + one fill per window). See **light budget** below |
| Period details | `MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** (never freestanding mid-hall or proud corner shafts that cover frames); `byzantine` adds engaged porphyry colonnettes with basket capitals, a marble revetment dado, and hanging brass polycandela |
| Back wall | Single wing: solid display wall. Multi-wing: **Exit double doors****Wing navigator** or **Exit to Timeline** |
| Front / entrance wall | Single wing: **Exit double doors** (leave the way you entered). Multi-wing: **“Next wing →”** archway when a later wing exists |
| Influence lamps | Same golden upside-down emissive fixtures as artist halls when `has_influence_links` is true |
| Missing images | Draped canvas cover in frame |
| Detail return | **Back to Gallery** from painting detail returns to the same wing with camera preserved; **Back to Timeline** exits the hall entirely |
@@ -349,8 +353,8 @@ Enter from the home page by clicking a **movement name** on the movement flow (`
|-------|--------|
| Walk / turn / drag | Same as artist hall |
| Click painting | Open detail view (returns to the same wing) |
| Back wall / `E` / **Wings / Exit →** | Open wing navigator |
| Front archway / `E` (when near) | Advance to the **next chronological wing** |
| Entrance / back door / `E` / **Wings / Exit →** | Open wing navigator (or exit on single-wing halls) |
| Front archway / `E` (when near, multi-wing) | Advance to the **next chronological wing** |
Movement galleries do **not** use the predecessor/successor influence picker — that remains artist-hall only.
@@ -362,7 +366,7 @@ Enter from the home page **Tours** popup (`GET /api/tours/:id`). Layout reuses t
|------|----------------|
| Visit order | Curator `sort_order` on `tour_stops` (not chronological) |
| Wings | Same ~55-per-wing split as movements; order preserved across wings |
| Wall hang | Same left-then-right rule as other halls |
| Wall hang | Same U-shaped hang as other halls (left → end → right) |
| Detail | Tour stop text panel; walks tour stops |
| Exit | Wing navigator / **Exit to Timeline** (no influence picker) |
@@ -370,7 +374,17 @@ Full guide: [tours.md](tours.md).
### Shared 3D behaviour
**3D images** use locally cached files only (`galleryImageUrl` in `client/src/api/client.ts`). Remote fetches are too slow for realtime WebGL textures; the client calls `POST /api/artists/:id/preload-images` automatically when entering an **artist** hall (public route — links disk files only). Movement galleries load painting lists from the API without a separate preload step. While a texture is loading, the frame shows the canvas cover instead of a white placeholder. The center of the hall shows **“Loading gallery…”** until the WebGL canvas is ready, then **“Loading paintings…”** until every wall texture has resolved (tracked through `GalleryTextureLoadContext`). The 3D hall stays mounted while painting detail or bio overlays are open; returning remounts the canvas when the hall becomes active again.
**Light budget (critical):** Three.js forward shading cannot compile `MeshStandardMaterial` with dozens of dynamic lights. Large wings used to add **one `spotLight` per painting** (plus influence lamps), which pushed the scene to ~100200 lights — walls, floor, and ceiling then rendered as a black void while painting canvases (`MeshBasicMaterial`) still showed. Halls therefore use **shared lighting only**: ambient / hemisphere / directional fill, a small set of ceiling track spots (`GalleryTrackLights`, capped), and **one** window spot each. Painting canvases stay unlit (`MeshBasicMaterial`); frames and influence fixtures use **emissive** accents instead of per-frame lights. Intensities are scaled for three.js physical lights (post-r155).
Shared lights carry a **brightness floor** so dark period styles never render as a black void. A style that is *meant* to be dim (a basilica lit by window shafts, not flooded) sets **`lightScale`** in `movement-interior-styles.ts` — it multiplies the scene ambient/hemisphere/directional/fill lights, the hall point lights, the ceiling track spots, and the HDR `environmentIntensity`. It defaults to **1**, so only styles that opt in are affected. Window fill lights are deliberately **not** scaled, so the daylight shafts still read against the darker room.
**Period architecture:** Freestanding columns must not sit in the walkway or in the corner pocket in front of frames (hang margin is only ~0.7 m from each wall end). Classical / neoclassical details use **engaged corner pilasters** flush to the walls (`MovementHallDetails.tsx`).
**Surface textures:** Procedural colour maps use sRGB; **normal maps use `NoColorSpace`** (`galleryProceduralTextures.ts`). Wrong colour space on normals breaks wall shading.
**3D images** prefer local **thumbnail** files (`galleryImageUrlCandidates` in `client/src/api/client.ts`: thumb → `GET /api/paintings/:id/image?size=thumb`; full originals are **not** used for hall frames — they can be tens of MB and made large halls take ~1 minute). Remote Wikipedia fetches are too slow for realtime WebGL textures. Entering an **artist** or **movement** hall fires `POST /api/artists/:id/preload-images` or `POST /api/movements/:id/preload-images` in the **background** (does not block hall open). Texture downloads are **queued** (max 8 parallel). While a texture is loading, the frame shows the canvas cover instead of a white placeholder. A per-image deadline only releases the boot overlay counter — it does **not** permanently blank the frame if the download finishes later.
**Boot overlay** (`VirtualGallery.tsx`): the center shows **“Loading gallery…”** until the WebGL canvas is ready and a short `gl.compileAsync` warm-up finishes (so entrance doors / passages do not hitch on the first turn). HDR `Environment` loads in a Suspense boundary **without** blocking the overlay. Painting textures keep loading in the background. The 3D hall stays mounted while painting detail or bio overlays are open; returning remounts the canvas when the hall becomes active again.
**WebGL context-loss recovery:** on some GPUs/drivers (notably certain Chrome setups) the browser can drop the WebGL context right after entering a hall, which would otherwise leave a permanent dark window. `VirtualGallery.tsx` listens for `webglcontextlost` / `webglcontextrestored`, calls `preventDefault()` so the browser can restore the context, and remounts the `<Canvas>` with a fresh context (a **“Restoring gallery…”** overlay shows briefly). The network-loaded HDR `Environment` map is wrapped in an error boundary so, if it fails to load, the hall still renders without reflections instead of unmounting the whole scene.
@@ -382,6 +396,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 |
@@ -425,10 +440,10 @@ Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens
- **Guided tours** add curator-ordered winged halls with stop text on painting detail — [tours.md](tours.md).
- **Wall hang** is shared: first work on the left at the entrance, last on the right.
- **Influence-based hall links** connect artists through documented painting relationships, grouped by movement at the exit.
- **3D gallery images** use locally cached files only; slow remote fetches would break realtime rendering. The client calls `POST /api/artists/:id/preload-images` automatically when entering an **artist** hall (public route — links disk files only).
- **3D gallery images** use local **thumbnails** for hall frames (not multi-MB originals). `POST /api/artists/:id/preload-images` / `POST /api/movements/:id/preload-images` run in the background when entering a hall (public routes — link disk files and regenerate missing thumbs).
- **3D gallery session** stays mounted while painting detail or bio overlays are open; returning to the hall remounts the WebGL canvas when it becomes active again.
- **3D gallery resilience:** a lost WebGL context is recovered by remounting the canvas with a fresh context (rather than showing a dark window), and the HDR environment map is isolated behind an error boundary so its failure never blanks the scene.
- **Loading feedback:** `GalleryLoadingMarker` surfaces catalog load, portrait download, gallery entry, painting-texture load, and context-restore states so the user always knows work is still in progress.
- **Loading feedback:** `GalleryLoadingMarker` surfaces catalog load, portrait download, gallery entry, painting-texture / GPU warm-up, Environment settle, and context-restore states so the user always knows work is still in progress.
- **Influence data** is stored in **`painting_influence_sources`** (directed links from paintings to source paintings, artists, or movements), with optional period fields and citation metadata. Sources include curated scholarship (`art-influences-data.js`) and **PainterPalette** (`discovered_via = painter-palette`). Legacy `painting_influences` mirrors painting-to-painting edges for scripts only.
## User roles and access
@@ -436,28 +451,34 @@ Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens
| Role | Who | Can do |
|------|-----|--------|
| **`user`** | Anonymous visitor (default) | Browse timeline, movement flow, 3D artist/movement halls, painting detail, artist bios, images |
| **`curator`** | Named account (`users` table) | Everything above + **Debug mode**, **Checkup**, **Translations**, **Influences**, **Tour editor**, debug API mutations |
| **`curator`** | Named staff account | Public browse + tools allowed by their **permission flags** |
| **`admin`** | Named staff account | All curator tools + **Users** management |
Curators sign in via **Curator login** in the site header. Sessions use an HTTP-only cookie (`gallery.sid`). The UI hides debug controls from guests; the server enforces the same rules on debug/checkup API routes (`401` without a valid session).
**Permission flags:** `images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`. Admins always have every flag.
Mutating debug actions (fix/clear/upload/delete, checkup flag changes) are appended to **`curator_audit_log`** with username, action, target id, optional JSON details, and client IP. Query in pgAdmin — see [DB_structure.md](DB_structure.md#curator_audit_log).
Staff sign in via **Curator login** in the site header (individual username/password). Sessions use an HTTP-only cookie (`gallery.sid`). The UI shows only tools the account may use; the server enforces the same rules (`401` without a session, `403` without permission).
Admins create and manage accounts on the **Users** page (`UsersPage.tsx` / `/api/users`). Bootstrap the first admin with `CURATOR_*` env vars + `npm run dev:migrate` (or `npm run dev:reset-curator`).
Mutating actions are appended to **`curator_audit_log`** with `user_id`, action, target id, optional JSON details, and client IP. Query in pgAdmin — see [DB_structure.md](DB_structure.md#curator_audit_log).
## Developer tools (image audit)
Curator-only workflow for reviewing and fixing local image files — not part of the public visitor experience.
Staff workflow for reviewing and fixing local image files (requires **`images`** permission) — not part of the public visitor experience.
| Feature | Where | Purpose |
|---------|--------|---------|
| **Catalog search** | Timeline header (all visitors) | Find artists, paintings, movements; `GET /api/search`; navigate to gallery or detail |
| **Curator login** | Home header (guests) | Username + password modal; unlocks debug tools |
| **Debug mode** | Home header toggle (curators only) | Persists in `localStorage`; enables debug panel on painting detail and artist bio |
| **Show more** | Home header checkbox (curators, when debug on) | Auto-opens the **More** modal on each painting / bio page load |
| **Checkup page** | Home header → **Checkup** (curators only) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags |
| **Translations** | Home header → **Translations** (curators only) | Review/publish Russian `entity_translations` |
| **Influences** | Home header → **Influences** (curators only) | List/CRUD influence edges, CSV/JSON/XLSX import wizard, neighborhood graph — [influence-import.md](influence-import.md) |
| **Tour editor** | Home header → **Tour editor** (curators only) | Create/publish guided tours and stop text — [tours.md](tours.md) |
| **Curator login** | Home header (guests) | Username + password modal; unlocks permitted tools |
| **Debug mode** | Home header toggle (`images`) | Persists in `localStorage`; enables debug panel on painting detail and artist bio |
| **Show more** | Home header checkbox (`images`, when debug on) | Auto-opens the **More** modal on each painting / bio page load |
| **Checkup page** | Home header → **Checkup** (`checkup`) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags |
| **Translations** | Home header → **Translations** (`translations`) | Review/publish Russian `entity_translations` |
| **Influences** | Home header → **Influences** (`influences`) | List/CRUD influence edges, CSV/JSON/XLSX import wizard, neighborhood graph — [influence-import.md](influence-import.md) |
| **Tour editor** | Home header → **Tour editor** (`tours`) | Create/publish guided tours and stop text — [tours.md](tours.md) |
| **Users** | Home header → **Users** (`users` / admin) | Create staff accounts, roles, permissions, reset passwords, disable accounts |
| **Tours** | Home header → **Tours** (everyone) | Open published tours in a 3D hall — [tours.md](tours.md) |
| **Logout** | Home header (curators) | Ends session; hides debug tools |
| **Logout** | Home header (staff) | Ends session; hides staff tools |
| **Debug panel** | Painting detail or artist bio (bottom-left, when debug mode on) | Search preview + action buttons (six on painting detail, five on artist bio) |
### Debug panel (painting detail and artist bio)
+42 -12
View File
@@ -7,6 +7,21 @@ How catalog content, biographies, and artwork files enter the system.
1. **No runtime hot-linking** — the UI reads from `/images/…` (local disk). External URLs are used only during ingest.
2. **No AI-generated art or text** — biographies and descriptions come from Wikipedia; influence notes from curated art-history sources.
3. **Local copies** — every displayed image should exist under `data/images/` after seeding or fetch.
4. **Painting thumbnails must stay in sync with the full image** — see [Painting thumbnail invariant](#painting-thumbnail-invariant) below. Treat this as a hard requirement for any new feature or script that writes or replaces painting picture files.
## Painting thumbnail invariant
**Requirement (do not regress):** any action that **creates, replaces, or clears** a paintings full picture must also **regenerate or remove** that paintings dedicated thumbnail under `paintings/thumbs/`. Never point `thumbnail_path` at the full-size file as a stand-in, and never reuse a search-result or Commons thumb URL.
| Change type | Expected thumb behavior | Primary code |
|-------------|-------------------------|--------------|
| Fix / upload / buffer or URL replace | Write full file, then `writePaintingThumb` / `generateThumbnailFromFull` | `server/image-service.js` |
| Fetch / seed / expand / influence ingest | Same: thumb from saved full via `savePaintingImages` | `scripts/image-fetcher.js` |
| Disk sync / preload / ensure when full exists but thumb missing | Generate real `*_thumb.jpg` and update `thumbnail_path` | `ensurePaintingThumbFromFull`, `syncPaintingFromDisk`, `ensurePaintingImages`, `preloadArtistImagesLocal`, `preloadMovementImagesLocal` |
| Clear image / delete painting | Delete thumb file(s) and null paths (or drop row) | `clearPaintingImage`, `deletePainting` |
| Bulk rebuild after sync/harmonize | `regenerate-thumbnails.js` / `harmonize:images` | scripts + npm scripts |
Path-only maintenance (`sync-image-paths.js`) may link existing thumb files without rewriting pixels; if full images changed without a matching thumb step, run `npm run dev:regenerate-thumbnails` (or `harmonize:images`).
## Directory layout
@@ -31,7 +46,7 @@ File names are sanitised `{Artist}_{Title}.{ext}`. The image service can redisco
| **Development** | `./data/images/` in repo | Working copy on dev PC |
| **Production** | `/mnt/BasePool/Applications/Gallery/data/images` on TrueNAS | SMB `\\192.168.10.122\Gallery\data\images` |
**One-direction promote:** `npm run devtoprod:images` (dev → prod, skip older). **Refresh dev from prod:** `npm run prodto:dev:images`. **Bidirectional merge** (newer file wins): `npm run harmonize:images` or full `npm run harmonize` — see [harmonize-dev-prod.md](harmonize-dev-prod.md). General sync reference: [environments.md](environments.md).
**One-direction promote:** `npm run devtoprod:images` (dev → prod, skip older). **Refresh all images on dev from prod:** `npm run prodto:dev:images`. **One artist only:** map the SMB share, then `robocopy` that artists `ArtistName*` files under `paintings/` and `paintings/thumbs/` (see [FAC.md](FAC.md#dev--prod-image-sync-smb)). **Bidirectional merge** (newer file wins, then merge artists/paintings checkup flags + image paths, then rebuild painting + portrait thumbs on both sides): `npm run harmonize:images` or full `npm run harmonize` — see [harmonize-dev-prod.md](harmonize-dev-prod.md). General sync reference: [environments.md](environments.md).
## Scripts overview
@@ -49,7 +64,7 @@ File names are sanitised `{Artist}_{Title}.{ext}`. The image service can redisco
| `fetch-missing-images.js` | `npm run dev:fetch-images` | Downloads files for paintings missing on disk |
| `image-fetcher.js` | *(library)* | Wikimedia / museum resolution used by fetch scripts and API |
| `sync-images-to-prod.ps1` / `sync-images-from-prod.ps1` | `npm run devtoprod:images` / `npm run prodto:dev:images` | Robocopy via SMB `\\192.168.10.122\Gallery` |
| `harmonize-db.js` / `harmonize-images.js` | `npm run harmonize:db` / `harmonize:images` | Bidirectional merge by `updated_at` / file mtime |
| `harmonize-db.js` / `harmonize-images.js` | `npm run harmonize:db` / `harmonize:images` | Bidirectional merge by `updated_at` / file mtime; images step also merges artists/paintings checkup flags + paths, then rebuilds thumbs on both sides |
| `harmonize.ps1` | `npm run harmonize` | Orchestrator: backups, optional schema, DB + image merge |
| `regenerate-thumbnails.js` | `npm run dev:regenerate-thumbnails` | Rebuild painting thumbs from full images via `sharp` |
| `regenerate-portrait-thumbs.js` | `npm run dev:regenerate-portrait-thumbs` | Rebuild timeline portrait thumbs (~256px) and set `portrait_thumb_path` |
@@ -183,7 +198,7 @@ Influence data drives:
- **Painting detail** — *Influenced By* (left) and *Influenced* (right) panels: painting thumbnails, artist portraits, or movement colour swatches, plus notes, aspects, period labels, and citations
- **3D hall exit** — predecessor and successor artists grouped by movement (from painting and artist sources)
- **3D gallery lamps** — golden picture light above frames with any influence edge (`has_influence_links` on painting API responses)
- **3D gallery lamps** — golden picture light above frames with any influence edge (`has_influence_links` on painting API responses); rendered upside down in `InfluencePictureLamp`
### Audit duplicate influence links
@@ -263,9 +278,9 @@ To add or fix a branch, edit `MOVEMENT_LINEAGE` in that file and rebuild the cli
## Movement gallery interiors (frontend)
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 (pilasters, coffered ceilings, etc.). Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts` (colour maps in sRGB, normal maps in linear/`NoColorSpace`). Period mesh details live in `client/src/components/MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** so shafts never cover frames.
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 with packed-wall **clerestory** fallback) 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. Hall lighting is **shared** (no per-painting spotlights) so `MeshStandardMaterial` walls stay within WebGL light limits — see [basics.md](basics.md) § Shared 3D behaviour. To change a movements look, edit its entry in `movement-interior-styles.ts` and rebuild the client.
## Historical event markers (frontend timeline)
@@ -282,9 +297,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 |
|--------|--------|
@@ -351,17 +379,17 @@ When a painting has no local file, `GET /api/paintings/:id/image` triggers `ensu
- **Wikipedia search** when catalog labels fail
- Wikidata → Wikimedia Commons → Wikipedia page image
- Fallbacks: Met Museum, Art Institute of Chicago, Cleveland Museum, Rijksmuseum, Smithsonian*, Harvard*
4. Save full image, **generate thumbnail by resizing the full file** (not a separate Commons thumb URL), update DB, serve file.
4. Save full image, **generate thumbnail by resizing the full file** (not a separate Commons thumb URL), update DB, serve file. If a full file already exists but a dedicated thumb under `paintings/thumbs/` is missing, regenerate the thumb (`ensurePaintingThumbFromFull`) rather than serving the full image as a thumb.
Separate Wikipedia/Commons thumbnail URLs often resolve to the **wrong work** (e.g. a different painting with a similar title). Thumbnails are always derived locally from the downloaded full image via `sharp` in `scripts/image-fetcher.js`.
Separate Wikipedia/Commons thumbnail URLs often resolve to the **wrong work** (e.g. a different painting with a similar title). Thumbnails are always derived locally from the downloaded full image via `sharp` in `scripts/image-fetcher.js`. See [Painting thumbnail invariant](#painting-thumbnail-invariant).
Requests are deduplicated (`inflight` map) and timeout after 15 seconds. On-demand resolution uses a ~2.5 s delay between external requests to reduce rate-limit risk; batch `fetch-images` runs skip that delay while the per-painting deadline is active.
## Preload before 3D gallery
`POST /api/artists/:id/preload-images` is a **public** route (no curator login). It runs **local-only** linking — no network. The React client calls it automatically when entering an **artists** 3D hall so textures use files already on disk.
`POST /api/artists/:id/preload-images` and `POST /api/movements/:id/preload-images` are **public** routes (no curator login). They run **local-only** linking — no network — and regenerate missing `*_thumb.jpg` files from full images on disk. The React client calls them automatically when entering an artist or movement 3D hall so textures use thumbs already on disk.
**Movement galleries** (`GET /api/movements/:id/gallery`) do not use preload — they load the full painting list from the API and resolve local paths the same way as artist halls. Works without files still show the canvas cover in the frame.
Works without files still show the canvas cover in the frame.
The 3D scene uses `galleryImageUrl()`, which never hits the on-demand API (remote latency breaks WebGL texture loading).
@@ -421,13 +449,15 @@ Re-run `import-painter-palette` after adding gallery artists or updating the CSV
## Catalog export
Export the full painting catalog as CSV:
Export helpers write under **`Output/`** (gitignored — regenerate as needed):
```bash
npm run dev:export-paintings
```
Writes **`Output/paintings.csv`** with columns `artist`, `painting`, `year` (sorted by artist, year, title). The `Output/` folder is git-ignored by convention; regenerate after catalog changes.
Writes **`Output/paintings.csv`** with columns `artist`, `painting`, `year` (sorted by artist, year, title).
Ad-hoc **prod** dumps (movements / artists / paintings) can also be written to `Output/*.csv` for offline review. Influence workbooks ready for the curator import wizard belong under **`Inputs/`** (e.g. Gariff, Story of Art) — see [influence-import.md](influence-import.md).
## Image fetcher overrides
+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 |
| 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**.
---
+8 -10
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):**
```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/roles and bootstraps the first **admin** when `users` is empty. Reset that account later with `npm run dev:reset-curator`. Create additional staff via the in-app **Users** page.
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`.
@@ -343,7 +341,7 @@ For **incremental** dev ↔ prod merge (both sides edited), use [harmonize-dev-p
| `npm run devtoprod:db:restore` | Dev PC PowerShell | Restore into prod |
| `npm run harmonize` | Dev PC PowerShell | Bidirectional catalog + image merge — [harmonize-dev-prod.md](harmonize-dev-prod.md) |
| `npm run harmonize:db` | Dev PC PowerShell | DB merge only |
| `npm run harmonize:images` | Dev PC PowerShell | Image merge only |
| `npm run harmonize:images` | Dev PC PowerShell | Image merge + artists/paintings checkup/path sync + regenerate thumbs on both sides |
## Image sync
+22 -4
View File
@@ -45,7 +45,7 @@ For refreshing dev from prod entirely, use `npm run prodto:dev:db` (destructive
net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER
```
5. Copy harmonize config:
5. Copy **dedicated** harmonize config (do not rely on `devtoprod.config.json` alone — that file is a release profile and its step flags are ignored for harmonize):
```powershell
Copy-Item infra/deploy/harmonize.config.example.json infra/deploy/harmonize.config.json
@@ -53,6 +53,8 @@ For refreshing dev from prod entirely, use `npm run prodto:dev:db` (destructive
Edit `harmonize.config.json` (gitignored) — optional `smb.user` / `smb.password`, `prefer` for tie-breaks (`dev` | `prod`), `schemaChanged: true` when new migrations shipped.
6. Prod DB targeting uses [`infra/docker/.env.prod`](../infra/docker/.env.prod). `scripts/db-env.js` loads that file with **file values winning** over the root `.env`, so `harmonize:db` can open `gallery_dev` and `gallery_prod` in the same process without the safety check rejecting `gallery_dev` as a fake prod target.
---
## One-command harmonize
@@ -81,7 +83,7 @@ npm run harmonize:dry-run
| `backupProd` | `prod:db:backup` | Safety snapshot |
| `schema` | `harmonize:schema` | Apply dev migrations to prod (when `schemaChanged: true`) |
| `db` | `harmonize:db` | Row-level catalog merge |
| `images` | `harmonize:images` | Bidirectional file merge |
| `images` | `harmonize:images` | Bidirectional file merge, then merge **artists/paintings** (checkup flags + image paths), then regenerate thumbs on **dev and prod** |
| `verify` | curl `/api/bounds` | Optional smoke check |
Reports are written to `db/SyncReports/harmonize_db_*.json` and `harmonize_images_*.json` (gitignored).
@@ -96,7 +98,10 @@ Reports are written to `db/SyncReports/harmonize_db_*.json` and `harmonize_image
| `npm run harmonize:db` | Merge catalog rows by `updated_at` |
| `npm run harmonize:db -- --dry-run` | Preview DB changes |
| `npm run harmonize:db -- --prefer=dev` | On equal `updated_at`, dev wins |
| `npm run harmonize:images` | Merge image files by mtime |
| `npm run harmonize:images` | Merge image files by mtime, merge artists/paintings checkup flags + image paths, then regenerate thumbs on both sides |
| `npm run harmonize:images -- --skip-thumbnails` | File + checkup/path DB merge only (no thumb rebuild) |
| `npm run harmonize:images -- --skip-db` | File merge (+ thumbs) without artists/paintings checkup sync |
| `npm run harmonize:images -- --dry-run` | Preview file copies and catalog merge (skips thumbnails) |
| `npm run dev:migrate:sync-timestamps` | Apply `updated_at` migration on dev only |
| `npm run dev:backfill-updated-at` | Backfill dev `updated_at` from image mtimes |
| `npm run harmonize:backfill-updated-at` | Same backfill on prod |
@@ -107,7 +112,9 @@ Reports are written to `db/SyncReports/harmonize_db_*.json` and `harmonize_image
Processed in FK order:
`historical_eras` → `art_movements` → `artists` → `artist_periods` → `paintings` → `painting_influences` → `painting_influence_sources` → `painting_annotations` → **`entity_translations`**
`historical_eras` → `art_movements` → `artists` → `artist_periods` → `paintings` → `painting_influences` → `painting_influence_sources` → `painting_annotations` → **`entity_translations`** → **`tours`** → **`tour_stops`**
Every synced table needs an `updated_at` column (including `tour_stops` — added via `migrate-tours.sql` / `migrate-sync-timestamps.sql`).
---
@@ -134,6 +141,7 @@ Harmonize reports conflicts in the JSON report and skips those rows:
| New migration in repo | `harmonize:schema` or deploy step 4 |
| Only images changed on one side | `npm run harmonize:images` |
| Only DB metadata changed | `npm run harmonize:db` |
| Pull **one artists** images from prod → dev | Map SMB, then robocopy `Duccio*` (or the artist prefix) under `paintings/` and `paintings/thumbs/` — see [FAC.md — Dev ↔ prod image sync](FAC.md#dev--prod-image-sync-smb) |
---
@@ -163,6 +171,16 @@ npm run harmonize
---
## Troubleshooting
| Symptom | Likely cause | Fix |
|---------|--------------|-----|
| `Refusing prod operation on database "gallery_dev"` | Old `db-env` left `DB_NAME` from root `.env` when loading prod | Update to current `scripts/db-env.js` (`.env.prod` wins); confirm `infra/docker/.env.prod` has `DB_NAME=gallery_prod` |
| `Table tour_stops missing updated_at` | Prod/dev schema behind | `npm run dev:migrate` and migrate prod (`$env:DB_NAME="gallery_prod"; npm run dev:migrate; Remove-Item Env:\DB_NAME`) |
| Orchestrator lists release steps (`restoreProd`, …) | Missing `harmonize.config.json`; fallback to release config | Copy `harmonize.config.example.json` → `harmonize.config.json` (current `harmonize.ps1` ignores release-only step keys) |
---
## Safety
- Pre-flight backups of dev and prod DB (configurable; on by default)
+1 -1
View File
@@ -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` |
+19 -6
View File
@@ -30,17 +30,30 @@ Unresolved names (artists / movements / paintings not in the DB) are **skipped**
### Presets
| Preset | Typical headers |
|--------|-----------------|
| Web sources | `Artist`, `Painting`, `Influenced by`, `Influenced`, `Reference (source + link)` |
| Story of Art | Same Title Case (+ chapter reference column) |
| Art influences | `artist`, `painting`, `influenced_by`, `influenced`, `reference` |
| Custom | Map any columns manually |
| Preset | Typical headers | Example file |
|--------|-----------------|--------------|
| Web sources | `Artist`, `Painting`, `Influenced by`, `Influenced`, `Reference (source + link)` | [`Inputs/artist_influences_web_sources.xlsx`](../Inputs/artist_influences_web_sources.xlsx) |
| Story of Art | Same Title Case (+ chapter reference column) | [`Inputs/story_of_art_influences.xlsx`](../Inputs/story_of_art_influences.xlsx) |
| Gariff influential painters | `artist`, `painting`, `influenced by`, `influenced`, `reference` | [`Inputs/gariff_influential_painters_influences.xlsx`](../Inputs/gariff_influential_painters_influences.xlsx) |
| Art influences | `artist`, `painting`, `influenced_by`, `influenced`, `reference` | [`Inputs/art_influences.xlsx`](../Inputs/art_influences.xlsx) |
| Custom | Map any columns manually | — |
Token classification order: **artist → movement → painting title** (under subject artist, then global).
Committed edges use `confidence=curated`, `discovered_via=import-wizard`.
### Book extract: Gariff (2008)
[`Inputs/gariff_influential_painters_influences.xlsx`](../Inputs/gariff_influential_painters_influences.xlsx) is distilled from David Gariff et al., *The World's Most Influential Painters and the Artists They Inspired* (Herbert Press / Quarto, 2008; local PDF `Inputs/1.pdf`, not committed).
- Columns match the import wizard (`artist` / `painting` / `influenced by` / `influenced` / `reference`).
- **`artist` and `painting`** are limited to catalog slices in `Inputs/artistslistdbdata-*.csv` and `Inputs/paintinglistdbdata-*.csv` (regenerate those from prod/dev as needed).
- **`influenced by` / `influenced`** may name artists, movements, or works from the book (including entities outside those CSVs).
- Multiple rows per subject when the book states several influences.
- Import via curator **Influences → Import** (Art influences / custom mapping) or keep as a curated source workbook.
See also [`Inputs/story_of_art_influences.xlsx`](../Inputs/story_of_art_influences.xlsx) (Gombrich) and optional local `Inputs/janson_short_history_influences.xlsx` (same column shape).
### Duplicate file / data guard
Each successful commit stores SHA-256 fingerprints in `curator_audit_log` (`influence.import` details):
+6 -5
View File
@@ -29,9 +29,9 @@ cp .env.example .env
| `TRUST_PROXY` | Set to `true` when behind nginx/reverse proxy (honours `X-Forwarded-*`) |
| `IMAGE_DIR` | Root for cached images (default `./data/images`) |
| `SESSION_SECRET` | Random string for signed session cookies (required for curator login) |
| `SESSION_COOKIE_SECURE` | `false` for local HTTP dev; `true` in prod behind HTTPS |
| `CURATOR_USERNAME` | Bootstrap only — first curator account name (default `curator`) |
| `CURATOR_PASSWORD` | Bootstrap only — password for first curator when `users` table is empty |
| `SESSION_COOKIE_SECURE` | Optional — omit for auto (HTTPS via proxy → Secure); set `true`/`false` to force |
| `CURATOR_USERNAME` | Bootstrap / reset — curator account name (default `curator`) |
| `CURATOR_PASSWORD` | Bootstrap when `users` is empty; also used by `npm run dev:reset-curator` |
`.env` is git-ignored; never commit passwords.
@@ -70,7 +70,7 @@ If migration fails with permission errors, grant schema rights to the app user f
### Curator accounts (auth migration)
`npm run dev:migrate` applies `db/migrate-auth.sql` (`users`, `curator_audit_log`, `session` tables). When the `users` table is empty and `CURATOR_USERNAME` / `CURATOR_PASSWORD` are set in `.env`, the first curator account is created automatically.
`npm run dev:migrate` applies `db/migrate-auth.sql` (`users`, `curator_audit_log`, `session` tables). When the `users` table is empty and `CURATOR_USERNAME` / `CURATOR_PASSWORD` are set in `.env`, the first curator account is created automatically. To sync the password from `.env` later, run `npm run dev:reset-curator`.
After migrate, sign in from the site header (**Curator login**). Debug mode, Checkup, Translations, Influences, Tour editor, and all mutating debug APIs require an active curator session. Anonymous visitors browse the timeline, published Tours, and 3D halls without logging in.
@@ -283,9 +283,10 @@ After clone: copy `.env.example` → `.env`, install dependencies, run [one-time
| Movement gallery shows generic cream walls | Stale client build | `cd client && npm run build`; hard-refresh browser |
| Windows overlap paintings in movement wing | Stale client | Rebuild client — windows are placed only on side walls in gaps between frames |
| Influence thumbnails cropped on painting detail | Stale client build | `npm run prod:build` — panels use `object-fit: contain` for full image |
| **Curator login** fails / always guest | Auth tables missing or wrong password | Set `SESSION_SECRET` + `CURATOR_PASSWORD` in `.env`, run `npm run dev:migrate`, restart server |
| **Curator login** fails / always guest | Auth tables missing or wrong password | Set `SESSION_SECRET` + `CURATOR_PASSWORD` in `.env`, run `npm run dev:migrate` (or `npm run dev:reset-curator`), restart server |
| Debug / Checkup returns **401** | Not signed in as curator | **Curator login** (top-right); session cookie `gallery.sid` must be sent (`credentials: include`) |
| Debug works in UI but API rejects | Stale server without auth middleware | Restart `npm run dev:web` or `npm run dev:server` after pulling auth changes |
| **Empty screen** entering 3D hall (header missing) | Stale client before gallery-session fix | Hard-refresh; pull latest client — hall renders from `view` state, not only `gallerySession` |
| **Dark center** entering 3D hall (header visible, no spinner) | Stale client before gallery loading overlay fix | Hard-refresh; latest client shows **Loading gallery…** / **Loading paintings…** in the canvas area until ready |
| 3D hall loading overlay never clears | Stuck texture counter or hung HDR | Hard-refresh; current client times out Environment / shader warm-up so the overlay cannot stay forever |
| 3D hall black after returning from painting detail | WebGL context lost while hall was hidden | Hard-refresh; latest client remounts canvas when hall becomes active again |
Binary file not shown.
Binary file not shown.
+141 -1
View File
@@ -5,6 +5,7 @@ import type {
Artist,
ArtistDetail,
MovementGalleryDetail,
ArtistSummary,
Painting,
PaintingDetail,
ArtistNavigation,
@@ -39,11 +40,41 @@ function localizedPath(path: string, params?: URLSearchParams): string {
const fetchCredentials: RequestInit = { credentials: 'include' };
export type AuthRole = 'user' | 'curator';
export type AuthRole = 'user' | 'admin' | 'curator';
export type StaffPermission =
| 'images'
| 'checkup'
| 'curator_notes'
| 'translations'
| 'influences'
| 'tours'
| 'users';
export const ALL_STAFF_PERMISSIONS: StaffPermission[] = [
'images',
'checkup',
'curator_notes',
'translations',
'influences',
'tours',
'users',
];
export interface AuthState {
role: AuthRole;
username?: string;
permissions?: StaffPermission[];
}
export interface StaffUser {
id: number;
username: string;
role: 'admin' | 'curator';
permissions: StaffPermission[];
is_active: boolean;
created_at: string;
last_login_at: string | null;
}
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
@@ -64,6 +95,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}`);
}
@@ -138,6 +177,34 @@ export function galleryImageUrl(
return null;
}
/** Ordered hall texture URLs — thumbs only (never full originals; those can be 10MB+ each). */
export function galleryImageUrlCandidates(
painting: {
id?: number;
thumbnail_path?: string | null;
image_path?: string | null;
image_cache_key?: number | null;
thumbnail_cache_key?: number | null;
},
sessionRevision?: number | null
): string[] {
const revision = paintingImageRevision(painting, sessionRevision);
const urls: string[] = [];
const push = (u: string | null | undefined) => {
if (u && !urls.includes(u)) urls.push(u);
};
if (painting.thumbnail_path) push(imageUrl(painting.thumbnail_path, revision));
// If DB has no thumb path but has a full file, still prefer the on-demand thumb API
// over streaming the multi-megabyte original into WebGL.
if (painting.id != null) {
push(`/api/paintings/${painting.id}/image?size=thumb`);
}
if (!painting.thumbnail_path && painting.image_path) {
push(imageUrl(painting.image_path, revision));
}
return urls;
}
export function galleryImageUrlWithRevision(
painting: {
id?: number;
@@ -264,6 +331,15 @@ export async function preloadArtistImages(artistId: number): Promise<{ fetched:
return res.json();
}
export async function preloadMovementImages(movementId: number): Promise<{ fetched: number; total: number }> {
const res = await fetch(`${API}/movements/${movementId}/preload-images`, {
...fetchCredentials,
method: 'POST',
});
if (!res.ok) throw new Error('Preload failed');
return res.json();
}
export interface FixPaintingImageResult {
imagePath: string | null;
thumbnailPath: string | null;
@@ -329,6 +405,52 @@ export interface PaintingCheckupData {
}
export const api = {
listUsers: () => fetchJson<{ users: StaffUser[]; permissions: StaffPermission[] }>(`${API}/users`),
createUser: (body: {
username: string;
password: string;
role: 'admin' | 'curator';
permissions: StaffPermission[];
}) =>
fetch(`${API}/users`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(async (res) => {
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Create failed: ${res.status}`);
return data as { user: StaffUser };
}),
updateUser: (
id: number,
body: Partial<{ role: 'admin' | 'curator'; permissions: StaffPermission[]; is_active: boolean }>
) =>
fetch(`${API}/users/${id}`, {
...fetchCredentials,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(async (res) => {
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Update failed: ${res.status}`);
return data as { user: StaffUser };
}),
resetUserPassword: (id: number, password: string) =>
fetch(`${API}/users/${id}/password`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
}).then(async (res) => {
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Password reset failed: ${res.status}`);
return data as { ok: boolean };
}),
getBounds: () => fetchJson<YearBounds>(`${API}/bounds`),
getCatalogBootstrap: (start?: number, end?: number) => {
@@ -365,6 +487,9 @@ export const api = {
getMovementGallery: (id: number) => fetchJson<MovementGalleryDetail>(localizedPath(`${API}/movements/${id}/gallery`)),
getMovementArtistsSummary: (id: number) =>
fetchJson<ArtistSummary[]>(localizedPath(`${API}/movements/${id}/artists-summary`)),
getArtistNavigation: (id: number) =>
fetchJson<ArtistNavigation>(localizedPath(`${API}/artists/${id}/navigation`)),
@@ -436,6 +561,20 @@ export const api = {
return res.json() as Promise<{ checked: boolean; fixed: boolean }>;
}),
updatePaintingCuratorNotes: (id: number, curatorNotes: string) =>
fetch(`${API}/paintings/${id}/curator-notes`, {
...fetchCredentials,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ curatorNotes }),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Update failed: ${res.status}`);
}
return res.json() as Promise<{ curatorNotes: string }>;
}),
getArtistDebugPortraitSearch: (id: number) =>
fetchJson<DebugImageSearchResult>(`${API}/artists/${id}/debug-portrait-search`),
@@ -766,6 +905,7 @@ export const api = {
}),
preloadArtistImages,
preloadMovementImages,
};
export interface TranslationWorklistItem {
+4
View File
@@ -19,6 +19,7 @@ interface Props {
artist: Artist & { movement_name?: string };
debugMode?: boolean;
debugShowMore?: boolean;
canCheckup?: boolean;
portraitRevision?: number;
onBack: () => void;
onEnterGallery: () => void;
@@ -36,6 +37,7 @@ export default function ArtistBio({
artist,
debugMode = false,
debugShowMore = false,
canCheckup = true,
portraitRevision = 0,
onBack,
onEnterGallery,
@@ -322,6 +324,7 @@ export default function ArtistBio({
<p className="debug-image-status">No portrait image result found.</p>
)}
<div className="debug-action-buttons">
{canCheckup && (
<button
type="button"
className="debug-checked-btn"
@@ -330,6 +333,7 @@ export default function ArtistBio({
>
{markingChecked ? '…' : 'Checked'}
</button>
)}
<button
type="button"
className="debug-fix-btn"
+229
View File
@@ -0,0 +1,229 @@
.artist-filter-backdrop {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.65);
padding: 16px;
}
.artist-filter-modal {
width: min(100%, 720px);
max-height: min(90vh, 640px);
display: flex;
flex-direction: column;
border-radius: 10px;
border: 1px solid rgba(201, 169, 110, 0.35);
background: linear-gradient(180deg, #1a1a2e 0%, #12121f 100%);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
}
.artist-filter-header {
padding: 20px 24px 12px;
flex-shrink: 0;
}
.artist-filter-header h2 {
margin: 0 0 6px;
font-family: 'Georgia', serif;
font-size: 22px;
color: #e8d5b5;
}
.artist-filter-subtitle {
margin: 0;
font-size: 13px;
color: rgba(201, 169, 110, 0.7);
}
.artist-filter-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 24px 12px;
flex-shrink: 0;
}
.artist-filter-toggle-all {
border: none;
background: transparent;
color: rgba(255, 220, 160, 0.9);
font-size: 13px;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.artist-filter-toggle-all:hover {
color: #ffe8c0;
}
.artist-filter-count {
font-size: 12px;
color: rgba(201, 169, 110, 0.65);
}
.artist-filter-grid {
flex: 1;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
padding: 4px 24px 16px;
}
.artist-filter-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 8px;
border-radius: 8px;
border: 1px solid rgba(201, 169, 110, 0.2);
background: rgba(0, 0, 0, 0.25);
cursor: pointer;
text-align: center;
transition: border-color 0.15s, background 0.15s, opacity 0.15s;
}
.artist-filter-card:hover {
border-color: rgba(255, 220, 160, 0.45);
background: rgba(0, 0, 0, 0.35);
}
.artist-filter-card-selected {
border-color: rgba(255, 220, 160, 0.55);
background: rgba(255, 220, 160, 0.08);
}
.artist-filter-card:not(.artist-filter-card-selected) {
opacity: 0.55;
}
.artist-filter-portrait-wrap {
position: relative;
width: 56px;
height: 56px;
margin-bottom: 4px;
}
.artist-filter-portrait {
width: 56px;
height: 56px;
border-radius: 50%;
object-fit: cover;
border: 2px solid rgba(201, 169, 110, 0.4);
}
.artist-filter-portrait-placeholder {
display: flex;
align-items: center;
justify-content: center;
background: rgba(201, 169, 110, 0.15);
color: rgba(232, 213, 181, 0.6);
font-size: 20px;
font-family: 'Georgia', serif;
}
.artist-filter-check {
position: absolute;
right: -2px;
bottom: -2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(255, 220, 160, 0.95);
color: #1a1a2e;
font-size: 12px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.artist-filter-card:not(.artist-filter-card-selected) .artist-filter-check {
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(201, 169, 110, 0.35);
color: transparent;
}
.artist-filter-name {
font-family: 'Georgia', serif;
font-size: 13px;
font-weight: 600;
color: #e8d5b5;
line-height: 1.25;
}
.artist-filter-years {
font-size: 11px;
color: rgba(201, 169, 110, 0.75);
}
.artist-filter-paintings {
font-size: 11px;
color: rgba(201, 169, 110, 0.55);
}
.artist-filter-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 24px 20px;
border-top: 1px solid rgba(201, 169, 110, 0.15);
flex-shrink: 0;
}
.artist-filter-cancel,
.artist-filter-proceed {
padding: 10px 18px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
}
.artist-filter-cancel {
border: 1px solid rgba(201, 169, 110, 0.35);
background: transparent;
color: rgba(232, 213, 181, 0.85);
}
.artist-filter-cancel:hover {
background: rgba(201, 169, 110, 0.1);
}
.artist-filter-proceed {
border: none;
background: linear-gradient(180deg, #c9a96e 0%, #a88b4a 100%);
color: #1a1a2e;
font-weight: 600;
}
.artist-filter-proceed:hover:not(:disabled) {
filter: brightness(1.08);
}
.artist-filter-proceed:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@media (max-width: 480px) {
.artist-filter-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px;
padding-left: 16px;
padding-right: 16px;
}
.artist-filter-header,
.artist-filter-toolbar,
.artist-filter-footer {
padding-left: 16px;
padding-right: 16px;
}
}
+171
View File
@@ -0,0 +1,171 @@
import { useEffect, useMemo, useState } from 'react';
import type { ArtistSummary } from '../types';
import { portraitThumbUrl, portraitUrl } from '../api/client';
import { useQueuedImageSrc } from '../hooks/useQueuedImageSrc';
import './ArtistFilterModal.css';
interface Props {
open: boolean;
movementName: string;
artists: ArtistSummary[];
portraitRevisions?: Record<number, number>;
onProceed: (selectedIds: Set<number>) => void;
onClose: () => void;
}
function formatLifespan(birth: number | null, death: number | null): string {
const b = birth != null ? String(birth) : '?';
const d = death != null ? String(death) : '?';
return `${b}${d}`;
}
function paintingCountLabel(count: number): string {
return count === 1 ? '1 painting' : `${count} paintings`;
}
function ArtistFilterPortrait({
artist,
revision,
}: {
artist: ArtistSummary;
revision?: number;
}) {
const thumbSrc =
artist.portrait_thumb_path || artist.portrait_path
? portraitThumbUrl(artist, revision)
: null;
const fullSrc = artist.portrait_path
? portraitUrl(artist.portrait_path, revision ?? artist.portrait_cache_key)
: null;
const [srcIndex, setSrcIndex] = useState(0);
const candidates = [thumbSrc, fullSrc].filter((s, i, arr): s is string => Boolean(s) && arr.indexOf(s) === i);
const requested = candidates[srcIndex] ?? null;
const queuedSrc = useQueuedImageSrc(requested);
useEffect(() => {
setSrcIndex(0);
}, [thumbSrc, fullSrc]);
if (!queuedSrc) {
return (
<span className="artist-filter-portrait artist-filter-portrait-placeholder" aria-hidden>
?
</span>
);
}
return (
<img
src={queuedSrc}
alt=""
className="artist-filter-portrait"
onError={() => {
setSrcIndex((i) => (i + 1 < candidates.length ? i + 1 : candidates.length));
}}
/>
);
}
export default function ArtistFilterModal({
open,
movementName,
artists,
portraitRevisions,
onProceed,
onClose,
}: Props) {
const allIds = useMemo(() => new Set(artists.map((a) => a.id)), [artists]);
const [selected, setSelected] = useState<Set<number>>(() => new Set(allIds));
useEffect(() => {
if (open) {
setSelected(new Set(artists.map((a) => a.id)));
}
}, [open, artists]);
if (!open) return null;
const allSelected = selected.size === artists.length && artists.length > 0;
const noneSelected = selected.size === 0;
const toggleArtist = (id: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleAll = () => {
setSelected(allSelected ? new Set() : new Set(allIds));
};
return (
<div className="artist-filter-backdrop" onMouseDown={onClose}>
<div
className="artist-filter-modal"
role="dialog"
aria-labelledby="artist-filter-title"
aria-modal="true"
onMouseDown={(e) => e.stopPropagation()}
>
<header className="artist-filter-header">
<h2 id="artist-filter-title">{movementName}</h2>
<p className="artist-filter-subtitle">Choose artists to include in the gallery hall</p>
</header>
<div className="artist-filter-toolbar">
<button type="button" className="artist-filter-toggle-all" onClick={toggleAll}>
{allSelected ? 'Deselect all' : 'Select all'}
</button>
<span className="artist-filter-count">
{selected.size} of {artists.length} selected
</span>
</div>
<div className="artist-filter-grid">
{artists.map((artist) => {
const isSelected = selected.has(artist.id);
return (
<button
key={artist.id}
type="button"
className={`artist-filter-card${isSelected ? ' artist-filter-card-selected' : ''}`}
onClick={() => toggleArtist(artist.id)}
aria-pressed={isSelected}
>
<span className="artist-filter-portrait-wrap">
<ArtistFilterPortrait
artist={artist}
revision={portraitRevisions?.[artist.id]}
/>
<span className="artist-filter-check" aria-hidden>
{isSelected ? '✓' : ''}
</span>
</span>
<span className="artist-filter-name">{artist.name}</span>
<span className="artist-filter-years">{formatLifespan(artist.birth_year, artist.death_year)}</span>
<span className="artist-filter-paintings">{paintingCountLabel(artist.painting_count)}</span>
</button>
);
})}
</div>
<footer className="artist-filter-footer">
<button type="button" className="artist-filter-cancel" onClick={onClose}>
Cancel
</button>
<button
type="button"
className="artist-filter-proceed"
disabled={noneSelected}
onClick={() => onProceed(new Set(selected))}
>
Enter gallery
</button>
</footer>
</div>
</div>
);
}
+10 -16
View File
@@ -173,22 +173,15 @@ function SingleWindow({
<meshBasicMaterial color="#ffffff" transparent opacity={0.25} toneMapped={false} />
</mesh>
{/* Daylight into room */}
{/* One fill light per window — keep total scene lights well under WebGL limits. */}
<spotLight
position={[0, 0, 0.15]}
angle={Math.min(1.2, (spec.width / Math.max(spec.height, 0.5)) * 0.55)}
penumbra={0.95}
intensity={spec.lightIntensity}
intensity={spec.lightIntensity * Math.PI * 0.85}
distance={14}
decay={0}
color={spec.lightColor}
castShadow={false}
/>
<pointLight
position={[0, 0, 0.25]}
intensity={spec.lightIntensity * 0.45}
distance={10}
color={glassColor}
decay={2}
/>
</group>
);
@@ -223,8 +216,9 @@ export function GalleryTrackLights({
}) {
const positions = useMemo(() => {
const pts: [number, number, number][] = [];
const cols = Math.max(2, Math.min(5, Math.floor(width / 4)));
const rows = Math.max(1, Math.min(3, Math.floor(depth / 6)));
// Cap fixtures so MeshStandard hall materials stay within WebGL light limits.
const cols = Math.max(2, Math.min(3, Math.floor(width / 5)));
const rows = Math.max(1, Math.min(2, Math.floor(depth / 8)));
for (let c = 0; c < cols; c++) {
for (let r = 0; r < rows; r++) {
const x = -width / 2 + (width / (cols + 1)) * (c + 1);
@@ -245,12 +239,12 @@ export function GalleryTrackLights({
</mesh>
<spotLight
position={[0, -0.04, 0]}
angle={0.55}
penumbra={0.85}
angle={0.7}
penumbra={0.9}
intensity={intensity}
distance={8}
distance={12}
decay={0}
color={color}
castShadow={false}
/>
</group>
))}
+2 -2
View File
@@ -46,14 +46,14 @@ export default function HallPassage({
{/* Side jambs */}
{([-1, 1] as const).map((sign) => (
<mesh key={sign} position={[sign * (openingW / 2 + jamb / 2), openingH / 2, faceZ]} castShadow>
<mesh key={sign} position={[sign * (openingW / 2 + jamb / 2), openingH / 2, faceZ]}>
<boxGeometry args={[jamb, openingH + 0.1, 0.1]} />
<meshStandardMaterial color={trimColor} roughness={0.45} metalness={0.25} />
</mesh>
))}
{/* Arch header */}
<mesh position={[0, openingH + 0.06, faceZ]} castShadow>
<mesh position={[0, openingH + 0.06, faceZ]}>
<boxGeometry args={[openingW + jamb * 2, 0.14, 0.1]} />
<meshStandardMaterial color={trimColor} roughness={0.4} metalness={0.3} />
</mesh>
+49 -26
View File
@@ -28,7 +28,7 @@
.movements-flow-canvas {
--stream-stroke: 54px;
--portrait-size: 52px;
--portrait-size: 39px;
position: relative;
flex: 1;
min-height: 0;
@@ -96,12 +96,13 @@
}
.movement-branch-fast {
stroke-opacity: 0.32;
stroke-opacity: 0.55;
stroke-width: calc(var(--stream-stroke) * 0.45);
stroke-linecap: round;
}
.movement-stream-fast {
stroke-opacity: 0.42;
stroke-opacity: 0.62;
stroke-width: var(--stream-stroke);
stroke-linecap: round;
vector-effect: non-scaling-stroke;
@@ -119,13 +120,14 @@
stroke-width: var(--stream-stroke);
stroke-linecap: round;
vector-effect: non-scaling-stroke;
filter: saturate(1.25) brightness(1.12) contrast(1.08);
transition: filter 0.2s ease, opacity 0.2s ease;
}
.movement-stream-core {
stroke-width: 2px;
stroke-linecap: round;
opacity: 0.22;
opacity: 0.38;
stroke-dasharray: 8 6;
animation: stream-shimmer 16s linear infinite;
vector-effect: non-scaling-stroke;
@@ -133,29 +135,30 @@
}
.movements-flow-canvas.movements-flow-movement-hover .movement-stream-fill:not(.movement-stream-highlighted) {
opacity: 0.55;
opacity: 0.5;
filter: saturate(1.05) brightness(0.95) contrast(1.02);
}
.movements-flow-canvas.movements-flow-movement-hover .movement-stream-core:not(.movement-stream-highlighted) {
opacity: 0.1;
opacity: 0.14;
}
.movements-flow-canvas.movements-flow-movement-hover .movement-branch:not(.movement-branch-highlighted) {
opacity: 0.45;
opacity: 0.4;
}
.movement-stream-fill.movement-stream-highlighted {
filter: brightness(1.65) saturate(1.3);
filter: brightness(1.75) saturate(1.45) contrast(1.12);
}
.movement-stream-core.movement-stream-highlighted {
opacity: 0.72;
filter: brightness(1.5);
opacity: 0.82;
filter: brightness(1.55) saturate(1.2);
stroke-width: 2.5px;
}
.movement-branch.movement-branch-highlighted {
filter: brightness(1.55) saturate(1.2);
filter: brightness(1.65) saturate(1.35) contrast(1.1);
}
@keyframes stream-shimmer {
@@ -173,7 +176,29 @@
inset: 0;
}
.movements-flow-hits {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
}
.movement-stream-hit {
position: absolute;
height: var(--stream-stroke);
transform: translateY(-50%);
pointer-events: auto;
}
.movements-flow-artists {
opacity: 0;
pointer-events: none;
transition: opacity 0.18s ease;
z-index: 5;
}
.movements-flow-canvas.movements-flow-band-hover .movements-flow-artists {
opacity: 1;
pointer-events: auto;
}
@@ -239,23 +264,16 @@
padding: 2px 6px;
pointer-events: none;
z-index: 6;
}
.movement-flow-label-above {
transform: translate(-4px, -100%);
}
.movement-flow-label-below {
transform: translate(-4px, 0);
transform: translate(-50%, -50%);
}
.movement-flow-label-btn {
pointer-events: auto;
border: none;
background: rgba(12, 10, 18, 0.55);
background: rgba(12, 10, 18, 0.72);
border-radius: 4px;
cursor: pointer;
text-align: left;
text-align: center;
transition: background 0.2s, box-shadow 0.2s;
}
@@ -269,6 +287,11 @@
outline-offset: 2px;
}
.movement-flow-label-hover-reveal {
z-index: 8;
box-shadow: 0 0 0 1px rgba(255, 220, 160, 0.4), 0 4px 18px rgba(0, 0, 0, 0.45);
}
.movement-name {
display: block;
font-family: 'Georgia', serif;
@@ -290,8 +313,8 @@
background: none;
border: 2px solid rgba(232, 213, 181, 0.75);
border-radius: 50%;
width: var(--portrait-size, 52px);
height: var(--portrait-size, 52px);
width: var(--portrait-size, 39px);
height: var(--portrait-size, 39px);
padding: 0;
cursor: pointer;
overflow: visible;
@@ -335,7 +358,7 @@
@media (max-width: 768px) {
.movements-flow-canvas {
--stream-stroke: 44px;
--portrait-size: 44px;
--portrait-size: 33px;
}
.movements-flow-caption {
@@ -343,8 +366,8 @@
}
.artist-portrait {
width: 44px;
height: 44px;
width: var(--portrait-size, 33px);
height: var(--portrait-size, 33px);
}
.movement-flow-label {
File diff suppressed because it is too large Load Diff
+140 -22
View File
@@ -26,7 +26,7 @@ function PalazzoDetails({ width, depth, halfW, halfD, trim }: Props & { trim: st
<group>
{pilasterPositions.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 1.8, 0]} castShadow>
<mesh position={[0, 1.8, 0]}>
<boxGeometry args={[0.22, 3.6, 0.22]} />
<meshStandardMaterial color="#e8dcc8" roughness={0.85} metalness={0.05} />
</mesh>
@@ -139,50 +139,166 @@ function MedievalDetails({ halfW, halfD }: Pick<Props, 'halfW' | 'halfD'>) {
);
}
function NeoclassicalDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) {
const columns = [
[-halfW + 0.45, -halfD + 0.6],
[halfW - 0.45, -halfD + 0.6],
[-halfW + 0.45, halfD - 0.6],
[halfW - 0.45, halfD - 0.6],
] as [number, number][];
function ByzantineDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) {
// Engaged porphyry colonnettes with Byzantine basket/impost capitals at the
// corners (flush — never proud of the ~0.7m frame hang margin), a marble
// revetment dado at spring-line height, and hanging brass polycandela in
// place of wall torches (a Byzantine basilica hangs its light, not sconces it).
const colonnettes = useMemo(
() =>
[
[-halfW + 0.08, -halfD + 0.08],
[halfW - 0.08, -halfD + 0.08],
[-halfW + 0.08, halfD - 0.08],
[halfW - 0.08, halfD - 0.08],
] as [number, number][],
[halfW, halfD]
);
const lampPositions = useMemo(
() =>
[
[0, -halfD * 0.32],
[0, halfD * 0.18],
] as [number, number][],
[halfD]
);
const dadoW = Math.max(0, halfW * 2 - 0.3);
const dadoD = Math.max(0, halfD * 2 - 0.3);
return (
<group>
{columns.map(([x, z], i) => (
{/* Marble revetment dado — imperial banding course at chair-rail height */}
{[
[0, -halfD + 0.07, dadoW, 0.1] as const,
[0, halfD - 0.07, dadoW, 0.1] as const,
[-halfW + 0.07, 0, 0.1, dadoD] as const,
[halfW - 0.07, 0, 0.1, dadoD] as const,
].map(([x, z, w, d], i) => (
<group key={i}>
<mesh position={[x, 0.62, z]}>
<boxGeometry args={[w, 1.24, d]} />
<meshStandardMaterial color="#6d5644" roughness={0.34} metalness={0.12} />
</mesh>
<mesh position={[x, 1.28, z]}>
<boxGeometry args={[w * 1.004, 0.09, d * 1.004]} />
<meshStandardMaterial color="#d8c8a8" roughness={0.42} metalness={0.1} />
</mesh>
</group>
))}
{/* Engaged colonnettes, basket capital, and impost block */}
{colonnettes.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
{/* Polished porphyry shaft — the imperial stone of the reference basilicas */}
<mesh position={[0, 1.95, 0]}>
<cylinderGeometry args={[0.14, 0.16, 3.4, 14]} />
<meshStandardMaterial color="#43222a" roughness={0.32} metalness={0.16} />
</mesh>
<mesh position={[0, 0.12, 0]}>
<cylinderGeometry args={[0.17, 0.19, 0.24, 14]} />
<meshStandardMaterial color="#d8cdb4" roughness={0.5} metalness={0.08} />
</mesh>
{/* Basket capital in white marble, then an impost block carrying the ceiling */}
<mesh position={[0, 3.8, 0]}>
<cylinderGeometry args={[0.22, 0.11, 0.3, 8]} />
<meshStandardMaterial color="#e8e0cc" roughness={0.44} metalness={0.06} />
</mesh>
<mesh position={[0, 4.05, 0]}>
<boxGeometry args={[0.28, 0.22, 0.28]} />
<meshStandardMaterial color="#ded4bc" roughness={0.5} metalness={0.05} />
</mesh>
</group>
))}
{/* Hanging polycandela — brass ring lamps suspended from the vault */}
{lampPositions.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 4.1, 0]}>
<cylinderGeometry args={[0.012, 0.012, 0.5, 6]} />
<meshStandardMaterial color="#5a4a20" roughness={0.4} metalness={0.6} />
</mesh>
<mesh position={[0, 3.84, 0]} rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.32, 0.025, 8, 20]} />
<meshStandardMaterial color={trim} roughness={0.25} metalness={0.8} emissive="#3a2808" emissiveIntensity={0.1} />
</mesh>
{Array.from({ length: 6 }, (_, j) => {
const a = (j / 6) * Math.PI * 2;
return (
<mesh key={j} position={[Math.cos(a) * 0.32, 3.76, Math.sin(a) * 0.32]}>
<sphereGeometry args={[0.045, 8, 8]} />
<meshStandardMaterial color="#ffcf80" emissive="#ff9830" emissiveIntensity={0.9} toneMapped={false} />
</mesh>
);
})}
<pointLight position={[0, 3.76, 0]} intensity={0.85} distance={6} color="#ffb060" />
</group>
))}
</group>
);
}
function NeoclassicalDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) {
// Shallow engaged corner pilasters only. Freestanding columns (~0.5m into the
// room) overlap the outermost frames — hang margin is only ~0.7m from each end.
const pilasters = useMemo(
() =>
[
[-halfW + 0.06, -halfD + 0.06],
[halfW - 0.06, -halfD + 0.06],
[-halfW + 0.06, halfD - 0.06],
[halfW - 0.06, halfD - 0.06],
] as [number, number][],
[halfW, halfD]
);
return (
<group>
{pilasters.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 1.85, 0]}>
<cylinderGeometry args={[0.18, 0.2, 3.7, 12]} />
<boxGeometry args={[0.12, 3.7, 0.12]} />
<meshStandardMaterial color="#f0ece8" roughness={0.72} metalness={0.12} />
</mesh>
<mesh position={[0, 3.85, 0]}>
<boxGeometry args={[0.42, 0.12, 0.42]} />
<mesh position={[0, 3.78, 0]}>
<boxGeometry args={[0.18, 0.1, 0.18]} />
<meshStandardMaterial color={trim} roughness={0.4} metalness={0.35} />
</mesh>
</group>
))}
<mesh position={[0, 3.88, -halfD + 0.12]}>
<boxGeometry args={[2.8, 0.14, 0.2]} />
{/* Cornice above wall title (title sits ~y 3.75) */}
<mesh position={[0, 4.08, -halfD + 0.1]}>
<boxGeometry args={[Math.max(2.4, halfW * 2 - 0.5), 0.1, 0.16]} />
<meshStandardMaterial color={trim} roughness={0.45} metalness={0.3} />
</mesh>
</group>
);
}
function ClassicalDetails({ halfW, trim }: Pick<Props, 'halfW'> & { trim: string }) {
function ClassicalDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) {
// Engaged corner shafts only — anything proud of the corner pocket covers frames.
const columns = useMemo(
() =>
[
[-halfW + 0.06, -halfD + 0.06],
[halfW - 0.06, -halfD + 0.06],
[-halfW + 0.06, halfD - 0.06],
[halfW - 0.06, halfD - 0.06],
] as [number, number][],
[halfW, halfD]
);
return (
<group>
{[
[-halfW + 0.5, 0],
[halfW - 0.5, 0],
].map(([x, z], i) => (
{columns.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 2, 0]}>
<cylinderGeometry args={[0.16, 0.18, 4, 10]} />
<cylinderGeometry args={[0.08, 0.09, 4, 10]} />
<meshStandardMaterial color="#e0d8c8" roughness={0.88} />
</mesh>
<mesh position={[0, 4.05, 0]}>
<boxGeometry args={[0.36, 0.1, 0.36]} />
<boxGeometry args={[0.18, 0.1, 0.18]} />
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.25} />
</mesh>
</group>
@@ -210,10 +326,12 @@ export default function MovementHallDetails({ style, width, depth, halfW, halfD
return <BaroqueDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
case 'medieval':
return <MedievalDetails halfW={halfW} halfD={halfD} />;
case 'byzantine':
return <ByzantineDetails halfW={halfW} halfD={halfD} trim={trim} />;
case 'neoclassical':
return <NeoclassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
case 'classical':
return <ClassicalDetails halfW={halfW} trim={trim} />;
return <ClassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
case 'salon':
return <SalonDetails trim={trim} />;
default:
+109
View File
@@ -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;
+100 -1
View File
@@ -21,6 +21,9 @@ interface Props {
onCatalogNavigate: (paintingId: number) => void;
onArtistBio: () => void;
onInfluenceArtistClick?: (artistId: number) => void;
isCurator?: boolean;
/** When false, hide the Checked action (needs checkup permission). Defaults to true when debugMode is on. */
canCheckup?: boolean;
debugMode?: boolean;
debugShowMore?: boolean;
onPaintingImageFixed?: (
@@ -32,6 +35,7 @@ interface Props {
flags: { checked: boolean; fixed: boolean }
) => void | Promise<void>;
onPaintingRemoved?: (paintingId: number, artistId: number) => void | Promise<void>;
onCuratorNotesUpdated?: (paintingId: number, curatorNotes: string) => void;
}
function influenceKey(inf: InfluenceLink, index: number): string {
@@ -203,15 +207,23 @@ export default function PaintingDetailView({
onCatalogNavigate,
onArtistBio,
onInfluenceArtistClick,
isCurator = false,
canCheckup = true,
debugMode = false,
debugShowMore = false,
onPaintingImageFixed,
onPaintingCheckupFlagsUpdated,
onPaintingRemoved,
onCuratorNotesUpdated,
}: Props) {
const { t } = useTranslation('painting');
const { painting, influencedBy, influenced, annotations = [] } = data;
const inTour = tourText != null;
const [curatorNotes, setCuratorNotes] = useState(painting.curator_notes ?? '');
const [editingNotes, setEditingNotes] = useState(false);
const [notesDraft, setNotesDraft] = useState(painting.curator_notes ?? '');
const [savingNotes, setSavingNotes] = useState(false);
const [notesError, setNotesError] = useState<string | null>(null);
const [fullscreen, setFullscreen] = useState(false);
const [imageVersion, setImageVersion] = useState(0);
const [debugSearch, setDebugSearch] = useState<DebugImageSearchResult | null>(null);
@@ -257,7 +269,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 +309,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 +632,57 @@ export default function PaintingDetailView({
)}
</aside>
)}
{(isCurator || curatorNotes.trim()) && (
<aside className="curator-notes-panel" aria-label={t('curatorNotes')}>
<div className="curator-notes-header">
<h3>{t('curatorNotes')}</h3>
{isCurator && !editingNotes && (
<button
type="button"
className="curator-notes-edit-btn"
onClick={startEditingNotes}
>
{t('curatorNotesEdit')}
</button>
)}
</div>
{editingNotes ? (
<div className="curator-notes-editor">
<textarea
className="curator-notes-textarea"
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
rows={6}
disabled={savingNotes}
aria-label={t('curatorNotes')}
/>
{notesError && <p className="curator-notes-error">{notesError}</p>}
<div className="curator-notes-actions">
<button
type="button"
className="curator-notes-save-btn"
onClick={saveCuratorNotes}
disabled={savingNotes}
>
{savingNotes ? t('curatorNotesSaving') : t('curatorNotesSave')}
</button>
<button
type="button"
className="curator-notes-cancel-btn"
onClick={cancelEditingNotes}
disabled={savingNotes}
>
{t('curatorNotesCancel')}
</button>
</div>
</div>
) : curatorNotes.trim() ? (
<p className="curator-notes-body">{curatorNotes}</p>
) : (
<p className="curator-notes-empty">{t('curatorNotesEmpty')}</p>
)}
</aside>
)}
{painting.description && (
<div className="painting-description">
<p>{painting.description}</p>
@@ -640,6 +737,7 @@ export default function PaintingDetailView({
<p className="debug-image-status">No Google image result found.</p>
)}
<div className="debug-action-buttons">
{canCheckup && (
<button
type="button"
className="debug-checked-btn"
@@ -648,6 +746,7 @@ export default function PaintingDetailView({
>
{markingChecked ? '…' : 'Checked'}
</button>
)}
<button
type="button"
className="debug-fix-btn"
+6
View File
@@ -419,3 +419,9 @@
width: 100%;
margin-top: 8px;
}
.exit-nav-footer {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid rgba(201, 169, 110, 0.25);
}
File diff suppressed because it is too large Load Diff
+40 -8
View File
@@ -1,11 +1,20 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { getAuthMe, loginCurator, logoutCurator, type AuthRole } from '../api/client';
import { useCallback, useContext, useEffect, useMemo, useState, type ReactNode, createContext } from 'react';
import {
getAuthMe,
loginCurator,
logoutCurator,
type AuthRole,
type StaffPermission,
} from '../api/client';
interface AuthContextValue {
role: AuthRole;
username?: string;
permissions: StaffPermission[];
isCurator: boolean;
isAdmin: boolean;
loading: boolean;
can: (permission: StaffPermission) => boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refresh: () => Promise<void>;
@@ -13,15 +22,26 @@ interface AuthContextValue {
const AuthContext = createContext<AuthContextValue | null>(null);
function applyAuthState(
me: { role: AuthRole; username?: string; permissions?: StaffPermission[] },
setRole: (r: AuthRole) => void,
setUsername: (u: string | undefined) => void,
setPermissions: (p: StaffPermission[]) => void
) {
setRole(me.role);
setUsername(me.username);
setPermissions(me.role === 'user' ? [] : me.permissions ?? []);
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [role, setRole] = useState<AuthRole>('user');
const [username, setUsername] = useState<string | undefined>();
const [permissions, setPermissions] = useState<StaffPermission[]>([]);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
const me = await getAuthMe();
setRole(me.role);
setUsername(me.username);
applyAuthState(me, setRole, setUsername, setPermissions);
}, []);
useEffect(() => {
@@ -30,27 +50,39 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const login = useCallback(async (user: string, password: string) => {
const me = await loginCurator(user, password);
setRole(me.role);
setUsername(me.username);
applyAuthState(me, setRole, setUsername, setPermissions);
}, []);
const logout = useCallback(async () => {
await logoutCurator();
setRole('user');
setUsername(undefined);
setPermissions([]);
}, []);
const can = useCallback(
(permission: StaffPermission) => {
if (role === 'admin') return true;
if (role !== 'curator') return false;
return permissions.includes(permission);
},
[role, permissions]
);
const value = useMemo(
() => ({
role,
username,
isCurator: role === 'curator',
permissions,
isCurator: role === 'admin' || role === 'curator',
isAdmin: role === 'admin',
loading,
can,
login,
logout,
refresh,
}),
[role, username, loading, login, logout, refresh]
[role, username, permissions, loading, can, login, logout, refresh]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+67 -22
View File
@@ -52,7 +52,9 @@ export interface MovementInteriorStyle {
doorWood: [string, string, string];
windows: GalleryWindowSpec[];
trackLights: number;
details: 'palazzo' | 'baroque' | 'medieval' | 'neoclassical' | 'salon' | 'modern' | 'classical' | 'museum' | 'industrial' | 'atelier';
/** Scales the shared hall/scene lights. <1 for deliberately dim period interiors. */
lightScale: number;
details: 'palazzo' | 'baroque' | 'medieval' | 'byzantine' | 'neoclassical' | 'salon' | 'modern' | 'classical' | 'museum' | 'industrial' | 'atelier';
}
function windows(...specs: GalleryWindowSpec[]): GalleryWindowSpec[] {
@@ -85,6 +87,7 @@ function mk(
doorWood: opts.doorWood ?? ['#3d2818', '#4e3624', '#261a10'],
windows: opts.windows,
trackLights: opts.trackLights ?? 0.8,
lightScale: opts.lightScale ?? 1,
details: opts.details,
};
}
@@ -114,25 +117,29 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
),
28: mk(
'byzantine-sanctuary',
'Byzantine chapel',
'Gold mosaic walls · amber light through arched windows',
{ wall: 'mosaic-byzantine', ceiling: 'gilded-stucco', floor: 'marble-checker' },
{ wall: '#d4af37', ceiling: '#c8a840', floor: '#ddd8cc', trim: '#b8860b' },
'Byzantine basilica',
'Marble revetment · coffered timber ceiling · Cosmatesque stone floor',
{ wall: 'marble-revetment', ceiling: 'coffered-wood', floor: 'marble-opus-sectile' },
{ wall: '#a89880', ceiling: '#6a4526', floor: '#a09079', trim: '#8a6440' },
{
details: 'medieval',
titleColor: '#f0e8c0',
ambient: 0.48,
warmLight: '#ffd898',
sunLight: '#ffe8b0',
fog: '#0a0806',
background: '#060504',
details: 'byzantine',
titleColor: '#e8d8b0',
ambient: 0.44,
warmLight: '#ffdca8',
sunLight: '#ffdca0',
fog: '#100b07',
background: '#0a0705',
windows: windows(
{ wall: 'back', x: 0, y: 2.8, width: 1.0, height: 2.2, style: 'roman-arch', lightColor: '#ffb860', lightIntensity: 2.4 },
{ wall: 'left', x: -1, y: 2.6, width: 0.7, height: 1.8, style: 'roman-arch', lightColor: '#ffb860', lightIntensity: 1.8 },
{ wall: 'right', x: 1, y: 2.6, width: 0.7, height: 1.8, style: 'roman-arch', lightColor: '#ffb860', lightIntensity: 1.8 }
{ wall: 'back', x: 0, y: 3.1, width: 0.9, height: 1.9, style: 'roman-arch', lightColor: '#ffd28a', lightIntensity: 3.4 },
{ wall: 'left', x: -3.5, y: 3.2, width: 0.7, height: 1.6, style: 'roman-arch', lightColor: '#ffd28a', lightIntensity: 3.0 },
{ wall: 'left', x: 3.5, y: 3.2, width: 0.7, height: 1.6, style: 'roman-arch', lightColor: '#ffd28a', lightIntensity: 3.0 },
{ wall: 'right', x: -3.5, y: 3.2, width: 0.7, height: 1.6, style: 'roman-arch', lightColor: '#ffd28a', lightIntensity: 3.0 },
{ wall: 'right', x: 3.5, y: 3.2, width: 0.7, height: 1.6, style: 'roman-arch', lightColor: '#ffd28a', lightIntensity: 3.0 }
),
trackLights: 0.5,
doorWood: ['#3a3020', '#4a3830', '#2a2018'],
trackLights: 0.35,
// Basilica interiors are lit by shafts from high windows, not evenly flooded.
lightScale: 0.3,
doorWood: ['#3a2416', '#4c3220', '#241608'],
}
),
29: mk(
@@ -144,17 +151,19 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
{
details: 'medieval',
titleColor: '#e8dcc8',
ambient: 0.52,
ambient: 0.72,
warmLight: '#e8d8c0',
sunLight: '#d0e8ff',
fog: '#0c0c10',
// Keep atmosphere dark, but not pure void (walls must stay readable).
fog: '#1a1816',
background: '#141210',
windows: windows(
{ wall: 'back', x: -2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.5 },
{ wall: 'back', x: 2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.5 },
{ wall: 'left', x: 0, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 2.8 },
{ wall: 'right', x: 0, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 2.8 }
),
trackLights: 0.3,
trackLights: 0.85,
}
),
30: mk(
@@ -578,13 +587,48 @@ function blendAccent(style: MovementInteriorStyle, accentHex?: string): Movement
};
}
/**
* Styles were authored with legacy ids 2752; gallery_dev uses 126.
* Northern (5) / High Renaissance (6) are swapped vs the legacy sequence.
*/
const DB_ID_TO_STYLE_KEY: Record<number, number> = {
1: 27,
2: 28,
3: 29,
4: 30,
5: 32,
6: 31,
7: 33,
8: 34,
9: 35,
10: 36,
11: 37,
12: 38,
13: 39,
14: 40,
15: 41,
16: 42,
17: 43,
18: 44,
19: 45,
20: 46,
21: 47,
22: 48,
23: 49,
24: 50,
25: 51,
26: 52,
};
/** Fallback name-based resolver for movements not in the catalog. */
function fallbackByName(movement: ArtMovement & { era_name?: string }): MovementInteriorStyle {
const n = movement.name.toLowerCase();
const era = (movement.era_name ?? '').toLowerCase();
if (n.includes('byzantine')) return BY_MOVEMENT_ID[28];
if (n.includes('gothic')) return BY_MOVEMENT_ID[29];
if (n.includes('renaissance')) return BY_MOVEMENT_ID[31];
if (n.includes('baroque') || n.includes('rococo')) return BY_MOVEMENT_ID[34];
if (era.includes('medieval') || n.includes('gothic')) return BY_MOVEMENT_ID[29];
if (era.includes('medieval')) return BY_MOVEMENT_ID[29];
if (n.includes('impression')) return BY_MOVEMENT_ID[39];
if (era.includes('modern') || era.includes('contemporary')) return BY_MOVEMENT_ID[52];
return BY_MOVEMENT_ID[36];
@@ -593,7 +637,8 @@ function fallbackByName(movement: ArtMovement & { era_name?: string }): Movement
export function resolveMovementInteriorStyle(
movement: ArtMovement & { era_name?: string }
): MovementInteriorStyle {
const base = BY_MOVEMENT_ID[movement.id] ?? fallbackByName(movement);
const styleKey = DB_ID_TO_STYLE_KEY[movement.id] ?? movement.id;
const base = BY_MOVEMENT_ID[styleKey] ?? fallbackByName(movement);
return blendAccent(base, movement.color);
}
+2 -1
View File
@@ -22,7 +22,8 @@ export function useTexturedMaterial(
color: tint,
roughness: surf.roughness,
metalness: surf.metalness,
envMapIntensity: kind.startsWith('painted-') ? 0.45 : 0.65,
// Keep walls readable without HDR Environment IBL (CDN may be slow/offline).
envMapIntensity: kind.startsWith('painted-') ? 0.2 : 0.3,
});
}, [kind, tint, spanW, spanH]);
+5 -1
View File
@@ -13,6 +13,7 @@ import enDebug from '../locales/en/debug.json';
import enTranslations from '../locales/en/translations.json';
import enInfluences from '../locales/en/influences.json';
import enTours from '../locales/en/tours.json';
import enUsers from '../locales/en/users.json';
import ruCommon from '../locales/ru/common.json';
import ruHome from '../locales/ru/home.json';
@@ -25,6 +26,7 @@ import ruDebug from '../locales/ru/debug.json';
import ruTranslations from '../locales/ru/translations.json';
import ruInfluences from '../locales/ru/influences.json';
import ruTours from '../locales/ru/tours.json';
import ruUsers from '../locales/ru/users.json';
const initialLocale = readStoredLocale();
writeStoredLocale(initialLocale);
@@ -33,7 +35,7 @@ void i18n.use(initReactI18next).init({
lng: initialLocale,
fallbackLng: 'en',
supportedLngs: ['en', 'ru'],
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences', 'tours'],
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences', 'tours', 'users'],
defaultNS: 'common',
resources: {
en: {
@@ -48,6 +50,7 @@ void i18n.use(initReactI18next).init({
translations: enTranslations,
influences: enInfluences,
tours: enTours,
users: enUsers,
},
ru: {
common: ruCommon,
@@ -61,6 +64,7 @@ void i18n.use(initReactI18next).init({
translations: ruTranslations,
influences: ruInfluences,
tours: ruTours,
users: ruUsers,
},
},
interpolation: { escapeValue: false },
+1
View File
@@ -17,6 +17,7 @@
"influences": "Influences",
"tours": "Tours",
"toursEditor": "Tour editor",
"users": "Users",
"openingTourGallery": "Opening guided tour…",
"tourEmpty": "This tour has no paintings yet.",
"tourLoadFailed": "Failed to load the tour.",
+8 -1
View File
@@ -11,5 +11,12 @@
"tourNotes": "Tour notes",
"tourNotesFor": "Tour notes · {{title}}",
"tourNotesEmpty": "No notes for this stop.",
"tourStopPosition": "Stop {{current}} of {{total}}"
"tourStopPosition": "Stop {{current}} of {{total}}",
"curatorNotes": "Curator notes",
"curatorNotesEmpty": "No curator notes yet.",
"curatorNotesEdit": "Edit",
"curatorNotesSave": "Save",
"curatorNotesSaving": "Saving…",
"curatorNotesCancel": "Cancel",
"curatorNotesSaveFailed": "Could not save curator notes."
}
+37
View File
@@ -0,0 +1,37 @@
{
"title": "Users",
"back": "← Back",
"loadFailed": "Failed to load users",
"createTitle": "Create user",
"username": "Username",
"password": "Password",
"role": "Role",
"roleAdmin": "Admin",
"roleCurator": "Curator",
"permissions": "Permissions",
"active": "Active",
"inactive": "Disabled",
"create": "Create",
"creating": "Creating…",
"save": "Save",
"saving": "Saving…",
"resetPassword": "Reset password",
"newPassword": "New password",
"lastLogin": "Last login",
"never": "Never",
"selectUser": "Select a user to edit",
"loading": "Loading…",
"perm_images": "Images (fix/upload/debug)",
"perm_checkup": "Checkup",
"perm_curator_notes": "Curator notes",
"perm_translations": "Translations",
"perm_influences": "Influences",
"perm_tours": "Tours",
"perm_users": "Users",
"adminAllPerms": "Admins have all permissions automatically.",
"deactivate": "Disable account",
"activate": "Enable account",
"created": "User created",
"saved": "Saved",
"passwordReset": "Password updated"
}
+1
View File
@@ -17,6 +17,7 @@
"influences": "Влияния",
"tours": "Экскурсии",
"toursEditor": "Редактор экскурсий",
"users": "Пользователи",
"openingTourGallery": "Открытие экскурсии…",
"tourEmpty": "В этой экскурсии пока нет картин.",
"tourLoadFailed": "Не удалось загрузить экскурсию.",
+8 -1
View File
@@ -11,5 +11,12 @@
"tourNotes": "Текст экскурсии",
"tourNotesFor": "Экскурсия · {{title}}",
"tourNotesEmpty": "Для этой остановки нет текста.",
"tourStopPosition": "Остановка {{current}} из {{total}}"
"tourStopPosition": "Остановка {{current}} из {{total}}",
"curatorNotes": "Заметки куратора",
"curatorNotesEmpty": "Заметок куратора пока нет.",
"curatorNotesEdit": "Изменить",
"curatorNotesSave": "Сохранить",
"curatorNotesSaving": "Сохранение…",
"curatorNotesCancel": "Отмена",
"curatorNotesSaveFailed": "Не удалось сохранить заметки куратора."
}
+37
View File
@@ -0,0 +1,37 @@
{
"title": "Пользователи",
"back": "← Назад",
"loadFailed": "Не удалось загрузить пользователей",
"createTitle": "Создать пользователя",
"username": "Имя пользователя",
"password": "Пароль",
"role": "Роль",
"roleAdmin": "Администратор",
"roleCurator": "Куратор",
"permissions": "Права",
"active": "Активен",
"inactive": "Отключён",
"create": "Создать",
"creating": "Создание…",
"save": "Сохранить",
"saving": "Сохранение…",
"resetPassword": "Сбросить пароль",
"newPassword": "Новый пароль",
"lastLogin": "Последний вход",
"never": "Никогда",
"selectUser": "Выберите пользователя для редактирования",
"loading": "Загрузка…",
"perm_images": "Изображения (правка/загрузка)",
"perm_checkup": "Проверка",
"perm_curator_notes": "Заметки куратора",
"perm_translations": "Переводы",
"perm_influences": "Влияния",
"perm_tours": "Экскурсии",
"perm_users": "Пользователи",
"adminAllPerms": "У администраторов все права автоматически.",
"deactivate": "Отключить учётную запись",
"activate": "Включить учётную запись",
"created": "Пользователь создан",
"saved": "Сохранено",
"passwordReset": "Пароль обновлён"
}
+193 -20
View File
@@ -10,18 +10,22 @@ import CheckupPage from '../pages/CheckupPage';
import TranslationsPage from '../pages/TranslationsPage';
import InfluencesPage from '../pages/InfluencesPage';
import ToursPage from '../pages/ToursPage';
import UsersPage from '../pages/UsersPage';
import CuratorLoginModal from '../components/CuratorLoginModal';
import ArtistFilterModal from '../components/ArtistFilterModal';
import ToursPopup from '../components/ToursPopup';
import CatalogSearchBar from '../components/CatalogSearchBar';
import LocaleSwitcher from '../components/LocaleSwitcher';
import GalleryLoadingMarker from '../components/GalleryLoadingMarker';
import '../components/CatalogSearchBar.css';
import '../components/CuratorLoginModal.css';
import '../components/ArtistFilterModal.css';
import '../components/ToursPopup.css';
import '../components/LocaleSwitcher.css';
import '../pages/TranslationsPage.css';
import '../pages/InfluencesPage.css';
import '../pages/ToursPage.css';
import '../pages/UsersPage.css';
import { useAuth } from '../context/AuthContext';
import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
import type {
@@ -32,6 +36,7 @@ import type {
PaintingDetail,
MovementGalleryDetail,
TourGalleryDetail,
ArtistSummary,
} from '../types';
import { createViewChangeScheduler } from '../utils/timelineView';
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
@@ -44,6 +49,7 @@ type View =
| { type: 'translations' }
| { type: 'influences' }
| { type: 'tours' }
| { type: 'users' }
| { type: 'gallery'; artistId: number; data: ArtistDetail }
| { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail }
| { type: 'tour-gallery'; tourId: number; data: TourGalleryDetail }
@@ -55,7 +61,7 @@ type GallerySession =
| { kind: 'movement'; movementId: number; data: MovementGalleryDetail }
| { kind: 'tour'; tourId: number; data: TourGalleryDetail };
type CuratorLoginRedirect = 'checkup' | 'translations' | 'influences' | 'tours' | null;
type CuratorLoginRedirect = 'checkup' | 'translations' | 'influences' | 'tours' | 'users' | null;
function patchPaintingInMovementDetail(
detail: MovementGalleryDetail,
@@ -147,7 +153,14 @@ function catalogNavigateTarget(
export default function HomePage() {
const { t } = useTranslation('home');
const { isCurator, username, login, logout } = useAuth();
const { isCurator, username, login, logout, can } = useAuth();
const canImages = can('images');
const canCheckup = can('checkup');
const canNotes = can('curator_notes');
const canTranslations = can('translations');
const canInfluences = can('influences');
const canTours = can('tours');
const canUsers = can('users');
const [view, setView] = useState<View>({ type: 'timeline' });
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
const [bounds, setBounds] = useState({ min: -800, max: 2025 });
@@ -168,7 +181,12 @@ export default function HomePage() {
const [loginOpen, setLoginOpen] = useState(false);
const [loginRedirect, setLoginRedirect] = useState<CuratorLoginRedirect>(null);
const [toursPopupOpen, setToursPopupOpen] = useState(false);
const effectiveDebugMode = debugMode && isCurator;
const [artistFilterModal, setArtistFilterModal] = useState<{
movementId: number;
movementName: string;
artists: ArtistSummary[];
} | null>(null);
const effectiveDebugMode = debugMode && canImages;
const [galleryRevision, setGalleryRevision] = useState(0);
const viewRef = useRef(view);
viewRef.current = view;
@@ -279,6 +297,8 @@ export default function HomePage() {
setView({ type: 'influences' });
} else if (loginRedirect === 'tours') {
setView({ type: 'tours' });
} else if (loginRedirect === 'users') {
setView({ type: 'users' });
}
setLoginRedirect(null);
};
@@ -291,14 +311,15 @@ export default function HomePage() {
view.type === 'checkup' ||
view.type === 'translations' ||
view.type === 'influences' ||
view.type === 'tours'
view.type === 'tours' ||
view.type === 'users'
) {
goToTimelineHome();
}
};
const openCheckup = () => {
if (!isCurator) {
if (!canCheckup) {
openCuratorLogin('checkup');
return;
}
@@ -306,7 +327,7 @@ export default function HomePage() {
};
const openTranslations = () => {
if (!isCurator) {
if (!canTranslations) {
openCuratorLogin('translations');
return;
}
@@ -314,7 +335,7 @@ export default function HomePage() {
};
const openInfluences = () => {
if (!isCurator) {
if (!canInfluences) {
openCuratorLogin('influences');
return;
}
@@ -322,13 +343,21 @@ export default function HomePage() {
};
const openToursEditor = () => {
if (!isCurator) {
if (!canTours) {
openCuratorLogin('tours');
return;
}
setView({ type: 'tours' });
};
const openUsers = () => {
if (!canUsers) {
openCuratorLogin('users');
return;
}
setView({ type: 'users' });
};
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
const data = await api.getPainting(paintingId);
const patch: Partial<Painting> = {
@@ -444,6 +473,58 @@ export default function HomePage() {
[]
);
const handleCuratorNotesUpdated = useCallback((paintingId: number, curatorNotes: string) => {
const patch: Partial<Painting> = { curator_notes: curatorNotes };
setView((current) => {
if (current.type !== 'painting' || current.paintingId !== paintingId) return current;
let returnTo = current.returnTo;
if (returnTo.type === 'gallery') {
returnTo = {
...returnTo,
data: patchPaintingInArtistDetail(returnTo.data, paintingId, patch),
};
}
if (returnTo.type === 'movement-gallery') {
returnTo = {
...returnTo,
data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch),
};
}
if (returnTo.type === 'tour-gallery') {
returnTo = {
...returnTo,
data: patchPaintingInTourDetail(returnTo.data, paintingId, patch),
};
}
return {
...current,
data: {
...current.data,
painting: { ...current.data.painting, ...patch },
},
returnTo,
};
});
setDetailArtistPaintings((list) =>
list.map((p) => (p.id === paintingId ? { ...p, ...patch } : p))
);
setGallerySession((session) => {
if (session?.kind === 'artist') {
return { ...session, data: patchPaintingInArtistDetail(session.data, paintingId, patch) };
}
if (session?.kind === 'movement') {
return { ...session, data: patchPaintingInMovementDetail(session.data, paintingId, patch) };
}
if (session?.kind === 'tour') {
return { ...session, data: patchPaintingInTourDetail(session.data, paintingId, patch) };
}
return session;
});
}, []);
const applyArtistPatch = useCallback((artistId: number, patch: Partial<Artist>) => {
setArtists((list) => list.map((a) => (a.id === artistId ? { ...a, ...patch } : a)));
@@ -555,7 +636,7 @@ export default function HomePage() {
const handleArtistClick = async (artistId: number) => {
setGalleryEntryLoading('Opening artist gallery…');
try {
await api.preloadArtistImages(artistId).catch(() => undefined);
void api.preloadArtistImages(artistId).catch(() => undefined);
const data = await api.getArtist(artistId);
openArtistGallery(artistId, data);
} catch {
@@ -566,10 +647,46 @@ export default function HomePage() {
};
const handleMovementClick = async (movementId: number) => {
setGalleryEntryLoading('Loading artists…');
try {
const artists = await api.getMovementArtistsSummary(movementId);
if (artists.length === 0) {
setError('No artists in this movement.');
return;
}
const movement = timelineData.movements.find((m) => m.id === movementId);
setArtistFilterModal({
movementId,
movementName: movement?.name ?? 'Movement',
artists,
});
} catch {
setError('Failed to load movement artists.');
} finally {
setGalleryEntryLoading(null);
}
};
const handleArtistFilterProceed = async (selectedIds: Set<number>) => {
if (!artistFilterModal) return;
const { movementId } = artistFilterModal;
setArtistFilterModal(null);
setGalleryEntryLoading('Opening movement gallery…');
try {
// Do not await preload — it only syncs disk paths and must not delay hall open.
// Fire it in parallel so any missing thumbs regenerate while the gallery boots.
const preloadPromise = api.preloadMovementImages(movementId).catch(() => undefined);
const data = await api.getMovementGallery(movementId);
openMovementGallery(movementId, data);
void preloadPromise;
const filtered: MovementGalleryDetail = {
...data,
paintings: data.paintings.filter((p) => selectedIds.has(Number(p.artist_id))),
};
if (filtered.paintings.length === 0) {
setError('No paintings for the selected artists.');
return;
}
openMovementGallery(movementId, filtered);
} catch {
setError('Failed to load movement gallery.');
} finally {
@@ -900,11 +1017,14 @@ export default function HomePage() {
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo: view });
}}
onInfluenceArtistClick={handleArtistClick}
isCurator={canNotes}
canCheckup={canCheckup}
debugMode={effectiveDebugMode}
debugShowMore={debugShowMore && isCurator}
debugShowMore={debugShowMore && canImages}
onPaintingImageFixed={handlePaintingImageFixed}
onPaintingCheckupFlagsUpdated={handlePaintingCheckupFlagsUpdated}
onPaintingRemoved={handlePaintingRemoved}
onCuratorNotesUpdated={handleCuratorNotesUpdated}
/>
</div>
)}
@@ -914,7 +1034,8 @@ export default function HomePage() {
<ArtistBio
artist={view.data.artist}
debugMode={effectiveDebugMode}
debugShowMore={debugShowMore && isCurator}
debugShowMore={debugShowMore && canImages}
canCheckup={canCheckup}
portraitRevision={portraitRevisions[view.data.artist.id]}
onBack={() => setView(view.returnTo)}
onEnterGallery={() =>
@@ -927,7 +1048,7 @@ export default function HomePage() {
)}
{view.type === 'influences' && (
isCurator ? (
canInfluences ? (
<InfluencesPage onBack={goToTimelineHome} />
) : (
<div className="curator-login-gate">
@@ -946,7 +1067,7 @@ export default function HomePage() {
)}
{view.type === 'tours' && (
isCurator ? (
canTours ? (
<ToursPage onBack={goToTimelineHome} />
) : (
<div className="curator-login-gate">
@@ -964,8 +1085,27 @@ export default function HomePage() {
)
)}
{view.type === 'users' && (
canUsers ? (
<UsersPage onBack={goToTimelineHome} />
) : (
<div className="curator-login-gate">
<h2>{t('curatorRequiredTitle')}</h2>
<p>{t('curatorRequiredBody')}</p>
<div className="curator-login-gate-actions">
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('users')}>
{t('curatorLogin')}
</button>
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
{t('backToGalleryBtn')}
</button>
</div>
</div>
)
)}
{view.type === 'translations' && (
isCurator ? (
canTranslations ? (
<TranslationsPage onBack={goToTimelineHome} />
) : (
<div className="curator-login-gate">
@@ -984,21 +1124,21 @@ export default function HomePage() {
)}
{view.type === 'checkup' && (
isCurator ? (
canCheckup ? (
<CheckupPage
onBack={goToTimelineHome}
onOpenPainting={handlePaintingClick}
/>
) : (
<div className="curator-login-gate">
<h2>Curator access required</h2>
<p>The painting checkup table is available to logged-in curators only.</p>
<h2>{t('curatorRequiredTitle')}</h2>
<p>{t('curatorRequiredBody')}</p>
<div className="curator-login-gate-actions">
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('checkup')}>
Curator login
{t('curatorLogin')}
</button>
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
Back to gallery
{t('backToGalleryBtn')}
</button>
</div>
</div>
@@ -1020,6 +1160,17 @@ export default function HomePage() {
onSelectTour={(tourId) => void handleSelectPublishedTour(tourId)}
/>
{artistFilterModal && (
<ArtistFilterModal
open
movementName={artistFilterModal.movementName}
artists={artistFilterModal.artists}
portraitRevisions={portraitRevisions}
onClose={() => setArtistFilterModal(null)}
onProceed={(selectedIds) => void handleArtistFilterProceed(selectedIds)}
/>
)}
{view.type === 'timeline' && (
<div className="home-page">
<header className="site-header">
@@ -1029,6 +1180,8 @@ export default function HomePage() {
<span className="curator-session-label" title={`Signed in as ${username}`}>
{username}
</span>
{canImages && (
<>
<button
type="button"
className={`debug-mode-toggle${debugMode ? ' debug-mode-toggle-active' : ''}`}
@@ -1048,6 +1201,9 @@ export default function HomePage() {
/>
Show more
</label>
</>
)}
{canInfluences && (
<button
type="button"
className="checkup-link-btn"
@@ -1056,6 +1212,8 @@ export default function HomePage() {
>
{t('influences')}
</button>
)}
{canTours && (
<button
type="button"
className="checkup-link-btn"
@@ -1064,6 +1222,8 @@ export default function HomePage() {
>
{t('toursEditor')}
</button>
)}
{canTranslations && (
<button
type="button"
className="checkup-link-btn"
@@ -1072,6 +1232,8 @@ export default function HomePage() {
>
{t('translations')}
</button>
)}
{canCheckup && (
<button
type="button"
className="checkup-link-btn"
@@ -1080,6 +1242,17 @@ export default function HomePage() {
>
{t('checkup')}
</button>
)}
{canUsers && (
<button
type="button"
className="checkup-link-btn"
onClick={openUsers}
title="Manage curator accounts"
>
{t('users')}
</button>
)}
<button
type="button"
className="curator-logout-btn"
+149
View File
@@ -0,0 +1,149 @@
.users-page {
padding: 1rem 1.5rem 2rem;
max-width: 1400px;
margin: 0 auto;
color: #f5f0e8;
}
.users-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.users-back {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.3);
color: inherit;
padding: 0.35rem 0.75rem;
border-radius: 6px;
cursor: pointer;
}
.users-error {
color: #f5a5a5;
}
.users-message {
color: #a8d5a2;
}
.users-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.users-list-panel,
.users-editor {
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
padding: 1rem;
max-height: 80vh;
overflow: auto;
}
.users-list table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
margin-bottom: 1.25rem;
}
.users-list th,
.users-list td {
padding: 0.45rem 0.6rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
text-align: left;
}
.users-list tbody tr {
cursor: pointer;
}
.users-row-selected {
background: rgba(255, 255, 255, 0.08);
}
.users-create,
.users-editor {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.users-create label,
.users-editor label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.9rem;
}
.users-create input,
.users-create select,
.users-editor input,
.users-editor select {
padding: 0.4rem 0.5rem;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(0, 0, 0, 0.25);
color: inherit;
}
.users-perms {
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
padding: 0.5rem 0.75rem;
margin: 0;
}
.users-perm-row {
flex-direction: row !important;
align-items: center;
gap: 0.5rem !important;
margin: 0.25rem 0;
}
.users-hint {
opacity: 0.85;
font-size: 0.9rem;
}
.users-meta {
opacity: 0.8;
font-size: 0.85rem;
}
.users-create button,
.users-editor button {
align-self: flex-start;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.3);
color: inherit;
padding: 0.4rem 0.85rem;
border-radius: 6px;
cursor: pointer;
}
.users-create button:disabled,
.users-editor button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.users-password-block {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.12);
display: flex;
flex-direction: column;
gap: 0.65rem;
}
@media (max-width: 900px) {
.users-layout {
grid-template-columns: 1fr;
}
}
+331
View File
@@ -0,0 +1,331 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import {
ALL_STAFF_PERMISSIONS,
api,
type StaffPermission,
type StaffUser,
} from '../api/client';
import { useAuth } from '../context/AuthContext';
import './UsersPage.css';
interface Props {
onBack: () => void;
}
function emptyCreateForm() {
return {
username: '',
password: '',
role: 'curator' as 'admin' | 'curator',
permissions: ['images', 'checkup', 'curator_notes'] as StaffPermission[],
};
}
export default function UsersPage({ onBack }: Props) {
const { t } = useTranslation('users');
const { isAdmin, username: selfUsername } = useAuth();
const [users, setUsers] = useState<StaffUser[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [createForm, setCreateForm] = useState(emptyCreateForm);
const [editRole, setEditRole] = useState<'admin' | 'curator'>('curator');
const [editPermissions, setEditPermissions] = useState<StaffPermission[]>([]);
const [editActive, setEditActive] = useState(true);
const [newPassword, setNewPassword] = useState('');
const selected = users.find((u) => u.id === selectedId) ?? null;
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await api.listUsers();
setUsers(data.users);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
if (!selected) return;
setEditRole(selected.role);
setEditPermissions(selected.permissions);
setEditActive(selected.is_active);
setNewPassword('');
setMessage(null);
}, [selected]);
const toggleCreatePerm = (perm: StaffPermission) => {
setCreateForm((prev) => {
const has = prev.permissions.includes(perm);
return {
...prev,
permissions: has
? prev.permissions.filter((p) => p !== perm)
: [...prev.permissions, perm],
};
});
};
const toggleEditPerm = (perm: StaffPermission) => {
setEditPermissions((prev) =>
prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm]
);
};
const handleCreate = async (e: FormEvent) => {
e.preventDefault();
setSaving(true);
setError(null);
setMessage(null);
try {
const { user } = await api.createUser({
username: createForm.username.trim(),
password: createForm.password,
role: createForm.role,
permissions: createForm.role === 'admin' ? ALL_STAFF_PERMISSIONS : createForm.permissions,
});
setCreateForm(emptyCreateForm());
setMessage(t('created'));
await load();
setSelectedId(user.id);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setSaving(false);
}
};
const handleSave = async () => {
if (!selected) return;
setSaving(true);
setError(null);
setMessage(null);
try {
await api.updateUser(selected.id, {
role: editRole,
permissions: editRole === 'admin' ? ALL_STAFF_PERMISSIONS : editPermissions,
is_active: editActive,
});
setMessage(t('saved'));
await load();
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setSaving(false);
}
};
const handleResetPassword = async () => {
if (!selected || !newPassword) return;
setSaving(true);
setError(null);
setMessage(null);
try {
await api.resetUserPassword(selected.id, newPassword);
setNewPassword('');
setMessage(t('passwordReset'));
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setSaving(false);
}
};
return (
<div className="users-page">
<header className="users-header">
<button type="button" className="users-back" onClick={onBack}>
{t('back')}
</button>
<h1>{t('title')}</h1>
</header>
{error && <p className="users-error">{error}</p>}
{message && <p className="users-message">{message}</p>}
<div className="users-layout">
<section className="users-list-panel">
{loading ? (
<p>{t('loading')}</p>
) : (
<div className="users-list">
<table>
<thead>
<tr>
<th>{t('username')}</th>
<th>{t('role')}</th>
<th>{t('active')}</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr
key={user.id}
className={user.id === selectedId ? 'users-row-selected' : undefined}
onClick={() => setSelectedId(user.id)}
>
<td>
{user.username}
{user.username === selfUsername ? ' *' : ''}
</td>
<td>{user.role === 'admin' ? t('roleAdmin') : t('roleCurator')}</td>
<td>{user.is_active ? t('active') : t('inactive')}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<form className="users-create" onSubmit={handleCreate}>
<h2>{t('createTitle')}</h2>
<label>
{t('username')}
<input
value={createForm.username}
onChange={(e) => setCreateForm((p) => ({ ...p, username: e.target.value }))}
autoComplete="off"
required
minLength={2}
maxLength={64}
/>
</label>
<label>
{t('password')}
<input
type="password"
value={createForm.password}
onChange={(e) => setCreateForm((p) => ({ ...p, password: e.target.value }))}
autoComplete="new-password"
required
minLength={8}
/>
</label>
<label>
{t('role')}
<select
value={createForm.role}
onChange={(e) =>
setCreateForm((p) => ({
...p,
role: e.target.value as 'admin' | 'curator',
}))
}
disabled={!isAdmin}
>
<option value="curator">{t('roleCurator')}</option>
{isAdmin && <option value="admin">{t('roleAdmin')}</option>}
</select>
</label>
{createForm.role === 'curator' && (
<fieldset className="users-perms">
<legend>{t('permissions')}</legend>
{ALL_STAFF_PERMISSIONS.map((perm) => (
<label key={perm} className="users-perm-row">
<input
type="checkbox"
checked={createForm.permissions.includes(perm)}
onChange={() => toggleCreatePerm(perm)}
/>
{t(`perm_${perm}`)}
</label>
))}
</fieldset>
)}
{createForm.role === 'admin' && <p className="users-hint">{t('adminAllPerms')}</p>}
<button type="submit" disabled={saving}>
{saving ? t('creating') : t('create')}
</button>
</form>
</section>
<section className="users-editor">
{!selected ? (
<p className="users-hint">{t('selectUser')}</p>
) : (
<>
<h2>{selected.username}</h2>
<p className="users-meta">
{t('lastLogin')}:{' '}
{selected.last_login_at
? new Date(selected.last_login_at).toLocaleString()
: t('never')}
</p>
<label>
{t('role')}
<select
value={editRole}
onChange={(e) => setEditRole(e.target.value as 'admin' | 'curator')}
disabled={!isAdmin}
>
<option value="curator">{t('roleCurator')}</option>
{isAdmin && <option value="admin">{t('roleAdmin')}</option>}
</select>
</label>
{editRole === 'curator' ? (
<fieldset className="users-perms">
<legend>{t('permissions')}</legend>
{ALL_STAFF_PERMISSIONS.map((perm) => (
<label key={perm} className="users-perm-row">
<input
type="checkbox"
checked={editPermissions.includes(perm)}
onChange={() => toggleEditPerm(perm)}
/>
{t(`perm_${perm}`)}
</label>
))}
</fieldset>
) : (
<p className="users-hint">{t('adminAllPerms')}</p>
)}
<label className="users-perm-row">
<input
type="checkbox"
checked={editActive}
onChange={(e) => setEditActive(e.target.checked)}
/>
{editActive ? t('active') : t('inactive')}
</label>
<button type="button" onClick={() => void handleSave()} disabled={saving}>
{saving ? t('saving') : t('save')}
</button>
<div className="users-password-block">
<h3>{t('resetPassword')}</h3>
<label>
{t('newPassword')}
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
minLength={8}
/>
</label>
<button
type="button"
onClick={() => void handleResetPassword()}
disabled={saving || newPassword.length < 8}
>
{t('resetPassword')}
</button>
</div>
</>
)}
</section>
</div>
</div>
);
}
+16
View File
@@ -20,6 +20,8 @@ export interface ArtMovement {
era_name?: string;
description: string;
color: string;
/** Influence edges on paintings by artists in this movement (timeline band weight). */
influence_link_count?: number;
}
export interface Artist {
@@ -60,6 +62,7 @@ export interface Painting {
year: number;
year_end?: number;
description: string;
curator_notes?: string;
image_path: string | null;
thumbnail_path?: string | null;
image_cache_key?: number | null;
@@ -133,6 +136,19 @@ export interface MovementGalleryDetail {
paintings: Painting[];
}
/** Artist row for movement gallery entry filter (portraits + painting counts). */
export interface ArtistSummary {
id: number;
name: string;
birth_year: number | null;
death_year: number | null;
portrait_path: string | null;
portrait_thumb_path?: string | null;
portrait_cache_key?: number | null;
portrait_thumb_cache_key?: number | null;
painting_count: number;
}
export interface TourSummary {
id: number;
title: string;
+263 -12
View File
@@ -8,6 +8,9 @@ export type SurfaceTextureKind =
| 'marble-veined-carrara'
| 'marble-veined-emerald'
| 'marble-checker'
| 'marble-opus-sectile'
| 'marble-revetment'
| 'coffered-wood'
| 'limestone'
| 'sandstone'
| 'rough-stone'
@@ -244,6 +247,43 @@ function marbleVeined(ctx: CanvasRenderingContext2D, size: number, base: string,
noiseOverlay(ctx, size, 0.035, seed + 1);
}
/** Veined marble confined to one slab rect — for revetment panels and inlay. */
function marbleSlab(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
base: string,
vein: string,
seed: number
) {
const rand = seeded(seed);
ctx.save();
ctx.beginPath();
ctx.rect(x, y, w, h);
ctx.clip();
ctx.fillStyle = base;
ctx.fillRect(x, y, w, h);
const [vr, vg, vb] = hexToRgb(vein);
const strands = 10 + Math.floor(w / 24);
for (let i = 0; i < strands; i++) {
ctx.strokeStyle = `rgba(${vr},${vg},${vb},${0.1 + rand() * 0.3})`;
ctx.lineWidth = 0.6 + rand() * 3.4;
ctx.beginPath();
let px = x + rand() * w;
let py = y + rand() * h;
ctx.moveTo(px, py);
for (let s = 0; s < 9; s++) {
px += (rand() - 0.35) * w * 0.28;
py += (rand() - 0.5) * h * 0.22;
ctx.quadraticCurveTo(px - w * 0.05, py + h * 0.04, px, py);
}
ctx.stroke();
}
ctx.restore();
}
function woodPanelSurface(
ctx: CanvasRenderingContext2D,
size: number,
@@ -346,6 +386,161 @@ function paintSurface(kind: SurfaceTextureKind, size: number): ImageData {
noiseOverlay(ctx, size, 0.03, 103);
break;
}
case 'marble-opus-sectile': {
// Cosmatesque paving: cut-stone squares with diagonally-set inlays and
// corner triangles in porphyry, verd-antique, and giallo over travertine.
const stones = [
['#6a3c42', '#4c2830'],
['#47554b', '#333f38'],
['#8a7752', '#66573c'],
['#4e4a45', '#363330'],
];
fill(ctx, size, '#7d6e58');
const grid = 2;
const cell = size / grid;
for (let row = 0; row < grid; row++) {
for (let col = 0; col < grid; col++) {
const x = col * cell;
const y = row * cell;
const rand = seeded(610 + row * grid + col);
// Travertine ground slab for this bay
marbleSlab(ctx, x, y, cell, cell, '#8f7f66', '#6d5f4c', 620 + row * grid + col);
const pick = stones[(row * 3 + col * 5 + Math.floor(rand() * 2)) % stones.length];
const inset = cell * 0.045;
// Corner triangles
ctx.fillStyle = pick[1];
const corners: [number, number, number, number, number, number][] = [
[x + inset, y + inset, x + cell * 0.5, y + inset, x + inset, y + cell * 0.5],
[x + cell - inset, y + inset, x + cell * 0.5, y + inset, x + cell - inset, y + cell * 0.5],
[x + inset, y + cell - inset, x + cell * 0.5, y + cell - inset, x + inset, y + cell * 0.5],
[x + cell - inset, y + cell - inset, x + cell * 0.5, y + cell - inset, x + cell - inset, y + cell * 0.5],
];
for (const [ax, ay, bx, by, cx2, cy2] of corners) {
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(bx, by);
ctx.lineTo(cx2, cy2);
ctx.closePath();
ctx.fill();
}
// Diamond inlay set on the diagonal
const cx = x + cell / 2;
const cy = y + cell / 2;
const r = cell * 0.34;
ctx.save();
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r, cy);
ctx.lineTo(cx, cy + r);
ctx.lineTo(cx - r, cy);
ctx.closePath();
ctx.clip();
marbleSlab(ctx, cx - r, cy - r, r * 2, r * 2, pick[0], pick[1], 640 + row * grid + col);
ctx.restore();
ctx.strokeStyle = 'rgba(40,30,22,0.45)';
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r, cy);
ctx.lineTo(cx, cy + r);
ctx.lineTo(cx - r, cy);
ctx.closePath();
ctx.stroke();
// Cut joint around the bay
ctx.strokeStyle = 'rgba(40,30,22,0.5)';
ctx.lineWidth = 2;
ctx.strokeRect(x + 1, y + 1, cell - 2, cell - 2);
}
}
noiseOverlay(ctx, size, 0.03, 601);
break;
}
case 'marble-revetment': {
// Basilica wall cladding: book-matched marble panels in carved stone
// surrounds — Santa Sabina / San Vitale, no mosaic.
fill(ctx, size, '#a8977c');
const cols = 2;
const rows = 2;
const pw = size / cols;
const ph = size / rows;
const fields = [
['#5e6d64', '#33423a'],
['#8a7a5e', '#5a4c36'],
['#5e6d64', '#33423a'],
['#6f5f56', '#453a34'],
];
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const x = col * pw;
const y = row * ph;
// Travertine surround
marbleSlab(ctx, x, y, pw, ph, '#b0a084', '#8a7658', 660 + row * cols + col);
const m = pw * 0.09;
const iw = pw - m * 2;
const ih = ph - m * 2;
const field = fields[(row * cols + col) % fields.length];
// Book-matched pair: the same slab mirrored about the panel centre
const half = iw / 2;
marbleSlab(ctx, x + m, y + m, half, ih, field[0], field[1], 670 + row * cols + col);
ctx.save();
ctx.translate(x + m + iw, y + m);
ctx.scale(-1, 1);
ctx.drawImage(ctx.canvas, x + m, y + m, half, ih, 0, 0, half, ih);
ctx.restore();
// Moulded frame around the panel
ctx.strokeStyle = 'rgba(60,44,30,0.55)';
ctx.lineWidth = 3;
ctx.strokeRect(x + m, y + m, iw, ih);
ctx.strokeStyle = 'rgba(255,244,222,0.35)';
ctx.lineWidth = 2;
ctx.strokeRect(x + m - 4, y + m - 4, iw + 8, ih + 8);
}
}
// Horizontal string course between panel registers
ctx.fillStyle = 'rgba(90,68,44,0.35)';
ctx.fillRect(0, ph - 3, size, 6);
noiseOverlay(ctx, size, 0.04, 680);
break;
}
case 'coffered-wood': {
// Open-timber coffered basilica ceiling: recessed dark walnut boxes with
// lit chamfers so the grid reads as depth, not a flat pattern.
fill(ctx, size, '#2e1e12');
const n = 6;
const cell = size / n;
for (let row = 0; row < n; row++) {
for (let col = 0; col < n; col++) {
const x = col * cell;
const y = row * cell;
const beam = cell * 0.14;
// Beam faces catch light from below
ctx.fillStyle = '#6a4a2c';
ctx.fillRect(x, y, cell, beam);
ctx.fillRect(x, y, beam, cell);
ctx.fillStyle = '#4a3018';
ctx.fillRect(x + cell - beam, y, beam, cell);
ctx.fillRect(x, y + cell - beam, cell, beam);
// Recessed coffer pan
const px = x + beam;
const py = y + beam;
const pwid = cell - beam * 2;
const phei = cell - beam * 2;
const grad = ctx.createLinearGradient(px, py, px, py + phei);
grad.addColorStop(0, '#22150c');
grad.addColorStop(1, '#3c2616');
ctx.fillStyle = grad;
ctx.fillRect(px, py, pwid, phei);
// Central gilt rosette boss
ctx.fillStyle = '#8a6a2a';
ctx.beginPath();
ctx.arc(px + pwid / 2, py + phei / 2, Math.min(pwid, phei) * 0.13, 0, Math.PI * 2);
ctx.fill();
}
}
noiseOverlay(ctx, size, 0.05, 690);
break;
}
case 'limestone':
fill(ctx, size, '#ddd4c4');
for (let y = 0; y < size; y += 4) {
@@ -460,15 +655,59 @@ function paintSurface(kind: SurfaceTextureKind, size: number): ImageData {
}
break;
case 'mosaic-byzantine': {
fill(ctx, size, '#1a1814');
const cell = size / 32;
const colors = ['#d4af37', '#8a3020', '#2060a0', '#f0ece0', '#408040'];
for (let y = 0; y < size; y += cell) {
for (let x = 0; x < size; x += cell) {
ctx.fillStyle = colors[(x / cell + y / cell) % colors.length];
ctx.fillRect(x + 1, y + 1, cell - 2, cell - 2);
// Gold-ground tessera field (gold-leaf glass, individually jittered) with
// sparse inlaid accent tesserae and a meander border course — Hagia Sophia /
// Ravenna style, not a flat multicolor checker.
fill(ctx, size, '#1a1408');
const grid = 32;
const cell = size / grid;
const rand = seeded(777);
const accents: [number, number, number][] = [
[32, 52, 98],
[126, 30, 44],
[28, 78, 58],
[18, 16, 22],
];
for (let gy = 0; gy < grid; gy++) {
for (let gx = 0; gx < grid; gx++) {
const x = gx * cell;
const y = gy * cell;
const roll = rand();
let r: number, g: number, b: number;
if (roll < 0.05) {
[r, g, b] = accents[Math.floor(rand() * accents.length)];
} else {
const j = (rand() - 0.5) * 50;
r = 214 + j;
g = 170 + j * 0.72;
b = 58 + j * 0.35;
}
ctx.fillStyle = rgb(r, g, b);
const pad = 1 + rand() * 1.4;
ctx.fillRect(x + pad, y + pad, cell - pad * 2, cell - pad * 2);
}
}
// Dark grout between tesserae reads as individual glass/gold tiles.
ctx.strokeStyle = 'rgba(16,10,4,0.4)';
ctx.lineWidth = 1;
for (let i = 0; i <= grid; i++) {
ctx.beginPath();
ctx.moveTo(i * cell, 0);
ctx.lineTo(i * cell, size);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, i * cell);
ctx.lineTo(size, i * cell);
ctx.stroke();
}
// Meander (key-pattern) register band dividing the panel, in navy and gold.
const bandRow = Math.floor(grid * 0.47);
for (let gx = 0; gx < grid; gx++) {
const on = Math.floor(gx / 2) % 2 === 0;
ctx.fillStyle = on ? '#0f2a52' : '#e0b840';
ctx.fillRect(gx * cell, bandRow * cell, cell, cell * 0.5);
}
noiseOverlay(ctx, size, 0.045, 778);
break;
}
case 'mosaic-roman': {
@@ -583,7 +822,7 @@ function imageDataToNormalMap(data: ImageData, size: number, strength = 2.5): Im
return out;
}
function imageDataToTexture(data: ImageData): THREE.CanvasTexture {
function imageDataToTexture(data: ImageData, colorSpace: THREE.ColorSpace): THREE.CanvasTexture {
const canvas = document.createElement('canvas');
canvas.width = data.width;
canvas.height = data.height;
@@ -591,8 +830,8 @@ function imageDataToTexture(data: ImageData): THREE.CanvasTexture {
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.RepeatWrapping;
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 16;
tex.colorSpace = colorSpace;
tex.anisotropy = 8;
return tex;
}
@@ -611,6 +850,9 @@ const ROUGHNESS: Partial<Record<SurfaceTextureKind, number>> = {
'marble-veined-carrara': 0.16,
'marble-veined-emerald': 0.18,
'marble-checker': 0.14,
'marble-opus-sectile': 0.2,
'marble-revetment': 0.34,
'coffered-wood': 0.72,
'gilded-stucco': 0.22,
'velvet-crimson': 0.92,
'velvet-navy': 0.92,
@@ -638,6 +880,8 @@ const ROUGHNESS: Partial<Record<SurfaceTextureKind, number>> = {
const METALNESS: Partial<Record<SurfaceTextureKind, number>> = {
'marble-white': 0.08,
'marble-veined-carrara': 0.1,
'marble-opus-sectile': 0.14,
'marble-revetment': 0.1,
'gilded-stucco': 0.65,
'concrete-polished': 0.12,
'terrazzo': 0.15,
@@ -647,6 +891,9 @@ const METALNESS: Partial<Record<SurfaceTextureKind, number>> = {
const METERS: Partial<Record<SurfaceTextureKind, number>> = {
'marble-checker': 2.4,
'marble-opus-sectile': 5.0,
'marble-revetment': 4.4,
'coffered-wood': 4.2,
flagstone: 3.0,
'mosaic-byzantine': 1.8,
'mosaic-roman': 2.0,
@@ -669,6 +916,9 @@ const NORMAL_SCALE: Partial<Record<SurfaceTextureKind, number>> = {
'painted-lime': 0.62,
'marble-white': 0.75,
'marble-veined-carrara': 0.8,
'marble-opus-sectile': 0.55,
'marble-revetment': 0.6,
'coffered-wood': 1.0,
'velvet-crimson': 0.5,
'velvet-navy': 0.5,
'velvet-emerald': 0.5,
@@ -686,8 +936,9 @@ export function getSurfaceTexture(kind: SurfaceTextureKind): SurfaceTextureSet {
const normalData = imageDataToNormalMap(colorData, TEXTURE_SIZE, normalStrengthFor(kind));
const set: SurfaceTextureSet = {
map: imageDataToTexture(colorData),
normalMap: imageDataToTexture(normalData),
map: imageDataToTexture(colorData, THREE.SRGBColorSpace),
// Normal maps must stay linear — sRGB encoding breaks lighting on walls.
normalMap: imageDataToTexture(normalData, THREE.NoColorSpace),
roughness: ROUGHNESS[kind] ?? 0.75,
metalness: METALNESS[kind] ?? 0.04,
metersPerRepeat: METERS[kind] ?? 2.8,
+120 -7
View File
@@ -29,6 +29,8 @@ export interface MovementHallLayout {
segments: WallSegment[];
paintingCount: number;
yearLabel: string;
/** When true, far (Z) wall has a center exit; paintings hang on flanks only. */
endWallHasDoor: boolean;
}
const WALL_HEIGHT = 4.2;
@@ -43,6 +45,9 @@ const MAX_FRAME_H = 1.35;
const MIN_HALL_SIZE = 10;
const MIN_HALL_WIDTH = 11;
const WALL_PADDING = 1.4;
/** Exit opening on the far wall — paintings hang on the flanking panels only. */
const DOOR_WIDTH = 2.4;
const DOOR_CLEARANCE = DOOR_WIDTH + 0.55;
const FRAME_MAT_BORDER = 0.1;
const FRAME_RAIL = 0.08;
@@ -110,16 +115,29 @@ export function splitPaintingsIntoMovementHalls(paintings: Painting[]): Painting
}
/**
* First half left wall, second half right.
* U-shaped visit order: left far/end wall right.
* Callers pass paintings already in visit order; first work hangs near the
* entrance on the left, last work near the entrance on the right.
*/
function distributeToSideWalls(paintings: Painting[]) {
const mid = Math.ceil(paintings.length / 2);
function distributeAcrossWalls(paintings: Painting[]) {
const n = paintings.length;
if (n < 3) {
const mid = Math.ceil(n / 2);
return {
back: [] as Painting[],
left: paintings.slice(0, mid),
right: paintings.slice(mid),
};
}
const q = Math.floor(n / 3);
const r = n % 3;
const leftCount = q + (r > 0 ? 1 : 0);
const backCount = q + (r > 1 ? 1 : 0);
return {
left: paintings.slice(0, leftCount),
back: paintings.slice(leftCount, leftCount + backCount),
right: paintings.slice(leftCount + backCount),
};
}
function layoutSideSlots(
@@ -149,21 +167,96 @@ function layoutSideSlots(
});
}
/** Far wall ahead of the entrance — full span, or split across door flanks. */
function layoutBackSlots(
paintings: Painting[],
width: number,
halfD: number,
inset: number,
hasDoor: boolean
): FrameSlot[] {
if (paintings.length === 0) return [];
const y = EYE_HEIGHT;
const z = -halfD + inset + WALL_STANDOFF;
if (!hasDoor) {
const { slots: rowSlots } = layoutRow(paintings, width);
return rowSlots.map((s) => ({
maxW: s.maxW,
maxH: s.maxH,
rotationY: 0,
side: 'back' as const,
position: [s.offset, y, z] as [number, number, number],
}));
}
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 leftCenterX = -DOOR_CLEARANCE / 2 - flankSpan / 2;
const rightCenterX = DOOR_CLEARANCE / 2 + flankSpan / 2;
const doorEdge = DOOR_CLEARANCE / 2 + 0.08;
const mapFlank = (group: Painting[], centerX: number, side: 'left' | 'right'): FrameSlot[] => {
if (group.length === 0) return [];
const { slots: rowSlots } = layoutRow(group, flankSpan);
return rowSlots.map((s) => {
let x = centerX + s.offset;
const halfOuter = frameOuterW(s.maxW, false) / 2;
if (side === 'left' && x + halfOuter > -doorEdge) {
x = -doorEdge - halfOuter;
} else if (side === 'right' && x - halfOuter < doorEdge) {
x = doorEdge + halfOuter;
}
return {
maxW: s.maxW,
maxH: s.maxH,
rotationY: 0,
side: 'back' as const,
position: [x, y, z] as [number, number, number],
};
});
};
return [...mapFlank(leftFlank, leftCenterX, 'left'), ...mapFlank(rightFlank, rightCenterX, 'right')];
}
export function buildMovementHallLayout(
paintings: Painting[],
hallIndex: number,
hallCount: number
): MovementHallLayout {
const { left, right } = distributeToSideWalls(paintings);
const { left, back, right } = distributeAcrossWalls(paintings);
const leftSpan = layoutRow(left, MIN_HALL_SIZE);
const rightSpan = layoutRow(right, MIN_HALL_SIZE);
const depth = Math.max(MIN_HALL_SIZE, leftSpan.spanNeeded, rightSpan.spanNeeded);
const width = MIN_HALL_WIDTH;
// Single-wing halls put the exit on the entrance wall, so the far wall is solid
// and can use its full width for paintings. Multi-wing halls keep a back exit.
const endWallHasDoor = hallCount > 1;
let width: number;
if (endWallHasDoor) {
const mid = Math.ceil(back.length / 2);
const leftFlankNeed = layoutRow(back.slice(0, mid), MIN_FRAME_W + WALL_PADDING).spanNeeded;
const rightFlankNeed = layoutRow(back.slice(mid), MIN_FRAME_W + WALL_PADDING).spanNeeded;
width = Math.max(MIN_HALL_WIDTH, leftFlankNeed + DOOR_CLEARANCE + rightFlankNeed);
} else {
width = Math.max(MIN_HALL_WIDTH, layoutRow(back, MIN_HALL_SIZE).spanNeeded);
}
const halfW = width / 2;
const halfD = depth / 2;
const inset = WALL_THICKNESS / 2 + MOUNT_OFFSET;
const segments: WallSegment[] = [
{ side: 'back', label: '', paintings: [], slots: [] },
{
side: 'back',
label: back.length > 0 ? `Wing ${hallIndex + 1} · End wall` : '',
paintings: back,
slots: layoutBackSlots(back, width, halfD, inset, endWallHasDoor),
},
{
side: 'left',
label: left.length > 0 ? `Wing ${hallIndex + 1} · Left wall` : '',
@@ -186,6 +279,7 @@ export function buildMovementHallLayout(
segments,
paintingCount: paintings.length,
yearLabel: formatYearLabel(paintings),
endWallHasDoor,
};
}
@@ -232,7 +326,7 @@ function windowTemplate(style: MovementInteriorStyle): Pick<GalleryWindowSpec, '
return { style: 'sash', lightColor: style.warmLight, lightIntensity: 2.8, width: 1.4, height: 1.4 };
}
/** Place windows on side walls only, in gaps between painting frames. */
/** Place windows on side walls in gaps between frames; fall back to high clerestory when packed. */
export function computeSideWallWindows(
layout: MovementHallLayout,
interiorStyle: MovementInteriorStyle
@@ -270,6 +364,25 @@ export function computeSideWallWindows(
lightIntensity: tmpl.lightIntensity,
});
}
// Packed walls: still add high clerestory windows so the hall gets daylight + style.
if (!specs.some((s) => s.wall === side)) {
const span = Math.max(2.4, halfD * 1.2);
const count = span > 8 ? 2 : 1;
for (let i = 0; i < count; i++) {
const t = count === 1 ? 0 : (i === 0 ? -0.35 : 0.35);
specs.push({
wall: side,
x: halfD * t,
y: 3.45,
width: Math.min(tmpl.width, 1.15),
height: Math.min(tmpl.height, 1.05),
style: tmpl.style,
lightColor: tmpl.lightColor,
lightIntensity: tmpl.lightIntensity * 0.85,
});
}
}
}
return specs;
+7
View File
@@ -36,3 +36,10 @@ export function paintingHasInfluenceLinks(
const flag = painting.has_influence_links;
return flag === true || flag === 't' || flag === 'true' || flag === 1;
}
/** True when the painting has public curator notes. */
export function paintingHasCuratorNotes(
painting: Pick<Painting, 'curator_notes'>
): boolean {
return Boolean(painting.curator_notes?.trim());
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 501 KiB

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 410 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 941 KiB

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 584 KiB

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 804 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 486 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 402 KiB

After

Width:  |  Height:  |  Size: 585 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 MiB

After

Width:  |  Height:  |  Size: 701 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 MiB

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 427 KiB

After

Width:  |  Height:  |  Size: 540 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 633 KiB

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 971 KiB

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 877 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 641 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 38 KiB

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