Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cb9eef935 | ||
|
|
1853222e01 | ||
|
|
710d262516 | ||
|
|
0a5918c4dd | ||
|
|
ad1572b3aa | ||
|
|
8111cd0163 | ||
|
|
23e1210e86 | ||
|
|
485c7d3e6f | ||
|
|
08ff4651e2 | ||
|
|
0466b77328 | ||
|
|
bfa21989c9 | ||
|
|
67594ea4d3 | ||
|
|
41d2d5d844 | ||
|
|
8d3ebcce60 | ||
|
|
89e39d408a | ||
|
|
6656f91f25 | ||
|
|
8005252881 |
@@ -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
@@ -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 user’s 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 painting’s 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 |
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 painting’s 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
@@ -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 parent’s 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 movement’s 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 artist’s 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 artist’s **movement colour** into cream plaster tones |
|
||||
| Frame finish | **Unchecked** works: black moulding; **Reviewed** (`checkup_checked`): bright gold moulding at **double width** |
|
||||
| Influence lamps | A golden picture light appears **above frames** whose work has any influence-graph edge (`has_influence_links` from the API); fixture is mounted **upside down** (`InfluencePictureLamp` in `VirtualGallery.tsx`) |
|
||||
| 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 **1–26** → authored keys **27–52**, 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 ~100–200 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)
|
||||
|
||||
@@ -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 painting’s full picture must also **regenerate or remove** that painting’s 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 artist’s `ArtistName*` files under `paintings/` and `paintings/thumbs/` (see [FAC.md](FAC.md#dev--prod-image-sync-smb)). **Bidirectional merge** (newer file wins): `npm run harmonize:images` or full `npm run harmonize` — see [harmonize-dev-prod.md](harmonize-dev-prod.md). General sync reference: [environments.md](environments.md).
|
||||
**One-direction promote:** `npm run devtoprod:images` (dev → prod, skip older). **Refresh all images on dev from prod:** `npm run prodto:dev:images`. **One artist only:** map the SMB share, then `robocopy` that artist’s `ArtistName*` files under `paintings/` and `paintings/thumbs/` (see [FAC.md](FAC.md#dev--prod-image-sync-smb)). **Bidirectional merge** (newer file wins, 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 movement’s look, edit its entry in `movement-interior-styles.ts` and rebuild the client.
|
||||
Wing layout (up to 55 works per wing, U-shaped hang, window gap placement 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 movement’s 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 **artist’s** 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+1432
-308
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 texture — large 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 & right walls — up to ~55 per wing</li>
|
||||
<li>Back door: wing navigator & 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>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 27–52; gallery_dev uses 1–26.
|
||||
* 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
"influences": "Влияния",
|
||||
"tours": "Экскурсии",
|
||||
"toursEditor": "Редактор экскурсий",
|
||||
"users": "Пользователи",
|
||||
"openingTourGallery": "Открытие экскурсии…",
|
||||
"tourEmpty": "В этой экскурсии пока нет картин.",
|
||||
"tourLoadFailed": "Не удалось загрузить экскурсию.",
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
@@ -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 $$;
|
||||
@@ -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
@@ -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,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 2–64 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;
|
||||
Reference in New Issue
Block a user