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
60 changed files with 4891 additions and 911 deletions
+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
+106 -27
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,30 +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/paintings/:id/curator-notes` | `painting.update_curator_notes` |
| `PATCH /api/artists/:id/checkup-flags` | `artist.checkup_flags` |
| `POST /api/paintings/:id/fix-image` | `painting.fix_image` |
| `POST /api/paintings/:id/clear-image` | `painting.clear_image` |
| `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)).
---
@@ -340,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**
@@ -564,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**
@@ -576,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.
@@ -751,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**
@@ -886,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 |
+13 -6
View File
@@ -215,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:
+10 -5
View File
@@ -95,16 +95,19 @@ CURATOR_USERNAME=curator
CURATOR_PASSWORD=your-secure-password
```
Then open the gallery → **Curator login** (top-right) → use debug mode / Checkup / Translations / **Influences** / inline **curator notes** on painting detail. Mutations are logged in `curator_audit_log` (view in pgAdmin).
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).
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), curator notes, 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`):**
@@ -143,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`.
@@ -213,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`.
---
+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)
+57 -41
View File
@@ -8,7 +8,7 @@ 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, U-shaped hang (left → end wall → right).
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**).
@@ -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, HDR Environment settles, or door/hall shaders warm up after the hall opens (painting images continue loading after the overlay dismisses) |
| Overlay **“Loading paintings…”** | While wall painting textures are still downloading / uploading to the GPU |
| Overlay **“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.
@@ -298,10 +299,10 @@ Each artist has **exactly one hall**. The hall is a rectangular room sized to fi
| 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); fixture is mounted **upside down** (`InfluencePictureLamp` in `VirtualGallery.tsx`) |
| Influence lamps | A golden picture light appears **above frames** whose work has any influence-graph edge (`has_influence_links` from the API); fixture is mounted **upside down** 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 |
@@ -325,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, end, and right**end-wall works sit on the panels beside the exit doors; front wall remains passage / solid |
| 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 upside-down 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 |
@@ -351,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.
@@ -372,9 +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.
**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).
**Boot overlay** (`VirtualGallery.tsx`): the center shows **“Loading gallery…”** until the WebGL canvas is ready, HDR `Environment` has settled (or timed out / failed), and a one-shot `gl.compileAsync` warm-up finishes so entrance doors / passages (often frustum-culled at spawn) do not hitch on the first turn. Painting textures keep loading in the background (prefer thumbnails; per-image timeouts; GPU upload after decode) so large halls (50+ works) are not stuck on the overlay. Env and shader warm-up also have short timeouts. The 3D hall stays mounted while painting detail or bio overlays are open; returning remounts the canvas when the hall becomes active again.
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.
@@ -430,7 +440,7 @@ 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 / GPU warm-up, Environment settle, and context-restore states so the user always knows work is still in progress.
@@ -441,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)
+27 -10
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 all images on dev from prod:** `npm run prodto:dev:images`. **One artist only:** map the SMB share, then `robocopy` that artists `ArtistName*` files under `paintings/` and `paintings/thumbs/` (see [FAC.md](FAC.md#dev--prod-image-sync-smb)). **Bidirectional merge** (newer file wins): `npm run harmonize:images` or full `npm run harmonize` — see [harmonize-dev-prod.md](harmonize-dev-prod.md). General sync reference: [environments.md](environments.md).
**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` |
@@ -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, U-shaped hang, window gap placement) lives in `client/src/utils/movementHallLayout.ts`. Visit order fills the **left wall** (first work at the entrance), then the **far/end wall**, then the **right** (last work at the entrance). The same hang applies to artist halls and guided tours. To change a movements look, edit its entry in `movement-interior-styles.ts` and rebuild the client.
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)
@@ -364,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).
@@ -434,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
+2 -2
View File
@@ -117,7 +117,7 @@ npm run infra:db:split-dev-prod
CURATOR_PASSWORD=your-secure-password
```
Omit `SESSION_COOKIE_SECURE` so cookies follow the request scheme (`TRUST_PROXY` + HTTPS → Secure). Set `true`/`false` to force. `npm run dev:migrate` creates auth tables and bootstraps the first curator when `users` is empty. Reset password later with `npm run dev:reset-curator`.
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:
@@ -341,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
+5 -2
View File
@@ -83,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).
@@ -98,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 |
+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):
Binary file not shown.
Binary file not shown.
+119 -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> {
@@ -146,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;
@@ -272,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;
@@ -337,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) => {
@@ -373,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`)),
@@ -788,6 +905,7 @@ export const api = {
}),
preloadArtistImages,
preloadMovementImages,
};
export interface TranslationWorklistItem {
+12 -8
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,14 +324,16 @@ export default function ArtistBio({
<p className="debug-image-status">No portrait image result found.</p>
)}
<div className="debug-action-buttons">
<button
type="button"
className="debug-checked-btn"
onClick={handleMarkChecked}
disabled={!!artist.checkup_checked || markingChecked || uploading}
>
{markingChecked ? '…' : 'Checked'}
</button>
{canCheckup && (
<button
type="button"
className="debug-checked-btn"
onClick={handleMarkChecked}
disabled={!!artist.checkup_checked || markingChecked || uploading}
>
{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 -14
View File
@@ -173,22 +173,16 @@ 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}
/>
<pointLight
position={[0, 0, 0.25]}
intensity={spec.lightIntensity * 0.45}
distance={10}
color={glassColor}
decay={2}
/>
</group>
);
}
@@ -222,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);
@@ -244,10 +239,11 @@ 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}
/>
</group>
+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
+139 -21
View File
@@ -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:
+13 -8
View File
@@ -22,6 +22,8 @@ interface Props {
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?: (
@@ -206,6 +208,7 @@ export default function PaintingDetailView({
onArtistBio,
onInfluenceArtistClick,
isCurator = false,
canCheckup = true,
debugMode = false,
debugShowMore = false,
onPaintingImageFixed,
@@ -734,14 +737,16 @@ export default function PaintingDetailView({
<p className="debug-image-status">No Google image result found.</p>
)}
<div className="debug-action-buttons">
<button
type="button"
className="debug-checked-btn"
onClick={handleMarkChecked}
disabled={!!painting.checkup_checked || markingChecked || uploading}
>
{markingChecked ? '…' : 'Checked'}
</button>
{canCheckup && (
<button
type="button"
className="debug-checked-btn"
onClick={handleMarkChecked}
disabled={!!painting.checkup_checked || markingChecked || uploading}
>
{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);
}
+424 -173
View File
@@ -12,7 +12,7 @@ import type {
MovementArtistGroup,
TourGalleryDetail,
} from '../types';
import { galleryImageUrlWithRevision, imageUrl, api } from '../api/client';
import { galleryImageUrlCandidates, imageUrl, api } from '../api/client';
import { comparePaintingsChronological, paintingHasCuratorNotes, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
import { cloneSurfaceTexture, getSurfaceTexture } from '../utils/galleryProceduralTextures';
import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles';
@@ -98,9 +98,48 @@ const REVIEWED_MAT_BORDER = FRAME_MAT_BORDER * 2;
const REVIEWED_RAIL = FRAME_RAIL * 2;
const EYE_HEIGHT = 1.65;
const FRAME_GAP = 0.32;
const SHADER_WARM_TIMEOUT_MS = 4000;
/** Per-image fetch/decode deadline so a stuck request cannot hold the counter forever. */
const TEXTURE_LOAD_TIMEOUT_MS = 10000;
const SHADER_WARM_TIMEOUT_MS = 2000;
/** Per-image fetch/decode deadline so a stuck request cannot hold the overlay counter forever. */
const TEXTURE_LOAD_TIMEOUT_MS = 20000;
/** Cap parallel WebGL texture downloads — large halls otherwise stampede the browser pool. */
const MAX_PARALLEL_TEXTURE_LOADS = 8;
const textureSlotWaiters: Array<() => void> = [];
let textureLoadsInFlight = 0;
function acquireTextureLoadSlot(): {
promise: Promise<() => void>;
cancel: () => void;
} {
let grantFn: (() => void) | null = null;
let cancelled = false;
const promise = new Promise<() => void>((resolve) => {
grantFn = () => {
if (cancelled) return;
textureLoadsInFlight++;
let released = false;
resolve(() => {
if (released) return;
released = true;
textureLoadsInFlight = Math.max(0, textureLoadsInFlight - 1);
const next = textureSlotWaiters.shift();
if (next) next();
});
};
if (textureLoadsInFlight < MAX_PARALLEL_TEXTURE_LOADS) grantFn();
else textureSlotWaiters.push(grantFn);
});
return {
promise,
cancel: () => {
cancelled = true;
if (grantFn) {
const idx = textureSlotWaiters.indexOf(grantFn);
if (idx >= 0) textureSlotWaiters.splice(idx, 1);
}
},
};
}
const MIN_FRAME_W = 0.45;
const MAX_FRAME_W = 1.05;
const MAX_FRAME_H = 1.35;
@@ -110,6 +149,8 @@ const WALL_PADDING = 1.4;
/** Every wall shows at most one row; side-wall depth grows to fit the catalog. */
const MAX_WALL_ROWS = 1;
const DOOR_WIDTH = 2.4;
/** Minimum horizontal distance from walls, corners, door planes, and painting faces. */
const PLAYER_CLEARANCE = 0.5;
const DOOR_HEIGHT = 2.5;
/** Museum exit — dimensions derived from door opening. */
const EXIT_JAMB = 0.13;
@@ -176,6 +217,73 @@ interface HallLayout {
segments: WallSegment[];
}
interface XZBounds {
minX: number;
maxX: number;
minZ: number;
maxZ: number;
}
/** Inner wall face half-extents (room center → inside of wall box). */
function innerWallHalfExtents(width: number, depth: number): { halfW: number; halfD: number } {
return {
halfW: width / 2 - WALL_THICKNESS / 2,
halfD: depth / 2 - WALL_THICKNESS / 2,
};
}
/** Keep-out box in front of a hanging painting (XZ), including player clearance. */
function paintingKeepOutBounds(slot: FrameSlot): XZBounds {
const [px, , pz] = slot.position;
const halfAlong = slot.maxW / 2 + FRAME_MAT_BORDER + FRAME_RAIL + 0.04;
const intoRoom = FRAME_DEPTH + FRAME_FACE_Z + PLAYER_CLEARANCE;
if (slot.side === 'left') {
return { minX: px - 0.02, maxX: px + intoRoom, minZ: pz - halfAlong, maxZ: pz + halfAlong };
}
if (slot.side === 'right') {
return { minX: px - intoRoom, maxX: px + 0.02, minZ: pz - halfAlong, maxZ: pz + halfAlong };
}
// back wall — faces into the room (+Z)
return { minX: px - halfAlong, maxX: px + halfAlong, minZ: pz - 0.02, maxZ: pz + intoRoom };
}
/** Door jamb keep-outs at an opening in the front (+Z) or back (Z) wall. */
function doorJambKeepOuts(halfD: number, atFront: boolean): XZBounds[] {
const jambZ = atFront ? halfD : -halfD;
const along = PLAYER_CLEARANCE;
const intoRoom = PLAYER_CLEARANCE;
const leftX = -DOOR_WIDTH / 2;
const rightX = DOOR_WIDTH / 2;
if (atFront) {
return [
{ minX: leftX - along, maxX: leftX + along, minZ: jambZ - intoRoom, maxZ: jambZ + 0.02 },
{ minX: rightX - along, maxX: rightX + along, minZ: jambZ - intoRoom, maxZ: jambZ + 0.02 },
];
}
return [
{ minX: leftX - along, maxX: leftX + along, minZ: jambZ - 0.02, maxZ: jambZ + intoRoom },
{ minX: rightX - along, maxX: rightX + along, minZ: jambZ - 0.02, maxZ: jambZ + intoRoom },
];
}
function pointInBounds(x: number, z: number, b: XZBounds): boolean {
return x > b.minX && x < b.maxX && z > b.minZ && z < b.maxZ;
}
/** Push a point out of an AABB via the shortest axis (XZ). */
function pushOutOfBounds(pos: { x: number; z: number }, b: XZBounds): void {
if (!pointInBounds(pos.x, pos.z, b)) return;
const dxMin = pos.x - b.minX;
const dxMax = b.maxX - pos.x;
const dzMin = pos.z - b.minZ;
const dzMax = b.maxZ - pos.z;
const m = Math.min(dxMin, dxMax, dzMin, dzMax);
if (m === dxMin) pos.x = b.minX;
else if (m === dxMax) pos.x = b.maxX;
else if (m === dzMin) pos.z = b.minZ;
else pos.z = b.maxZ;
}
function paintingIsReviewed(painting: Painting): boolean {
return !!painting.checkup_checked;
}
@@ -578,84 +686,118 @@ function CanvasCover({
);
}
function usePaintingTexture(url: string | null) {
function usePaintingTexture(urls: string[] | string | null) {
const candidates = useMemo(() => {
const list = Array.isArray(urls) ? urls.filter(Boolean) : urls ? [urls] : [];
return list;
}, [Array.isArray(urls) ? urls.join('|') : urls ?? '']);
const candidateKey = candidates.join('|');
const [urlIndex, setUrlIndex] = useState(0);
const url = candidates[urlIndex] ?? null;
const [texture, setTexture] = useState<THREE.Texture | null>(null);
const [failed, setFailed] = useState(!url);
const [failed, setFailed] = useState(candidates.length === 0);
const textureLoad = useContext(GalleryTextureLoadContext);
const { gl } = useThree();
useEffect(() => {
setUrlIndex(0);
}, [candidateKey]);
useEffect(() => {
if (!url) {
setTexture(null);
setFailed(true);
setFailed(candidates.length === 0 || urlIndex >= candidates.length);
return;
}
setFailed(false);
setTexture(null);
let disposed = false;
let loaded: THREE.Texture | null = null;
let settled = false;
let releaseSlot: (() => void) | null = null;
const loader = new THREE.TextureLoader();
loader.setCrossOrigin('anonymous');
// Relative /images URLs are same-origin via the Vite proxy — avoid CORS mode.
if (/^https?:\/\//i.test(url)) {
loader.setCrossOrigin('anonymous');
}
const finish = () => {
if (settled) return;
settled = true;
textureLoad?.end();
releaseSlot?.();
releaseSlot = null;
};
textureLoad?.begin();
const loadTimeout = window.setTimeout(() => {
if (settled || disposed) return;
setFailed(true);
finish();
}, TEXTURE_LOAD_TIMEOUT_MS);
loader.load(
url,
(tex) => {
window.clearTimeout(loadTimeout);
if (disposed) {
tex.dispose();
finish();
return;
}
const img = tex.image as HTMLImageElement | undefined;
if (!img || img.width < 4 || img.height < 4) {
tex.dispose();
setFailed(true);
finish();
return;
}
loaded = tex;
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 4;
// Release the hall overlay counter before GPU upload — large halls
// (50+ works) otherwise stay on "Loading paintings…" for a long time.
const slot = acquireTextureLoadSlot();
void slot.promise.then((release) => {
if (disposed) {
release();
finish();
if (!disposed) setTexture(tex);
try {
if (!disposed) gl.initTexture(tex);
} catch {
// Upload can fail after context loss; texture still usable later.
}
},
undefined,
() => {
window.clearTimeout(loadTimeout);
finish();
if (!disposed) setFailed(true);
return;
}
);
releaseSlot = release;
loader.load(
url,
(tex) => {
window.clearTimeout(loadTimeout);
if (disposed) {
tex.dispose();
finish();
return;
}
const img = tex.image as HTMLImageElement | undefined;
if (!img || img.width < 4 || img.height < 4) {
tex.dispose();
finish();
if (!disposed) {
if (urlIndex + 1 < candidates.length) setUrlIndex((i) => i + 1);
else setFailed(true);
}
return;
}
loaded = tex;
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 4;
finish();
if (!disposed) {
setFailed(false);
setTexture(tex);
}
try {
if (!disposed) gl.initTexture(tex);
} catch {
// Upload can fail after context loss; texture still usable later.
}
},
undefined,
() => {
window.clearTimeout(loadTimeout);
finish();
if (!disposed) {
if (urlIndex + 1 < candidates.length) setUrlIndex((i) => i + 1);
else setFailed(true);
}
}
);
});
return () => {
disposed = true;
slot.cancel();
window.clearTimeout(loadTimeout);
finish();
loaded?.dispose();
setTexture(null);
};
}, [url, textureLoad, gl]);
}, [url, urlIndex, candidates.length, textureLoad, gl]);
return { texture, failed };
}
@@ -702,25 +844,11 @@ function InfluencePictureLamp({
<meshStandardMaterial
color="#fff8e0"
emissive="#ffcc55"
emissiveIntensity={1.4 * glow}
emissiveIntensity={1.8 * glow}
toneMapped={false}
/>
</mesh>
<pointLight
position={[0, -0.03, 0.05]}
intensity={1.8 * glow}
distance={4}
color="#ffe4a8"
decay={2}
/>
<spotLight
position={[0, -0.03, 0.05]}
angle={0.75}
penumbra={0.95}
intensity={2.2 * glow}
distance={5}
color="#fff0c8"
/>
{/* No per-painting lights — too many MeshStandard lights break hall shaders. */}
</group>
</group>
);
@@ -808,8 +936,8 @@ function PaintingFrame({
const reviewed = paintingIsReviewed(painting);
const { matBorder, rail, depth: frameDepth } = frameDimsForReviewed(reviewed);
const hasImage = paintingHasGalleryImage(painting);
const url = hasImage ? galleryImageUrlWithRevision(painting, imageRevision) : null;
const { texture, failed } = usePaintingTexture(url);
const urls = hasImage ? galleryImageUrlCandidates(painting, imageRevision) : [];
const { texture, failed } = usePaintingTexture(urls);
const showImage = hasImage && !failed && !!texture;
const showCanvas = !showImage;
const hasInfluenceLinks = paintingHasInfluenceLinks(painting);
@@ -828,17 +956,12 @@ function PaintingFrame({
const captionY =
-height / 2 - matBorder - rail - (hasCuratorNotes ? 0.18 : 0.1);
// Hall lighting is shared (track/ambient). Per-frame spotLights (× dozens of
// paintings) exceed WebGL light limits and make MeshStandard walls vanish.
const frameEmissiveBoost = hovered ? 0.35 : showCanvas ? 0.08 : 0.12;
return (
<group position={position} rotation={[0, rotationY, 0]}>
<spotLight
position={[0, height / 2 + 0.25, 0.3]}
angle={0.5}
penumbra={0.75}
intensity={showCanvas ? (hovered ? 0.9 : 0.55) : hovered ? 2.6 : 1.9}
distance={4.5}
color={showCanvas ? '#e8dcc8' : '#fff8ee'}
/>
<mesh
position={[0, 0, frameDepth / 2]}
renderOrder={1}
@@ -854,8 +977,8 @@ function PaintingFrame({
color={finish.color}
roughness={finish.roughness}
metalness={finish.metalness}
emissive={finish.emissive}
emissiveIntensity={finish.emissiveIntensity}
emissive={reviewed ? finish.emissive : '#d8c090'}
emissiveIntensity={finish.emissiveIntensity + frameEmissiveBoost}
/>
</mesh>
@@ -1299,7 +1422,7 @@ function GalleryFloor({
normalScale={new THREE.Vector2(texture.normalScale, texture.normalScale)}
roughness={texture.roughness}
metalness={texture.metalness}
envMapIntensity={0.65}
envMapIntensity={0.3}
color={interiorStyle.tints.floor}
/>
</mesh>
@@ -1349,6 +1472,7 @@ function ArtistHall({
nearPassage?: boolean;
}) {
const { width, depth, segments } = layout;
const endWallHasDoor = movementMode && 'endWallHasDoor' in layout ? layout.endWallHasDoor : false;
const halfW = width / 2;
const halfD = depth / 2;
const walls = useMemo(
@@ -1360,6 +1484,7 @@ function ArtistHall({
);
const titleColor = interiorStyle?.titleColor ?? '#4a3020';
const warmLight = interiorStyle?.warmLight ?? '#fff5e8';
const hallLightScale = interiorStyle?.lightScale ?? 1;
const wallRoughness = interiorStyle ? 0.75 : 0.92;
const wallMetalness = interiorStyle ? 0.08 : 0.06;
@@ -1431,7 +1556,7 @@ function ArtistHall({
</>
)}
{/* Front wall — passage to next wing, or solid (artist exit / movement entrance) */}
{/* Front wall — next-wing passage, entrance exit (single-wing), or artist exit */}
{movementMode && hasNextHall ? (
<>
<GalleryWall
@@ -1458,10 +1583,8 @@ function ArtistHall({
trimColor={walls.trim}
/>
</>
) : movementMode ? (
<>
<GalleryWall position={[0, WALL_HEIGHT / 2, halfD]} size={[width, WALL_HEIGHT]} color={walls.main} />
</>
) : movementMode && endWallHasDoor ? (
<GalleryWall position={[0, WALL_HEIGHT / 2, halfD]} size={[width, WALL_HEIGHT]} color={walls.main} />
) : (
<>
<GalleryWall position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]} size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]} color={walls.main} />
@@ -1478,8 +1601,8 @@ function ArtistHall({
</>
)}
{/* Back wall — movement exit / navigation, or solid with title */}
{movementMode ? (
{/* Back wall — multi-wing exit / navigator, or solid end wall for paintings */}
{movementMode && endWallHasDoor ? (
<>
<GalleryWall
position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, -halfD]}
@@ -1507,20 +1630,34 @@ function ArtistHall({
/>
</group>
</>
) : movementMode ? (
interiorStyle ? (
<TexturedWall
kind={interiorStyle.surfaces.wall}
tint={interiorStyle.tints.wall}
position={[0, WALL_HEIGHT / 2, -halfD]}
size={[width, WALL_HEIGHT, WALL_THICKNESS]}
/>
) : (
<GalleryWall position={[0, WALL_HEIGHT / 2, -halfD]} size={[width, WALL_HEIGHT]} color={walls.main} />
)
) : null}
{interiorStyle && computedWindows && computedWindows.length > 0 && (
{interiorStyle && (
<>
<GalleryWindows
windows={computedWindows}
halfW={halfW}
halfD={halfD}
trimColor={interiorStyle.tints.trim}
/>
{computedWindows && computedWindows.length > 0 && (
<GalleryWindows
windows={computedWindows}
halfW={halfW}
halfD={halfD}
trimColor={interiorStyle.tints.trim}
/>
)}
{/* Always light the hall — do not gate track lights on window gaps. */}
<GalleryTrackLights
width={width}
depth={depth}
intensity={interiorStyle.trackLights}
intensity={Math.max(1.8, interiorStyle.trackLights * 2.8) * Math.PI * interiorStyle.lightScale}
color={interiorStyle.warmLight}
/>
</>
@@ -1598,15 +1735,24 @@ function ArtistHall({
</group>
))}
<pointLight position={[0, WALL_HEIGHT - 0.4, 0]} intensity={interiorStyle ? interiorStyle.ambient * 1.2 : 0.5} color={warmLight} distance={width + depth} />
<pointLight position={[0, WALL_HEIGHT - 0.4, -halfD / 2]} intensity={interiorStyle ? interiorStyle.ambient * 0.85 : 0.35} color={warmLight} distance={Math.max(12, depth * 0.75)} />
{depth > 14 && (
<pointLight position={[0, WALL_HEIGHT - 0.4, -halfD + 1.2]} intensity={interiorStyle ? interiorStyle.ambient * 0.7 : 0.3} color={warmLight} distance={14} />
)}
<pointLight
position={[0, WALL_HEIGHT - 0.4, 0]}
intensity={(interiorStyle ? Math.max(1.0, interiorStyle.ambient * 1.8) : 0.7) * Math.PI * hallLightScale}
distance={width + depth}
decay={0}
color={warmLight}
/>
<pointLight
position={[0, WALL_HEIGHT - 0.4, -halfD / 2]}
intensity={(interiorStyle ? Math.max(0.7, interiorStyle.ambient * 1.2) : 0.5) * Math.PI * hallLightScale}
distance={Math.max(12, depth * 0.75)}
decay={0}
color={warmLight}
/>
{interiorStyle && (
<directionalLight
position={[0, 6, -halfD - 2]}
intensity={interiorStyle.ambient * 0.9}
intensity={Math.max(0.55, interiorStyle.ambient * 0.85) * Math.PI * hallLightScale}
color={interiorStyle.sunLight}
/>
)}
@@ -1627,20 +1773,6 @@ function FrameloopSync({ active }: { active: boolean }) {
return null;
}
/** Fires onReady once Environment (inside Suspense) has resolved and mounted. */
function EnvironmentGate({
onReady,
children,
}: {
onReady: () => void;
children: ReactNode;
}) {
useEffect(() => {
onReady();
}, [onReady]);
return <>{children}</>;
}
/** Compile all scene materials (incl. culled doors) before dismissing the loading overlay. */
function WarmHallGpu({
enabled,
@@ -1749,7 +1881,7 @@ function MovementHallNavPanel({
))}
</ul>
<button type="button" className="gallery-exit-btn movement-hall-exit-timeline" onClick={onExitTimeline}>
Exit to Timeline
Back to Timeline
</button>
</div>
</div>
@@ -1762,11 +1894,13 @@ function NavigationPanel({
navigation,
loading,
onSelect,
onExitTimeline,
onClose,
}: {
navigation: ArtistNavigation | null;
loading: boolean;
onSelect: (artistId: number) => void;
onExitTimeline: () => void;
onClose: () => void;
}) {
const renderColumn = (title: string, groups: MovementArtistGroup[], emptyHint: string) => (
@@ -1822,6 +1956,11 @@ function NavigationPanel({
'No documented successors via painting influences.'
)}
</div>
<div className="exit-nav-footer">
<button type="button" className="gallery-exit-btn movement-hall-exit-timeline" onClick={onExitTimeline}>
Back to Timeline
</button>
</div>
</div>
</div>
);
@@ -1880,7 +2019,6 @@ export default function VirtualGallery(props: Props) {
const [isLooking, setIsLooking] = useState(false);
const [texturesPending, setTexturesPending] = useState(0);
const [canvasReady, setCanvasReady] = useState(false);
const [envReady, setEnvReady] = useState(false);
const [shadersWarmed, setShadersWarmed] = useState(false);
const [glEpoch, setGlEpoch] = useState(0);
const [glLost, setGlLost] = useState(false);
@@ -1893,16 +2031,16 @@ export default function VirtualGallery(props: Props) {
[]
);
const handleEnvReady = useCallback(() => {
setEnvReady(true);
}, []);
const handleShadersWarmed = useCallback(() => {
setShadersWarmed(true);
}, []);
const handleCanvasCreated = useCallback((state: { gl: THREE.WebGLRenderer }) => {
const canvas = state.gl.domElement;
// Physically correct lights (three r155+) need higher exposure so stone/dark
// period halls stay readable without HDR IBL.
state.gl.toneMapping = THREE.ACESFilmicToneMapping;
state.gl.toneMappingExposure = 1.25;
// A freshly created canvas has a healthy context, so clear any lingering
// "restoring" state from a previous loss/remount.
setGlLost(false);
@@ -1968,7 +2106,6 @@ export default function VirtualGallery(props: Props) {
}, [hallKey]);
useEffect(() => {
setEnvReady(false);
setShadersWarmed(false);
setTexturesPending(0);
}, [hallKey, glEpoch]);
@@ -1977,19 +2114,12 @@ export default function VirtualGallery(props: Props) {
setShadersWarmed(false);
}, [hallIndex]);
// HDR Environment can hang or fail (CDN / Suspense). Never block the hall forever.
useEffect(() => {
if (envReady) return;
const t = window.setTimeout(() => setEnvReady(true), 5000);
return () => window.clearTimeout(t);
}, [envReady, hallKey, glEpoch]);
// If shader warm-up never settles, dismiss the overlay anyway.
useEffect(() => {
if (shadersWarmed || !canvasReady || !envReady) return;
const t = window.setTimeout(() => setShadersWarmed(true), SHADER_WARM_TIMEOUT_MS + 1000);
if (shadersWarmed || !canvasReady) return;
const t = window.setTimeout(() => setShadersWarmed(true), SHADER_WARM_TIMEOUT_MS + 500);
return () => window.clearTimeout(t);
}, [shadersWarmed, canvasReady, envReady, hallKey, glEpoch, hallIndex]);
}, [shadersWarmed, canvasReady, hallKey, glEpoch, hallIndex]);
const layout = useMemo(() => {
if (isWingedHall && movementHalls.length > 0) {
@@ -1998,17 +2128,17 @@ export default function VirtualGallery(props: Props) {
return buildHallLayout(paintings, periods);
}, [isWingedHall, movementHalls, hallIndex, paintings, periods]);
// Do not block the hall on every painting texturelarge artists (e.g. Duccio, 50+)
// otherwise sit on "Loading paintings…" for a long time. Textures keep loading after.
// Do not block hall entry on HDR Environment (CDN)warm shaders as soon as
// the canvas exists; Environment continues loading in the background.
const gallerySceneLoading =
active &&
!glLost &&
(!canvasReady || !envReady || !shadersWarmed);
(!canvasReady || !shadersWarmed);
const galleryLoadingMessage =
canvasReady && texturesPending > 0 ? 'Loading paintings…' : 'Loading gallery…';
const warmGpuEnabled = canvasReady && envReady && !shadersWarmed;
const warmGpuEnabled = canvasReady && !shadersWarmed;
const computedWindows = useMemo(() => {
if (!isMovement || !interiorStyle || !('hallIndex' in layout)) return undefined;
@@ -2017,11 +2147,77 @@ export default function VirtualGallery(props: Props) {
const hasNextHall = isWingedHall && movementHalls.length > 1 && hallIndex < movementHalls.length - 1;
const halfW = layout.width / 2 - 0.55;
const halfD = layout.depth / 2 - 0.35;
const exitZ = layout.depth / 2 - 0.55;
const { halfW: wallInnerHalfW, halfD: wallInnerHalfD } = useMemo(
() => innerWallHalfExtents(layout.width, layout.depth),
[layout.width, layout.depth]
);
/** Playable half-extents: 0.5 m clear of inner wall faces (corners included). */
const playHalfW = wallInnerHalfW - PLAYER_CLEARANCE;
const playHalfD = wallInnerHalfD - PLAYER_CLEARANCE;
const exitZ = playHalfD;
const fogFar = Math.max(55, layout.depth + 42);
const collisionObstacles = useMemo(() => {
const boxes: XZBounds[] = [];
for (const seg of layout.segments) {
for (const slot of seg.slots) boxes.push(paintingKeepOutBounds(slot));
}
const endWallHasDoor =
isWingedHall && 'endWallHasDoor' in layout ? layout.endWallHasDoor : false;
// Front door/passage jambs when the entrance wall has an opening.
if (!isWingedHall || hasNextHall || !endWallHasDoor) {
boxes.push(...doorJambKeepOuts(wallInnerHalfD, true));
}
// Back exit jambs for multi-wing halls.
if (isWingedHall && endWallHasDoor) {
boxes.push(...doorJambKeepOuts(wallInnerHalfD, false));
}
return boxes;
}, [layout, wallInnerHalfD, isWingedHall, hasNextHall]);
const endWallHasDoor =
isWingedHall && 'endWallHasDoor' in layout ? layout.endWallHasDoor : false;
const exitOnFront = !isWingedHall || !endWallHasDoor;
const resolvePlayerPosition = useCallback(
(pos: { x: number; z: number }, allowFrontDoorApproach: boolean) => {
const clampToWalls = () => {
pos.x = Math.max(-playHalfW, Math.min(playHalfW, pos.x));
const inDoorBand = Math.abs(pos.x) < DOOR_WIDTH / 2 - PLAYER_CLEARANCE * 0.35;
if (isWingedHall) {
let minZ = -playHalfD;
let maxZ = playHalfD;
if (inDoorBand) {
if (endWallHasDoor) minZ = -wallInnerHalfD + 0.08;
if (hasNextHall || allowFrontDoorApproach || exitOnFront) {
maxZ = wallInnerHalfD - 0.08;
}
}
pos.z = Math.max(minZ, Math.min(maxZ, pos.z));
} else {
let maxZ = exitZ;
if (inDoorBand && allowFrontDoorApproach) maxZ = wallInnerHalfD - 0.08;
pos.z = Math.max(-playHalfD, Math.min(maxZ, pos.z));
}
};
clampToWalls();
for (const box of collisionObstacles) pushOutOfBounds(pos, box);
clampToWalls();
},
[
playHalfW,
playHalfD,
exitZ,
isWingedHall,
hasNextHall,
endWallHasDoor,
exitOnFront,
wallInnerHalfD,
collisionObstacles,
]
);
const initialPos = useMemo(
() => new THREE.Vector3(0, EYE_HEIGHT, layout.depth / 2 - 2.2),
[layout.depth]
@@ -2091,8 +2287,31 @@ export default function VirtualGallery(props: Props) {
}
}, [isWingedHall, onBack, !isWingedHall ? props.data.artist.id : undefined]);
const backExitZ = isWingedHall ? -layout.depth / 2 + 0.55 : layout.depth / 2 - 0.55;
const frontPassageZ = layout.depth / 2 - 0.55;
const backExitZ = isWingedHall && endWallHasDoor ? -playHalfD : exitZ;
const frontPassageZ = playHalfD;
const updateProximityFlags = useCallback(
(pos: { x: number; z: number }) => {
if (isWingedHall) {
const atBackExit =
endWallHasDoor &&
pos.z < backExitZ + 0.8 &&
Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
const atFrontExit =
!endWallHasDoor &&
pos.z > frontPassageZ - 0.8 &&
Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
const atFrontPassage =
hasNextHall && pos.z > frontPassageZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
setNearExit(!!(atBackExit || atFrontExit));
setNearPassage(!!atFrontPassage);
} else {
const atExit = pos.z > exitZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
setNearExit(atExit);
}
},
[isWingedHall, endWallHasDoor, backExitZ, frontPassageZ, hasNextHall, exitZ]
);
const moveCamera = useCallback(
(forward: number, strafe: number, rotY: number) => {
@@ -2100,43 +2319,49 @@ export default function VirtualGallery(props: Props) {
const target = camTargetRef.current.clone();
const angle = Math.atan2(target.x - pos.x, target.z - pos.z);
// Turn in place: never move; only change look direction.
if (rotY !== 0) {
const newAngle = angle + rotY;
const dist = pos.distanceTo(target);
const dist = Math.max(0.5, pos.distanceTo(target));
target.x = pos.x + Math.sin(newAngle) * dist;
target.z = pos.z + Math.cos(newAngle) * dist;
} else {
const newAngle = angle;
pos.x += Math.sin(newAngle) * forward + Math.sin(newAngle + Math.PI / 2) * strafe;
pos.z += Math.cos(newAngle) * forward + Math.cos(newAngle + Math.PI / 2) * strafe;
target.x += Math.sin(newAngle) * forward + Math.sin(newAngle + Math.PI / 2) * strafe;
target.z += Math.cos(newAngle) * forward + Math.cos(newAngle + Math.PI / 2) * strafe;
levelHorizontalView(pos, target);
updateProximityFlags(pos);
setCamPos(pos);
setCamTarget(target);
return;
}
pos.x = Math.max(-halfW, Math.min(halfW, pos.x));
target.x = Math.max(-halfW, Math.min(halfW, target.x));
if (isWingedHall) {
pos.z = Math.max(-halfD, Math.min(halfD, pos.z));
target.z = Math.max(-halfD, Math.min(halfD, target.z));
const atBackExit = pos.z < backExitZ + 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
const atFrontPassage =
hasNextHall && pos.z > frontPassageZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
setNearExit(atBackExit);
setNearPassage(!!atFrontPassage);
} else {
pos.z = Math.max(-halfD, Math.min(exitZ, pos.z));
target.z = Math.max(-halfD, Math.min(exitZ, target.z));
const atExit = pos.z > exitZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
setNearExit(atExit);
const dx = Math.sin(angle) * forward + Math.sin(angle + Math.PI / 2) * strafe;
const dz = Math.cos(angle) * forward + Math.cos(angle + Math.PI / 2) * strafe;
if (dx === 0 && dz === 0) {
updateProximityFlags(pos);
return;
}
const proposed = { x: pos.x + dx, z: pos.z + dz };
const resolved = { x: proposed.x, z: proposed.z };
resolvePlayerPosition(resolved, true);
// Hit wall/painting/door jamb: cancel the whole step — no slide, no view change.
if (
Math.abs(resolved.x - proposed.x) > 1e-4 ||
Math.abs(resolved.z - proposed.z) > 1e-4
) {
updateProximityFlags(pos);
return;
}
pos.x = proposed.x;
pos.z = proposed.z;
target.x += dx;
target.z += dz;
levelHorizontalView(pos, target);
updateProximityFlags(pos);
setCamPos(pos);
setCamTarget(target);
},
[halfW, halfD, exitZ, isWingedHall, backExitZ, frontPassageZ, hasNextHall]
[resolvePlayerPosition, updateProximityFlags]
);
useEffect(() => {
@@ -2205,7 +2430,10 @@ export default function VirtualGallery(props: Props) {
const sceneBackground = interiorStyle?.background ?? '#0d0906';
const sceneFog = interiorStyle?.fog ?? '#0d0906';
const ambientIntensity = interiorStyle?.ambient ?? 0.42;
// Floor ambient so dark period styles (Gothic, Byzantine) stay readable without HDR IBL.
// three r155+ physical lights need ~π× legacy intensity for similar brightness.
const lightScale = interiorStyle?.lightScale ?? 1;
const ambientIntensity = Math.max(0.85, interiorStyle?.ambient ?? 0.55) * Math.PI * lightScale;
const handleCanvasPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (!active || showExitNav || e.button !== 0) return;
@@ -2312,25 +2540,43 @@ export default function VirtualGallery(props: Props) {
>
<FrameloopSync active={active} />
<color attach="background" args={[sceneBackground]} />
<fog attach="fog" args={[sceneFog, 18, fogFar]} />
<fog
attach="fog"
args={[sceneFog, Math.max(45, layout.depth * 1.15), Math.max(fogFar, layout.depth + 55)]}
/>
<ambientLight intensity={ambientIntensity} />
<hemisphereLight
color={interiorStyle?.warmLight ?? '#fff8f0'}
groundColor={sceneFog}
intensity={0.55 * Math.PI * lightScale}
/>
<directionalLight
position={interiorStyle ? [2, 10, -4] : [3, 9, 4]}
intensity={interiorStyle ? 0.85 : 0.65}
intensity={(interiorStyle ? 1.4 : 0.9) * Math.PI * lightScale}
color={interiorStyle?.sunLight ?? '#fff8f0'}
/>
<directionalLight
position={[-4, 6, 5]}
intensity={0.55 * Math.PI * lightScale}
color={interiorStyle?.warmLight ?? '#fff5e8'}
/>
{/* Guaranteed fill so walls never disappear if HDR Environment fails. */}
<pointLight
position={[0, 3.2, 0]}
intensity={2.2 * Math.PI * lightScale}
distance={40}
decay={0}
color={interiorStyle?.warmLight ?? '#fff5e8'}
/>
<SceneErrorBoundary
key={`env-${hallKey}-${glEpoch}`}
fallback={null}
onError={handleEnvReady}
>
<Suspense fallback={null}>
<EnvironmentGate onReady={handleEnvReady}>
<Environment
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
environmentIntensity={interiorStyle ? 0.35 : 0.15}
/>
</EnvironmentGate>
<Environment
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
environmentIntensity={(interiorStyle ? 0.55 : 0.25) * lightScale}
/>
</Suspense>
</SceneErrorBoundary>
<GalleryTextureLoadContext.Provider value={textureLoad}>
@@ -2362,6 +2608,7 @@ export default function VirtualGallery(props: Props) {
navigation={navigation}
loading={navLoading}
onSelect={handleNavigate}
onExitTimeline={onBack}
onClose={() => setShowExitNav(false)}
/>
)}
@@ -2398,8 +2645,12 @@ export default function VirtualGallery(props: Props) {
{isWingedHall ? (
<>
<li>Date and artist labels appear below each frame</li>
<li>Works hang on left &amp; right walls up to ~55 per wing</li>
<li>Back door: wing navigator &amp; exit to timeline</li>
<li>Works hang on left, end, and right walls up to ~55 per wing</li>
<li>
{movementHalls.length > 1
? 'Back door: wing navigator & exit to timeline · Front: next wing'
: 'Entrance door: exit to timeline'}
</li>
{movementHalls.length > 1 && (
<li>Front archway: walk to the next chronological wing</li>
)}
@@ -2407,7 +2658,7 @@ export default function VirtualGallery(props: Props) {
) : (
<>
<li>Golden lamps mark works linked in the influence graph</li>
<li>Click the exit door or <kbd>E</kbd> to visit related artists</li>
<li>Click the exit door or <kbd>E</kbd> for related artists or back to the timeline</li>
</>
)}
</ul>
+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.",
+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": "Не удалось загрузить экскурсию.",
+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": "Пароль обновлён"
}
+191 -72
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> = {
@@ -607,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 {
@@ -618,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 {
@@ -952,9 +1017,10 @@ export default function HomePage() {
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo: view });
}}
onInfluenceArtistClick={handleArtistClick}
isCurator={isCurator}
isCurator={canNotes}
canCheckup={canCheckup}
debugMode={effectiveDebugMode}
debugShowMore={debugShowMore && isCurator}
debugShowMore={debugShowMore && canImages}
onPaintingImageFixed={handlePaintingImageFixed}
onPaintingCheckupFlagsUpdated={handlePaintingCheckupFlagsUpdated}
onPaintingRemoved={handlePaintingRemoved}
@@ -968,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={() =>
@@ -981,7 +1048,7 @@ export default function HomePage() {
)}
{view.type === 'influences' && (
isCurator ? (
canInfluences ? (
<InfluencesPage onBack={goToTimelineHome} />
) : (
<div className="curator-login-gate">
@@ -1000,7 +1067,7 @@ export default function HomePage() {
)}
{view.type === 'tours' && (
isCurator ? (
canTours ? (
<ToursPage onBack={goToTimelineHome} />
) : (
<div className="curator-login-gate">
@@ -1018,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">
@@ -1038,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>
@@ -1074,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">
@@ -1083,57 +1180,79 @@ export default function HomePage() {
<span className="curator-session-label" title={`Signed in as ${username}`}>
{username}
</span>
<button
type="button"
className={`debug-mode-toggle${debugMode ? ' debug-mode-toggle-active' : ''}`}
onClick={toggleDebugMode}
title="Toggle developer image audit mode on painting details and artist bios"
>
Debug mode{debugMode ? ': ON' : ''}
</button>
<label
className={`debug-show-more-toggle${debugShowMore ? ' debug-show-more-toggle-active' : ''}${!debugMode ? ' debug-show-more-toggle-muted' : ''}`}
title="When debug mode is on, open the More search results popup automatically on each painting or artist page"
>
<input
type="checkbox"
checked={debugShowMore}
onChange={(e) => setDebugShowMoreEnabled(e.target.checked)}
/>
Show more
</label>
<button
type="button"
className="checkup-link-btn"
onClick={openInfluences}
title="Manage influence links"
>
{t('influences')}
</button>
<button
type="button"
className="checkup-link-btn"
onClick={openToursEditor}
title="Create and edit guided tours"
>
{t('toursEditor')}
</button>
<button
type="button"
className="checkup-link-btn"
onClick={openTranslations}
title="Review and publish Russian translations"
>
{t('translations')}
</button>
<button
type="button"
className="checkup-link-btn"
onClick={openCheckup}
title="Open painting image checkup table"
>
{t('checkup')}
</button>
{canImages && (
<>
<button
type="button"
className={`debug-mode-toggle${debugMode ? ' debug-mode-toggle-active' : ''}`}
onClick={toggleDebugMode}
title="Toggle developer image audit mode on painting details and artist bios"
>
Debug mode{debugMode ? ': ON' : ''}
</button>
<label
className={`debug-show-more-toggle${debugShowMore ? ' debug-show-more-toggle-active' : ''}${!debugMode ? ' debug-show-more-toggle-muted' : ''}`}
title="When debug mode is on, open the More search results popup automatically on each painting or artist page"
>
<input
type="checkbox"
checked={debugShowMore}
onChange={(e) => setDebugShowMoreEnabled(e.target.checked)}
/>
Show more
</label>
</>
)}
{canInfluences && (
<button
type="button"
className="checkup-link-btn"
onClick={openInfluences}
title="Manage influence links"
>
{t('influences')}
</button>
)}
{canTours && (
<button
type="button"
className="checkup-link-btn"
onClick={openToursEditor}
title="Create and edit guided tours"
>
{t('toursEditor')}
</button>
)}
{canTranslations && (
<button
type="button"
className="checkup-link-btn"
onClick={openTranslations}
title="Review and publish Russian translations"
>
{t('translations')}
</button>
)}
{canCheckup && (
<button
type="button"
className="checkup-link-btn"
onClick={openCheckup}
title="Open painting image checkup table"
>
{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>
);
}
+15
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 {
@@ -134,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,
+75 -16
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;
@@ -165,36 +167,59 @@ function layoutSideSlots(
});
}
/** Far wall ahead of the entrance — split across door flanks (exit sits in the center). */
/** Far wall ahead of the entrance — full span, or split across door flanks. */
function layoutBackSlots(
paintings: Painting[],
width: number,
halfD: number,
inset: number
inset: number,
hasDoor: boolean
): FrameSlot[] {
if (paintings.length === 0) return [];
const flankSpan = Math.max(MIN_FRAME_W + WALL_PADDING, (width - DOOR_CLEARANCE) / 2);
const mid = Math.ceil(paintings.length / 2);
const leftFlank = paintings.slice(0, mid);
const rightFlank = paintings.slice(mid);
const y = EYE_HEIGHT;
const z = -halfD + inset + WALL_STANDOFF;
const leftCenterX = -DOOR_CLEARANCE / 2 - flankSpan / 2;
const rightCenterX = DOOR_CLEARANCE / 2 + flankSpan / 2;
const mapFlank = (group: Painting[], centerX: number): FrameSlot[] => {
if (group.length === 0) return [];
const { slots: rowSlots } = layoutRow(group, flankSpan);
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: [centerX + s.offset, y, z] as [number, number, number],
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), ...mapFlank(rightFlank, rightCenterX)];
return [...mapFlank(leftFlank, leftCenterX, 'left'), ...mapFlank(rightFlank, rightCenterX, 'right')];
}
export function buildMovementHallLayout(
@@ -206,7 +231,21 @@ export function buildMovementHallLayout(
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;
@@ -216,7 +255,7 @@ export function buildMovementHallLayout(
side: 'back',
label: back.length > 0 ? `Wing ${hallIndex + 1} · End wall` : '',
paintings: back,
slots: layoutBackSlots(back, width, halfD, inset),
slots: layoutBackSlots(back, width, halfD, inset, endWallHasDoor),
},
{
side: 'left',
@@ -240,6 +279,7 @@ export function buildMovementHallLayout(
segments,
paintingCount: paintings.length,
yearLabel: formatYearLabel(paintings),
endWallHasDoor,
};
}
@@ -286,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
@@ -324,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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 641 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 28 KiB

+35
View File
@@ -0,0 +1,35 @@
-- Staff roles and fine-grained permissions on users
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'role'
) THEN
ALTER TABLE users ADD COLUMN role VARCHAR(32) NOT NULL DEFAULT 'curator';
ALTER TABLE users ADD COLUMN permissions TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE users ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT true;
-- Existing accounts were full curators; promote to admin so nothing breaks
UPDATE users
SET role = 'admin',
permissions = ARRAY[
'images',
'checkup',
'curator_notes',
'translations',
'influences',
'tours',
'users'
]::text[];
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'users_role_check'
) THEN
ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('admin', 'curator'));
END IF;
END $$;
+5
View File
@@ -32,6 +32,11 @@ function sqlLiteral(value) {
if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE';
if (value instanceof Date) return `'${value.toISOString()}'`;
if (typeof value === 'number' || typeof value === 'bigint') return String(value);
// node-pg returns JS arrays for Postgres array columns (e.g. users.permissions TEXT[])
if (Array.isArray(value)) {
if (value.length === 0) return `ARRAY[]::text[]`;
return `ARRAY[${value.map((item) => sqlLiteral(item)).join(', ')}]`;
}
if (typeof value === 'object') {
return `'${JSON.stringify(value).replace(/'/g, "''")}'`;
}
+130 -4
View File
@@ -1,24 +1,41 @@
/**
* Bidirectional image harmonize (dev <-> prod) by file mtime.
* Bidirectional image harmonize (dev <-> prod) by file mtime,
* then merge artists/paintings catalog rows (checkup flags + image paths),
* then regenerate painting + portrait thumbnails on both image roots.
*
* Usage:
* npm run harmonize:images
* npm run harmonize:images -- --dry-run
* npm run harmonize:images -- --skip-thumbnails
* npm run harmonize:images -- --skip-db
*/
const fs = require('fs');
const path = require('path');
const { rootDir, confirmProdAction } = require('./db-env');
const { spawnSync } = require('child_process');
const { rootDir, confirmProdAction, PROD_DB_NAME } = require('./db-env');
const { loadHarmonizeConfig, prodImagesPath } = require('./lib/harmonize-config');
const { printCliResult } = require('./lib/cli-result');
const IMAGE_SUBDIRS = ['portraits', 'paintings'];
const MTIME_TOLERANCE_MS = 1000;
/** Tables that carry checkup flags and image/portrait path links. */
const IMAGE_CATALOG_TABLES = ['artists', 'paintings'];
const THUMB_SCRIPTS = [
'regenerate-thumbnails.js',
'regenerate-portrait-thumbs.js',
];
function parseArgs(argv) {
const configArg = argv.find((a) => a.startsWith('--config='));
const preferArg = argv.find((a) => a.startsWith('--prefer='));
return {
dryRun: argv.includes('--dry-run'),
verbose: argv.includes('--verbose'),
skipThumbnails: argv.includes('--skip-thumbnails'),
skipDb: argv.includes('--skip-db'),
prefer: preferArg ? preferArg.slice('--prefer='.length).trim().toLowerCase() : null,
configPath: configArg ? configArg.slice('--config='.length) : null,
};
}
@@ -78,6 +95,65 @@ function copyFile(src, dest, dryRun) {
fs.copyFileSync(src, dest);
}
/**
* Rebuild thumbs from full images for one environment.
* Child scripts honour existing env (dotenv will not override DB_NAME / IMAGE_DIR).
*/
function regenerateThumbnails({ label, imageDir, database }) {
console.log(`\n--- Thumbnails (${label}) ---`);
console.log(` IMAGE_DIR=${imageDir}`);
console.log(` DB_NAME=${database}`);
const env = {
...process.env,
IMAGE_DIR: imageDir,
DB_NAME: database,
};
for (const file of THUMB_SCRIPTS) {
const scriptPath = path.join(__dirname, file);
console.log(` Running ${file}`);
const result = spawnSync(process.execPath, [scriptPath], {
cwd: rootDir,
stdio: 'inherit',
env,
});
const code = result.status ?? 1;
if (code !== 0) {
throw new Error(`Thumbnail regeneration failed (${label}): ${file} exited ${code}`);
}
}
}
/**
* Merge artists + paintings so checkup flags and image/portrait path links
* travel with the file sync (harmonize:images alone used to leave DB stale).
*/
function syncImageCatalogRows({ dryRun, prefer, verbose }) {
console.log('\n--- Catalog (artists + paintings checkup / image paths) ---');
const args = [
path.join(__dirname, 'harmonize-db.js'),
`--tables=${IMAGE_CATALOG_TABLES.join(',')}`,
];
if (dryRun) args.push('--dry-run');
if (verbose) args.push('--verbose');
if (prefer === 'dev' || prefer === 'prod') args.push(`--prefer=${prefer}`);
const result = spawnSync(process.execPath, args, {
cwd: rootDir,
stdio: 'inherit',
env: {
...process.env,
// Parent already confirmed prod writes for this harmonize:images run.
CONFIRM_PROD: '1',
},
});
const code = result.status ?? 1;
if (code !== 0) {
throw new Error(`Image catalog sync failed: harmonize-db.js exited ${code}`);
}
}
function timestampSlug(date = new Date()) {
const pad = (n) => String(n).padStart(2, '0');
return [
@@ -107,7 +183,7 @@ async function main() {
if (!dryRun && process.env.CONFIRM_PROD !== '1') {
await confirmProdAction(
'Harmonize will copy image files between dev and prod (prod files may be overwritten).',
'Harmonize will copy image files between dev and prod, merge artists/paintings checkup flags and image paths, then regenerate thumbnails on both sides (prod files and gallery_prod may be updated).',
);
}
@@ -165,9 +241,53 @@ async function main() {
const reportDir = path.join(rootDir, 'db', 'SyncReports');
fs.mkdirSync(reportDir, { recursive: true });
const reportPath = path.join(reportDir, `harmonize_images_${timestampSlug()}.json`);
const extraDetails = [];
if (!options.skipDb) {
syncImageCatalogRows({
dryRun,
prefer: options.prefer,
verbose: options.verbose,
});
extraDetails.push(
dryRun
? 'Catalog checkup/path sync previewed (artists, paintings; dry-run)'
: 'Catalog checkup flags + image paths merged (artists, paintings)',
);
} else {
extraDetails.push('Catalog checkup/path sync skipped (--skip-db)');
}
if (!dryRun && !options.skipThumbnails) {
require('dotenv').config({ path: path.join(rootDir, '.env') });
const devDb = process.env.DB_NAME || 'gallery_dev';
regenerateThumbnails({
label: 'dev',
imageDir: devDir,
database: devDb,
});
extraDetails.push(`Dev thumbs regenerated (${devDb} @ ${devDir})`);
regenerateThumbnails({
label: 'prod',
imageDir: prodDir,
database: PROD_DB_NAME,
});
extraDetails.push(`Prod thumbs regenerated (${PROD_DB_NAME} @ ${prodDir})`);
} else if (options.skipThumbnails) {
extraDetails.push('Thumbnails skipped (--skip-thumbnails)');
} else {
extraDetails.push('Thumbnails skipped (dry-run)');
}
fs.writeFileSync(reportPath, JSON.stringify({
generatedAt: new Date().toISOString(),
dryRun,
skipThumbnails: options.skipThumbnails,
skipDb: options.skipDb,
prefer: options.prefer,
devDir,
prodDir,
stats: {
@@ -175,6 +295,7 @@ async function main() {
prodToDev: stats.prodToDev,
skipped: stats.skipped,
},
steps: extraDetails,
actions: options.verbose ? stats.actions : stats.actions.slice(0, 500),
}, null, 2));
@@ -183,7 +304,12 @@ async function main() {
script: 'harmonize-images',
ok: true,
summary: `Image harmonize complete${mode}: dev→prod ${stats.devToProd}, prod→dev ${stats.prodToDev}, skipped ${stats.skipped}.`,
details: [`Dev: ${devDir}`, `Prod: ${prodDir}`, `Report: ${reportPath}`],
details: [
`Dev: ${devDir}`,
`Prod: ${prodDir}`,
`Report: ${reportPath}`,
...extraDetails,
],
});
}
+7 -1
View File
@@ -1691,7 +1691,13 @@ async function generateThumbnailFromFull(fullDest, thumbDest, width = THUMB_WIDT
if (!fs.existsSync(fullDest)) return false;
const ext = path.extname(thumbDest).toLowerCase();
let pipeline = sharp(fullDest).rotate().resize({ width, withoutEnlargement: true });
// Museum scans can exceed Sharp's default ~268MP input cap; we only resize down.
let pipeline = sharp(fullDest, {
limitInputPixels: false,
sequentialRead: true,
})
.rotate()
.resize({ width, withoutEnlargement: true });
if (ext === '.png') {
pipeline = pipeline.png({ quality: 85 });
+1 -1
View File
@@ -1,6 +1,6 @@
const fs = require('fs');
const path = require('path');
const { rootDir } = require('./db-env');
const { rootDir } = require('../db-env');
const DEFAULT_CONFIG_PATH = path.join(rootDir, 'infra', 'deploy', 'harmonize.config.json');
const FALLBACK_CONFIG_PATH = path.join(rootDir, 'infra', 'deploy', 'devtoprod.config.json');
+1 -1
View File
@@ -51,7 +51,7 @@ async function unlinkOldThumbRel(thumbRel, imageRel) {
}
async function aspectRatio(filePath) {
const meta = await sharp(filePath).metadata();
const meta = await sharp(filePath, { limitInputPixels: false }).metadata();
if (!meta.width || !meta.height) return null;
return meta.width / meta.height;
}
+27 -11
View File
@@ -1,5 +1,5 @@
/**
* Upsert the curator account password from .env CURATOR_USERNAME / CURATOR_PASSWORD.
* Upsert the bootstrap admin account password from .env CURATOR_USERNAME / CURATOR_PASSWORD.
* Use when login fails after changing .env, or after a DB restore with a different hash.
*
* npm run dev:reset-curator
@@ -8,6 +8,16 @@ require('dotenv').config();
const bcrypt = require('bcryptjs');
const pool = require('../server/db');
const ADMIN_PERMISSIONS = [
'images',
'checkup',
'curator_notes',
'translations',
'influences',
'tours',
'users',
];
async function main() {
const username = (process.env.CURATOR_USERNAME || 'curator').trim();
const password = process.env.CURATOR_PASSWORD;
@@ -21,17 +31,23 @@ async function main() {
]);
if (rows.length === 0) {
await pool.query(`INSERT INTO users (username, password_hash) VALUES ($1, $2)`, [
username,
passwordHash,
]);
console.log(`Created curator account: ${username}`);
await pool.query(
`INSERT INTO users (username, password_hash, role, permissions, is_active)
VALUES ($1, $2, 'admin', $3::text[], true)`,
[username, passwordHash, ADMIN_PERMISSIONS]
);
console.log(`Created admin account: ${username}`);
} else {
await pool.query(`UPDATE users SET password_hash = $2 WHERE id = $1`, [
rows[0].id,
passwordHash,
]);
console.log(`Updated password for curator account: ${username}`);
await pool.query(
`UPDATE users
SET password_hash = $2,
role = 'admin',
permissions = $3::text[],
is_active = true
WHERE id = $1`,
[rows[0].id, passwordHash, ADMIN_PERMISSIONS]
);
console.log(`Updated password and admin role for account: ${username}`);
}
// Drop stale sessions so a fresh login is required.
+25 -1
View File
@@ -27,6 +27,28 @@ function getInsertTableName(statement) {
return match ? match[1] : null;
}
/**
* Older backups serialized TEXT[] (e.g. users.permissions) via JSON.stringify,
* producing '["a","b"]' which Postgres rejects as an array literal. Rewrite
* JSON string-array literals to ARRAY['a','b']::text[].
*/
function coerceJsonStringArrayLiterals(statement) {
return statement.replace(/'(\[(?:"(?:\\.|[^"\\])*"(?:\s*,\s*"(?:\\.|[^"\\])*")*)?\])'/g, (full, jsonBody) => {
let arr;
try {
arr = JSON.parse(jsonBody);
} catch {
return full;
}
if (!Array.isArray(arr) || !arr.every((item) => typeof item === 'string')) {
return full;
}
if (arr.length === 0) return `ARRAY[]::text[]`;
const items = arr.map((s) => `'${String(s).replace(/'/g, "''")}'`).join(', ');
return `ARRAY[${items}]::text[]`;
});
}
function filterInsertsForRestore(inserts, prod) {
if (!prod) return inserts;
return inserts.filter((statement) => {
@@ -113,7 +135,9 @@ async function main() {
const config = prod ? loadProdPgConfig() : loadDevPgConfig();
const dbName = config.database;
const allInserts = extractInsertStatements(fs.readFileSync(filePath, 'utf8'));
const allInserts = extractInsertStatements(fs.readFileSync(filePath, 'utf8')).map(
coerceJsonStringArrayLiterals
);
const inserts = filterInsertsForRestore(allInserts, prod);
const skippedInserts = prod ? allInserts.length - inserts.length : 0;
+111 -25
View File
@@ -96,10 +96,15 @@ function localFileExists(relPath) {
return fs.existsSync(path.join(IMAGE_DIR, relPath));
}
function syncPaintingFromDisk(row) {
const safeBase = `${row.artist_name}_${row.title}`.replace(/[^a-zA-Z0-9_-]/g, '_');
let imagePath = row.image_path;
let thumbPath = row.thumbnail_path;
function isPaintingThumbRel(rel) {
if (!rel) return false;
return rel.replace(/\\/g, '/').startsWith('paintings/thumbs/');
}
/** Discover full/thumb files on disk for a painting basename. */
function discoverPaintingFilesOnDisk(safeBase) {
let imagePath = null;
let thumbPath = null;
for (const ext of ['.jpg', '.jpeg', '.png', '.webp', '.JPG']) {
const full = path.join(IMAGE_DIR, 'paintings', safeBase + ext);
@@ -111,30 +116,61 @@ function syncPaintingFromDisk(row) {
thumbPath = `paintings/thumbs/${safeBase}_thumb${ext}`;
}
}
if (!thumbPath && imagePath) thumbPath = imagePath;
const jpgThumb = path.join(IMAGE_DIR, 'paintings', 'thumbs', `${safeBase}_thumb.jpg`);
if (!thumbPath && fs.existsSync(jpgThumb)) {
thumbPath = `paintings/thumbs/${safeBase}_thumb.jpg`;
}
return { imagePath, thumbPath };
}
/** Fast preload: link local files only, no external API calls */
async function preloadArtistImagesLocal(artistId) {
const rows = await pool.query(
`SELECT p.id, p.title, p.image_path, p.thumbnail_path, a.name AS artist_name
FROM paintings p
JOIN artists a ON a.id = p.artist_id
WHERE p.artist_id = $1`,
[artistId]
);
/**
* Ensure a dedicated thumbs/ file exists for a full painting image.
* Regenerates from the full file when missing.
*/
async function ensurePaintingThumbFromFull(fullRel, safeBase) {
if (!fullRel || !localFileExists(fullRel)) return null;
const expectedThumb = `paintings/thumbs/${safeBase}_thumb.jpg`;
if (localFileExists(expectedThumb)) return expectedThumb;
try {
return await writePaintingThumb(path.join(IMAGE_DIR, fullRel), safeBase);
} catch (err) {
console.warn(`Painting thumb generation failed for ${safeBase}:`, err.message);
return null;
}
}
/** Sync/link paths from disk; generate thumbnail when full exists without a thumbs/ file. */
async function syncPaintingFromDisk(row) {
const safeBase = safePaintingBase(row.artist_name, row.title);
let imagePath = localFileExists(row.image_path) ? row.image_path : null;
let thumbPath =
localFileExists(row.thumbnail_path) && isPaintingThumbRel(row.thumbnail_path)
? row.thumbnail_path
: null;
const discovered = discoverPaintingFilesOnDisk(safeBase);
if (!imagePath && discovered.imagePath) imagePath = discovered.imagePath;
if (!thumbPath && discovered.thumbPath) thumbPath = discovered.thumbPath;
if (!thumbPath && imagePath) {
thumbPath = (await ensurePaintingThumbFromFull(imagePath, safeBase)) || null;
}
return { imagePath, thumbPath };
}
/** Fast preload: link local files only, no external API calls; regenerate missing thumbs from full files. */
async function preloadPaintingImageRows(rows) {
let linked = 0;
for (const row of rows.rows) {
const hasLocal =
localFileExists(row.thumbnail_path) || localFileExists(row.image_path);
if (hasLocal) {
for (const row of rows) {
const hasFull = localFileExists(row.image_path);
const hasThumb = localFileExists(row.thumbnail_path) && isPaintingThumbRel(row.thumbnail_path);
if (hasFull && hasThumb) {
linked++;
continue;
}
const synced = syncPaintingFromDisk(row);
const synced = await syncPaintingFromDisk(row);
if (synced.imagePath || synced.thumbPath) {
await pool.query(
`UPDATE paintings SET image_path = COALESCE($1, image_path), thumbnail_path = COALESCE($2, thumbnail_path) WHERE id = $3`,
@@ -144,7 +180,29 @@ async function preloadArtistImagesLocal(artistId) {
}
}
return { fetched: linked, total: rows.rows.length };
return { fetched: linked, total: rows.length };
}
async function preloadArtistImagesLocal(artistId) {
const rows = await pool.query(
`SELECT p.id, p.title, p.image_path, p.thumbnail_path, a.name AS artist_name
FROM paintings p
JOIN artists a ON a.id = p.artist_id
WHERE p.artist_id = $1`,
[artistId]
);
return preloadPaintingImageRows(rows.rows);
}
async function preloadMovementImagesLocal(movementId) {
const rows = await pool.query(
`SELECT p.id, p.title, p.image_path, p.thumbnail_path, a.name AS artist_name
FROM paintings p
JOIN artists a ON a.id = p.artist_id
WHERE a.movement_id = $1`,
[movementId]
);
return preloadPaintingImageRows(rows.rows);
}
async function ensurePaintingImages(paintingId, size = 'thumb') {
@@ -163,12 +221,27 @@ async function ensurePaintingImages(paintingId, size = 'thumb') {
const row = result.rows[0];
const wantThumb = size !== 'full';
const safeBase = safePaintingBase(row.artist_name, row.title);
if (wantThumb && localFileExists(row.thumbnail_path)) return row.thumbnail_path;
if (wantThumb && localFileExists(row.thumbnail_path) && isPaintingThumbRel(row.thumbnail_path)) {
return row.thumbnail_path;
}
if (!wantThumb && localFileExists(row.image_path)) return row.image_path;
if (wantThumb && localFileExists(row.image_path)) return row.image_path;
const synced = syncPaintingFromDisk(row);
// Full on disk but no dedicated thumb — regenerate before falling back to full file.
if (wantThumb && localFileExists(row.image_path)) {
const thumbRel = await ensurePaintingThumbFromFull(row.image_path, safeBase);
if (thumbRel) {
await pool.query(`UPDATE paintings SET thumbnail_path = $1 WHERE id = $2`, [
thumbRel,
paintingId,
]);
return thumbRel;
}
return row.image_path;
}
const synced = await syncPaintingFromDisk(row);
if (synced.imagePath || synced.thumbPath) {
await pool.query(
`UPDATE paintings
@@ -177,8 +250,20 @@ async function ensurePaintingImages(paintingId, size = 'thumb') {
WHERE id = $3`,
[synced.imagePath, synced.thumbPath, paintingId]
);
if (wantThumb && localFileExists(synced.thumbPath)) return synced.thumbPath;
if (wantThumb && localFileExists(synced.imagePath)) return synced.imagePath;
if (wantThumb && localFileExists(synced.thumbPath) && isPaintingThumbRel(synced.thumbPath)) {
return synced.thumbPath;
}
if (wantThumb && localFileExists(synced.imagePath)) {
const thumbRel = await ensurePaintingThumbFromFull(synced.imagePath, safeBase);
if (thumbRel) {
await pool.query(`UPDATE paintings SET thumbnail_path = $1 WHERE id = $2`, [
thumbRel,
paintingId,
]);
return thumbRel;
}
return synced.imagePath;
}
if (!wantThumb && localFileExists(synced.imagePath)) return synced.imagePath;
}
@@ -542,6 +627,7 @@ async function replaceArtistPortraitFromUrl(artistId, imageUrl, context = {}) {
module.exports = {
ensurePaintingImages,
preloadArtistImagesLocal,
preloadMovementImagesLocal,
replacePaintingImageFromUrl,
replaceArtistPortraitFromUrl,
clearPaintingImage,
+65 -20
View File
@@ -8,10 +8,11 @@ require('dotenv').config();
const pool = require('./db');
const { createSessionMiddleware } = require('./middleware/session');
const { requireCurator } = require('./middleware/auth');
const { requirePermission } = require('./middleware/auth');
const { logCuratorAction } = require('./audit-log');
const authRoutes = require('./routes/auth');
const { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, deletePainting, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, enrichPaintingRow, enrichArtistRow, IMAGE_DIR } = require('./image-service');
const usersRoutes = require('./routes/users');
const { ensurePaintingImages, preloadArtistImagesLocal, preloadMovementImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, deletePainting, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, enrichPaintingRow, enrichArtistRow, IMAGE_DIR } = require('./image-service');
const { getVersionInfo } = require('./version-info');
const { searchCatalog } = require('./search-service');
const {
@@ -44,6 +45,7 @@ app.use(compression());
app.use(express.json({ limit: '20mb' }));
app.use(createSessionMiddleware());
app.use('/api/auth', authRoutes);
app.use('/api/users', usersRoutes);
app.use('/api/translations', translationRoutes);
app.use('/api/influences', influenceRoutes);
app.use('/api/tours', tourRoutes);
@@ -88,10 +90,18 @@ async function fetchTimelineErasAndMovements(startYear, endYear) {
[startYear, endYear]
),
pool.query(
`SELECT DISTINCT m.*, e.name as era_name
`SELECT DISTINCT m.*, e.name as era_name,
COALESCE(ic.link_count, 0)::int AS influence_link_count
FROM art_movements m
LEFT JOIN historical_eras e ON m.era_id = e.id
INNER JOIN artists a ON a.movement_id = m.id
LEFT JOIN (
SELECT a2.movement_id, COUNT(pis.id)::int AS link_count
FROM painting_influence_sources pis
JOIN paintings p ON p.id = pis.painting_id
JOIN artists a2 ON a2.id = p.artist_id
GROUP BY a2.movement_id
) ic ON ic.movement_id = m.id
WHERE m.end_year >= $1 AND m.start_year <= $2
AND (a.death_year IS NULL OR a.death_year >= $1)
AND (a.birth_year IS NULL OR a.birth_year <= $2)
@@ -124,7 +134,8 @@ async function catalogBootstrapEtag() {
(SELECT COUNT(*)::int FROM artists) AS artist_count,
(SELECT COUNT(*)::int FROM art_movements) AS movement_count,
(SELECT COUNT(*)::int FROM historical_eras) AS era_count,
(SELECT COUNT(*)::int FROM paintings) AS painting_count
(SELECT COUNT(*)::int FROM paintings) AS painting_count,
(SELECT COUNT(*)::int FROM painting_influence_sources) AS influence_count
`);
return result.rows[0];
}
@@ -324,6 +335,29 @@ app.get('/api/movements/:id/gallery', async (req, res) => {
}
});
// Artists for a movement with painting counts (for gallery entry filter modal)
app.get('/api/movements/:id/artists-summary', async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query(
`SELECT a.id, a.name, a.birth_year, a.death_year, a.portrait_path, a.portrait_thumb_path,
COUNT(p.id)::int AS painting_count
FROM artists a
LEFT JOIN paintings p ON p.artist_id = a.id
WHERE a.movement_id = $1
GROUP BY a.id
ORDER BY a.birth_year NULLS LAST, a.name`,
[id]
);
const { locale, statuses } = localeContext(req);
const localized = await localizeArtists(result.rows, locale, statuses);
res.json(localized.map(enrichArtistRow));
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to fetch movement artists summary' });
}
});
// Artists for a movement in a time range
app.get('/api/movements/:id/artists', async (req, res) => {
try {
@@ -469,7 +503,7 @@ app.get('/api/artists/:id/navigation', async (req, res) => {
});
// Update artist portrait checkup flags (checked / fixed)
app.patch('/api/artists/:id/checkup-flags', requireCurator, async (req, res) => {
app.patch('/api/artists/:id/checkup-flags', requirePermission('checkup'), async (req, res) => {
try {
const artistId = parseInt(req.params.id, 10);
const { checked, fixed } = req.body ?? {};
@@ -537,7 +571,7 @@ app.patch('/api/artists/:id/checkup-flags', requireCurator, async (req, res) =>
});
// Developer debug: portrait image search for artist bio
app.get('/api/artists/:id/debug-portrait-search/more', requireCurator, async (req, res) => {
app.get('/api/artists/:id/debug-portrait-search/more', requirePermission('images'), async (req, res) => {
try {
const artistId = parseInt(req.params.id, 10);
const limit = Math.min(20, Math.max(1, parseInt(req.query.limit, 10) || 20));
@@ -555,7 +589,7 @@ app.get('/api/artists/:id/debug-portrait-search/more', requireCurator, async (re
}
});
app.get('/api/artists/:id/debug-portrait-search', requireCurator, async (req, res) => {
app.get('/api/artists/:id/debug-portrait-search', requirePermission('images'), async (req, res) => {
try {
const artistId = parseInt(req.params.id, 10);
const result = await pool.query(`SELECT name FROM artists WHERE id = $1`, [artistId]);
@@ -573,7 +607,7 @@ app.get('/api/artists/:id/debug-portrait-search', requireCurator, async (req, re
});
// Developer debug: replace artist portrait with a search result URL
app.post('/api/artists/:id/fix-portrait', requireCurator, async (req, res) => {
app.post('/api/artists/:id/fix-portrait', requirePermission('images'), async (req, res) => {
try {
const artistId = parseInt(req.params.id, 10);
const { imageUrl, searchUrl, source, pageUrl, thumbUrl } = req.body ?? {};
@@ -607,7 +641,7 @@ app.post('/api/artists/:id/fix-portrait', requireCurator, async (req, res) => {
}
});
app.post('/api/artists/:id/clear-portrait', requireCurator, async (req, res) => {
app.post('/api/artists/:id/clear-portrait', requirePermission('images'), async (req, res) => {
try {
const artistId = parseInt(req.params.id, 10);
const updated = await clearArtistPortrait(artistId);
@@ -630,7 +664,7 @@ app.post('/api/artists/:id/clear-portrait', requireCurator, async (req, res) =>
}
});
app.post('/api/artists/:id/upload-portrait', requireCurator, async (req, res) => {
app.post('/api/artists/:id/upload-portrait', requirePermission('images'), async (req, res) => {
try {
const artistId = parseInt(req.params.id, 10);
const { imageData, mimeType } = req.body ?? {};
@@ -720,7 +754,7 @@ app.get('/api/artists/:id', async (req, res) => {
});
// Painting image checkup (developer audit table) — must be before /api/paintings/:id
app.get('/api/paintings/checkup', requireCurator, async (_req, res) => {
app.get('/api/paintings/checkup', requirePermission('checkup'), async (_req, res) => {
try {
const { rows } = await pool.query(
`SELECT p.id, p.title, p.year, p.image_path, p.thumbnail_path,
@@ -768,7 +802,7 @@ app.get('/api/paintings/checkup', requireCurator, async (_req, res) => {
});
// Update checkup workflow flags (checked / fixed)
app.patch('/api/paintings/:id/checkup-flags', requireCurator, async (req, res) => {
app.patch('/api/paintings/:id/checkup-flags', requirePermission('checkup'), async (req, res) => {
try {
const paintingId = parseInt(req.params.id, 10);
const { checked, fixed } = req.body ?? {};
@@ -837,7 +871,7 @@ app.patch('/api/paintings/:id/checkup-flags', requireCurator, async (req, res) =
});
// Update public curator notes on a painting
app.patch('/api/paintings/:id/curator-notes', requireCurator, async (req, res) => {
app.patch('/api/paintings/:id/curator-notes', requirePermission('curator_notes'), async (req, res) => {
try {
const paintingId = parseInt(req.params.id, 10);
if (!Number.isFinite(paintingId)) {
@@ -940,8 +974,19 @@ app.post('/api/artists/:id/preload-images', async (req, res) => {
}
});
app.post('/api/movements/:id/preload-images', async (req, res) => {
try {
const movementId = parseInt(req.params.id, 10);
const result = await preloadMovementImagesLocal(movementId);
res.json(result);
} catch (err) {
console.error('Movement preload error:', err.message);
res.status(500).json({ error: 'Preload failed' });
}
});
// Developer debug: Google Images first result for image audit
app.get('/api/paintings/:id/debug-image-search/more', requireCurator, async (req, res) => {
app.get('/api/paintings/:id/debug-image-search/more', requirePermission('images'), async (req, res) => {
try {
const paintingId = parseInt(req.params.id, 10);
const limit = Math.min(20, Math.max(1, parseInt(req.query.limit, 10) || 20));
@@ -965,7 +1010,7 @@ app.get('/api/paintings/:id/debug-image-search/more', requireCurator, async (req
}
});
app.get('/api/paintings/:id/debug-image-search', requireCurator, async (req, res) => {
app.get('/api/paintings/:id/debug-image-search', requirePermission('images'), async (req, res) => {
try {
const paintingId = parseInt(req.params.id, 10);
const result = await pool.query(
@@ -989,7 +1034,7 @@ app.get('/api/paintings/:id/debug-image-search', requireCurator, async (req, res
});
// Developer debug: replace painting image with a search result URL
app.post('/api/paintings/:id/fix-image', requireCurator, async (req, res) => {
app.post('/api/paintings/:id/fix-image', requirePermission('images'), async (req, res) => {
try {
const paintingId = parseInt(req.params.id, 10);
const { imageUrl, searchUrl, source, pageUrl, thumbUrl } = req.body ?? {};
@@ -1023,7 +1068,7 @@ app.post('/api/paintings/:id/fix-image', requireCurator, async (req, res) => {
}
});
app.delete('/api/paintings/:id', requireCurator, async (req, res) => {
app.delete('/api/paintings/:id', requirePermission('images'), async (req, res) => {
try {
const paintingId = parseInt(req.params.id, 10);
if (!Number.isFinite(paintingId)) {
@@ -1047,7 +1092,7 @@ app.delete('/api/paintings/:id', requireCurator, async (req, res) => {
}
});
app.post('/api/paintings/:id/clear-image', requireCurator, async (req, res) => {
app.post('/api/paintings/:id/clear-image', requirePermission('images'), async (req, res) => {
try {
const paintingId = parseInt(req.params.id, 10);
const updated = await clearPaintingImage(paintingId);
@@ -1070,7 +1115,7 @@ app.post('/api/paintings/:id/clear-image', requireCurator, async (req, res) => {
}
});
app.post('/api/paintings/:id/upload-image', requireCurator, async (req, res) => {
app.post('/api/paintings/:id/upload-image', requirePermission('images'), async (req, res) => {
try {
const paintingId = parseInt(req.params.id, 10);
const { imageData, mimeType } = req.body ?? {};
@@ -1111,7 +1156,7 @@ app.post('/api/paintings/:id/upload-image', requireCurator, async (req, res) =>
});
// Proxy remote image for debug preview (avoids hotlink / CORS blocks)
app.get('/api/debug/image-proxy', requireCurator, async (req, res) => {
app.get('/api/debug/image-proxy', requirePermission('images'), async (req, res) => {
try {
const imageUrl = req.query.url;
const searchUrl = req.query.searchUrl;
+75 -7
View File
@@ -1,5 +1,34 @@
const pool = require('../db');
const { hasPermission, effectivePermissions } = require('../permissions');
async function loadStaffUser(userId) {
const { rows } = await pool.query(
`SELECT id, username, role, permissions, is_active
FROM users WHERE id = $1`,
[userId]
);
if (rows.length === 0) return null;
const row = rows[0];
return {
id: row.id,
username: row.username,
role: row.role,
permissions: row.permissions || [],
is_active: row.is_active !== false,
};
}
function attachStaff(req, user) {
req.curatorUser = {
id: user.id,
username: user.username,
role: user.role,
permissions: user.permissions,
is_active: user.is_active,
};
}
/** Any active staff account (admin or curator). */
async function requireCurator(req, res, next) {
const userId = req.session?.userId;
if (!userId) {
@@ -7,16 +36,13 @@ async function requireCurator(req, res, next) {
}
try {
const { rows } = await pool.query(
`SELECT id, username FROM users WHERE id = $1`,
[userId]
);
if (rows.length === 0) {
const user = await loadStaffUser(userId);
if (!user || !user.is_active) {
req.session.destroy(() => {});
return res.status(401).json({ error: 'Curator login required' });
}
req.curatorUser = rows[0];
attachStaff(req, user);
next();
} catch (err) {
console.error('Auth middleware error:', err.message);
@@ -24,4 +50,46 @@ async function requireCurator(req, res, next) {
}
}
module.exports = { requireCurator };
/** Active staff with a specific permission (admins always pass). */
function requirePermission(permission) {
return async (req, res, next) => {
const userId = req.session?.userId;
if (!userId) {
return res.status(401).json({ error: 'Curator login required' });
}
try {
const user = await loadStaffUser(userId);
if (!user || !user.is_active) {
req.session.destroy(() => {});
return res.status(401).json({ error: 'Curator login required' });
}
attachStaff(req, user);
if (!hasPermission(user, permission)) {
return res.status(403).json({ error: 'Permission denied' });
}
next();
} catch (err) {
console.error('Auth middleware error:', err.message);
res.status(500).json({ error: 'Authentication failed' });
}
};
}
function staffAuthPayload(user) {
return {
role: user.role,
username: user.username,
permissions: effectivePermissions(user),
};
}
module.exports = {
requireCurator,
requirePermission,
loadStaffUser,
staffAuthPayload,
};
+15 -3
View File
@@ -17,6 +17,17 @@ const INCREMENTAL_MIGRATIONS = [
'migrate-i18n.sql',
'migrate-tours.sql',
'migrate-curator-notes.sql',
'migrate-user-roles.sql',
];
const BOOTSTRAP_ADMIN_PERMISSIONS = [
'images',
'checkup',
'curator_notes',
'translations',
'influences',
'tours',
'users',
];
async function bootstrapCurator() {
@@ -36,10 +47,11 @@ async function bootstrapCurator() {
const bcrypt = require('bcryptjs');
const passwordHash = await bcrypt.hash(password, 10);
await pool.query(
`INSERT INTO users (username, password_hash) VALUES ($1, $2)`,
[username, passwordHash]
`INSERT INTO users (username, password_hash, role, permissions, is_active)
VALUES ($1, $2, 'admin', $3::text[], true)`,
[username, passwordHash, BOOTSTRAP_ADMIN_PERMISSIONS]
);
console.log(` bootstrap curator account: ${username}`);
console.log(` bootstrap admin account: ${username}`);
}
async function applySqlFile(label, filePath) {
+41
View File
@@ -0,0 +1,41 @@
/** Fine-grained curator tool permissions. Admins are treated as having all. */
const ALL_PERMISSIONS = [
'images',
'checkup',
'curator_notes',
'translations',
'influences',
'tours',
'users',
];
function normalizePermissions(raw) {
if (!Array.isArray(raw)) return [];
const allowed = new Set(ALL_PERMISSIONS);
const out = [];
for (const key of raw) {
if (typeof key === 'string' && allowed.has(key) && !out.includes(key)) {
out.push(key);
}
}
return out;
}
function effectivePermissions(user) {
if (!user) return [];
if (user.role === 'admin') return [...ALL_PERMISSIONS];
return normalizePermissions(user.permissions);
}
function hasPermission(user, permission) {
if (!user || !permission) return false;
if (user.role === 'admin') return true;
return normalizePermissions(user.permissions).includes(permission);
}
module.exports = {
ALL_PERMISSIONS,
normalizePermissions,
effectivePermissions,
hasPermission,
};
+24 -19
View File
@@ -1,6 +1,7 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const pool = require('../db');
const { loadStaffUser, staffAuthPayload } = require('../middleware/auth');
const router = express.Router();
@@ -11,19 +12,13 @@ router.get('/me', async (req, res) => {
}
try {
const { rows } = await pool.query(
`SELECT id, username FROM users WHERE id = $1`,
[userId]
);
if (rows.length === 0) {
const user = await loadStaffUser(userId);
if (!user || !user.is_active) {
req.session.destroy(() => {});
return res.json({ role: 'user' });
}
res.json({
role: 'curator',
username: rows[0].username,
});
res.json(staffAuthPayload(user));
} catch (err) {
console.error('Auth me error:', err.message);
res.status(500).json({ error: 'Failed to read session' });
@@ -38,23 +33,36 @@ router.post('/login', async (req, res) => {
try {
const { rows } = await pool.query(
`SELECT id, username, password_hash FROM users WHERE LOWER(username) = LOWER($1)`,
`SELECT id, username, password_hash, role, permissions, is_active
FROM users WHERE LOWER(username) = LOWER($1)`,
[username.trim()]
);
if (rows.length === 0) {
return res.status(401).json({ error: 'Invalid username or password' });
}
const user = rows[0];
const valid = await bcrypt.compare(password, user.password_hash);
const row = rows[0];
if (row.is_active === false) {
return res.status(401).json({ error: 'Account is disabled' });
}
const valid = await bcrypt.compare(password, row.password_hash);
if (!valid) {
return res.status(401).json({ error: 'Invalid username or password' });
}
await pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id]);
await pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [row.id]);
req.session.userId = user.id;
req.session.username = user.username;
req.session.userId = row.id;
req.session.username = row.username;
const user = {
id: row.id,
username: row.username,
role: row.role,
permissions: row.permissions || [],
is_active: true,
};
// Ensure the store writes before the response finishes (proxy / HTTPS).
req.session.save((err) => {
@@ -62,10 +70,7 @@ router.post('/login', async (req, res) => {
console.error('Auth session save error:', err.message);
return res.status(500).json({ error: 'Login failed' });
}
res.json({
role: 'curator',
username: user.username,
});
res.json(staffAuthPayload(user));
});
} catch (err) {
console.error('Auth login error:', err.message);
+10 -10
View File
@@ -1,6 +1,6 @@
const express = require('express');
const pool = require('../db');
const { requireCurator } = require('../middleware/auth');
const { requirePermission } = require('../middleware/auth');
const { logCuratorAction } = require('../audit-log');
const {
COLUMN_ROLES,
@@ -24,7 +24,7 @@ function parseId(value) {
return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
}
router.get('/presets', requireCurator, (_req, res) => {
router.get('/presets', requirePermission('influences'), (_req, res) => {
res.json({
roles: COLUMN_ROLES,
presets: Object.values(PRESETS).map((p) => ({
@@ -35,7 +35,7 @@ router.get('/presets', requireCurator, (_req, res) => {
});
});
router.get('/', requireCurator, async (req, res) => {
router.get('/', requirePermission('influences'), async (req, res) => {
try {
const artistId = parseId(req.query.artistId);
const paintingId = parseId(req.query.paintingId);
@@ -138,7 +138,7 @@ router.get('/', requireCurator, async (req, res) => {
}
});
router.get('/graph', requireCurator, async (req, res) => {
router.get('/graph', requirePermission('influences'), async (req, res) => {
try {
const artistId = parseId(req.query.artistId);
const paintingId = parseId(req.query.paintingId);
@@ -261,7 +261,7 @@ router.get('/graph', requireCurator, async (req, res) => {
}
});
router.post('/', requireCurator, async (req, res) => {
router.post('/', requirePermission('influences'), async (req, res) => {
try {
const {
paintingId,
@@ -361,7 +361,7 @@ router.post('/', requireCurator, async (req, res) => {
}
});
router.patch('/:id', requireCurator, async (req, res) => {
router.patch('/:id', requirePermission('influences'), async (req, res) => {
try {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid id' });
@@ -450,7 +450,7 @@ router.patch('/:id', requireCurator, async (req, res) => {
}
});
router.delete('/:id', requireCurator, async (req, res) => {
router.delete('/:id', requirePermission('influences'), async (req, res) => {
try {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid id' });
@@ -491,7 +491,7 @@ router.delete('/:id', requireCurator, async (req, res) => {
}
});
router.post('/import/parse', requireCurator, async (req, res) => {
router.post('/import/parse', requirePermission('influences'), async (req, res) => {
try {
const { filename, sheet, contentBase64, content } = req.body || {};
let buffer;
@@ -552,7 +552,7 @@ router.post('/import/parse', requireCurator, async (req, res) => {
}
});
router.post('/import/preview', requireCurator, async (req, res) => {
router.post('/import/preview', requirePermission('influences'), async (req, res) => {
try {
const { rows, mapping, sourceLabel, contentHash, payloadHash } = req.body || {};
if (!Array.isArray(rows) || !rows.length) {
@@ -585,7 +585,7 @@ router.post('/import/preview', requireCurator, async (req, res) => {
}
});
router.post('/import/commit', requireCurator, async (req, res) => {
router.post('/import/commit', requirePermission('influences'), async (req, res) => {
try {
const { proposals, fileName, contentHash, payloadHash, force } = req.body || {};
if (!Array.isArray(proposals)) {
+8 -11
View File
@@ -1,6 +1,6 @@
const express = require('express');
const pool = require('../db');
const { requireCurator } = require('../middleware/auth');
const { requirePermission, loadStaffUser } = require('../middleware/auth');
const { logCuratorAction } = require('../audit-log');
const { enrichPaintingRow } = require('../image-service');
const { localizePaintings, resolveLocale, translationStatuses } = require('../translation-service');
@@ -87,7 +87,7 @@ router.get('/', async (req, res) => {
}
});
router.get('/admin', requireCurator, async (_req, res) => {
router.get('/admin', requirePermission('tours'), async (_req, res) => {
try {
const { rows } = await pool.query(
`SELECT t.*,
@@ -125,11 +125,8 @@ router.get('/:id', async (req, res) => {
const tour = rows[0];
let isCurator = false;
if (req.session?.userId) {
const { rows: users } = await pool.query(
'SELECT id FROM users WHERE id = $1',
[req.session.userId],
);
isCurator = users.length > 0;
const user = await loadStaffUser(req.session.userId);
isCurator = Boolean(user && user.is_active);
}
if (tour.status !== 'published' && !isCurator) {
return res.status(404).json({ error: 'Tour not found' });
@@ -150,7 +147,7 @@ router.get('/:id', async (req, res) => {
}
});
router.post('/', requireCurator, async (req, res) => {
router.post('/', requirePermission('tours'), async (req, res) => {
try {
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
if (!title) return res.status(400).json({ error: 'title required' });
@@ -180,7 +177,7 @@ router.post('/', requireCurator, async (req, res) => {
}
});
router.patch('/:id', requireCurator, async (req, res) => {
router.patch('/:id', requirePermission('tours'), async (req, res) => {
try {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid id' });
@@ -236,7 +233,7 @@ router.patch('/:id', requireCurator, async (req, res) => {
}
});
router.delete('/:id', requireCurator, async (req, res) => {
router.delete('/:id', requirePermission('tours'), async (req, res) => {
try {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid id' });
@@ -260,7 +257,7 @@ router.delete('/:id', requireCurator, async (req, res) => {
}
});
router.put('/:id/stops', requireCurator, async (req, res) => {
router.put('/:id/stops', requirePermission('tours'), async (req, res) => {
const client = await pool.connect();
try {
const id = parseId(req.params.id);
+7 -7
View File
@@ -1,6 +1,6 @@
const express = require('express');
const pool = require('../db');
const { requireCurator } = require('../middleware/auth');
const { requirePermission } = require('../middleware/auth');
const { logCuratorAction } = require('../audit-log');
const {
TRANSLATABLE_FIELDS,
@@ -14,7 +14,7 @@ const router = express.Router();
const VALID_ENTITY_TYPES = new Set(Object.keys(TRANSLATABLE_FIELDS));
router.get('/worklist/:entityType', requireCurator, async (req, res) => {
router.get('/worklist/:entityType', requirePermission('translations'), async (req, res) => {
try {
const entityType = req.params.entityType;
const locale = typeof req.query.locale === 'string' ? req.query.locale : 'ru';
@@ -75,7 +75,7 @@ router.get('/worklist/:entityType', requireCurator, async (req, res) => {
}
});
router.get('/coverage', requireCurator, async (req, res) => {
router.get('/coverage', requirePermission('translations'), async (req, res) => {
try {
const locale = typeof req.query.locale === 'string' ? req.query.locale : 'ru';
const coverage = await getTranslationCoverage(locale);
@@ -86,7 +86,7 @@ router.get('/coverage', requireCurator, async (req, res) => {
}
});
router.get('/', requireCurator, async (req, res) => {
router.get('/', requirePermission('translations'), async (req, res) => {
try {
const entityType = typeof req.query.entityType === 'string' ? req.query.entityType : undefined;
const locale = typeof req.query.locale === 'string' ? req.query.locale : 'ru';
@@ -99,7 +99,7 @@ router.get('/', requireCurator, async (req, res) => {
}
});
router.get('/:entityType/:id', requireCurator, async (req, res) => {
router.get('/:entityType/:id', requirePermission('translations'), async (req, res) => {
try {
const entityType = req.params.entityType;
const entityId = parseInt(req.params.id, 10);
@@ -133,7 +133,7 @@ router.get('/:entityType/:id', requireCurator, async (req, res) => {
}
});
router.put('/:entityType/:id', requireCurator, async (req, res) => {
router.put('/:entityType/:id', requirePermission('translations'), async (req, res) => {
try {
const entityType = req.params.entityType;
const entityId = parseInt(req.params.id, 10);
@@ -185,7 +185,7 @@ router.put('/:entityType/:id', requireCurator, async (req, res) => {
}
});
router.post('/:entityType/:id/publish', requireCurator, async (req, res) => {
router.post('/:entityType/:id/publish', requirePermission('translations'), async (req, res) => {
try {
const entityType = req.params.entityType;
const entityId = parseInt(req.params.id, 10);
+230
View File
@@ -0,0 +1,230 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const pool = require('../db');
const { requirePermission } = require('../middleware/auth');
const { logCuratorAction } = require('../audit-log');
const { ALL_PERMISSIONS, normalizePermissions } = require('../permissions');
const router = express.Router();
const USER_SELECT = `id, username, role, permissions, is_active, created_at, last_login_at`;
function mapUser(row) {
return {
id: row.id,
username: row.username,
role: row.role,
permissions: row.role === 'admin' ? [...ALL_PERMISSIONS] : normalizePermissions(row.permissions),
is_active: row.is_active !== false,
created_at: row.created_at,
last_login_at: row.last_login_at,
};
}
function parseRole(raw) {
if (raw === 'admin' || raw === 'curator') return raw;
return null;
}
async function countActiveAdmins(client = pool) {
const { rows } = await client.query(
`SELECT COUNT(*)::int AS n FROM users WHERE role = 'admin' AND is_active = true`
);
return rows[0].n;
}
async function clearUserSessions(userId) {
// connect-pg-simple stores session JSON with userId
await pool.query(`DELETE FROM session WHERE (sess->>'userId')::int = $1`, [userId]);
}
router.use(requirePermission('users'));
router.get('/', async (_req, res) => {
try {
const { rows } = await pool.query(
`SELECT ${USER_SELECT} FROM users ORDER BY username ASC`
);
res.json({ users: rows.map(mapUser), permissions: ALL_PERMISSIONS });
} catch (err) {
console.error('Users list error:', err.message);
res.status(500).json({ error: 'Failed to list users' });
}
});
router.post('/', async (req, res) => {
try {
const username =
typeof req.body?.username === 'string' ? req.body.username.trim() : '';
const password = typeof req.body?.password === 'string' ? req.body.password : '';
const role = parseRole(req.body?.role) || 'curator';
const permissions = normalizePermissions(req.body?.permissions);
if (!username || username.length < 2 || username.length > 64) {
return res.status(400).json({ error: 'Username must be 264 characters' });
}
if (!/^[a-zA-Z0-9._-]+$/.test(username)) {
return res.status(400).json({ error: 'Username may only contain letters, numbers, . _ -' });
}
if (!password || password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
if (role === 'admin' && req.curatorUser.role !== 'admin') {
return res.status(403).json({ error: 'Only admins can create admin accounts' });
}
const passwordHash = await bcrypt.hash(password, 10);
const storedPermissions = role === 'admin' ? ALL_PERMISSIONS : permissions;
const { rows } = await pool.query(
`INSERT INTO users (username, password_hash, role, permissions, is_active)
VALUES ($1, $2, $3, $4::text[], true)
RETURNING ${USER_SELECT}`,
[username, passwordHash, role, storedPermissions]
);
await logCuratorAction({
userId: req.curatorUser.id,
action: 'user.create',
resourceType: 'user',
resourceId: rows[0].id,
details: { username, role, permissions: storedPermissions },
req,
});
res.status(201).json({ user: mapUser(rows[0]) });
} catch (err) {
if (err.code === '23505') {
return res.status(409).json({ error: 'Username already exists' });
}
console.error('Users create error:', err.message);
res.status(500).json({ error: 'Failed to create user' });
}
});
router.patch('/:id', async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid id' });
const { rows: existingRows } = await pool.query(
`SELECT ${USER_SELECT} FROM users WHERE id = $1`,
[id]
);
if (!existingRows[0]) return res.status(404).json({ error: 'User not found' });
const existing = existingRows[0];
let role = existing.role;
if (req.body?.role !== undefined) {
const parsed = parseRole(req.body.role);
if (!parsed) return res.status(400).json({ error: 'Invalid role' });
if (parsed === 'admin' && req.curatorUser.role !== 'admin') {
return res.status(403).json({ error: 'Only admins can promote to admin' });
}
if (existing.role === 'admin' && parsed !== 'admin' && req.curatorUser.role !== 'admin') {
return res.status(403).json({ error: 'Only admins can demote admins' });
}
role = parsed;
}
let permissions = normalizePermissions(existing.permissions);
if (req.body?.permissions !== undefined) {
permissions = normalizePermissions(req.body.permissions);
}
if (role === 'admin') {
permissions = [...ALL_PERMISSIONS];
}
let isActive = existing.is_active !== false;
if (req.body?.is_active !== undefined) {
if (typeof req.body.is_active !== 'boolean') {
return res.status(400).json({ error: 'is_active must be boolean' });
}
isActive = req.body.is_active;
}
if (
existing.role === 'admin' &&
existing.is_active !== false &&
(role !== 'admin' || !isActive)
) {
const admins = await countActiveAdmins();
if (admins <= 1) {
return res.status(400).json({ error: 'Cannot deactivate or demote the last active admin' });
}
}
const { rows } = await pool.query(
`UPDATE users
SET role = $2, permissions = $3::text[], is_active = $4
WHERE id = $1
RETURNING ${USER_SELECT}`,
[id, role, permissions, isActive]
);
if (!isActive) {
await clearUserSessions(id);
}
await logCuratorAction({
userId: req.curatorUser.id,
action: 'user.update',
resourceType: 'user',
resourceId: id,
details: {
username: rows[0].username,
role,
permissions,
is_active: isActive,
},
req,
});
res.json({ user: mapUser(rows[0]) });
} catch (err) {
console.error('Users update error:', err.message);
res.status(500).json({ error: 'Failed to update user' });
}
});
router.post('/:id/password', async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid id' });
const password = typeof req.body?.password === 'string' ? req.body.password : '';
if (!password || password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
const { rows: existingRows } = await pool.query(
`SELECT id, username, role FROM users WHERE id = $1`,
[id]
);
if (!existingRows[0]) return res.status(404).json({ error: 'User not found' });
if (existingRows[0].role === 'admin' && req.curatorUser.role !== 'admin' && req.curatorUser.id !== id) {
return res.status(403).json({ error: 'Only admins can reset another admin password' });
}
const passwordHash = await bcrypt.hash(password, 10);
await pool.query(`UPDATE users SET password_hash = $2 WHERE id = $1`, [id, passwordHash]);
await clearUserSessions(id);
await logCuratorAction({
userId: req.curatorUser.id,
action: 'user.reset_password',
resourceType: 'user',
resourceId: id,
details: { username: existingRows[0].username },
req,
});
res.json({ ok: true });
} catch (err) {
console.error('Users password error:', err.message);
res.status(500).json({ error: 'Failed to reset password' });
}
});
module.exports = router;