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 backups (may contain data)
|
||||||
db/DataBackup/
|
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
|
# Recovery / temp files from local restore
|
||||||
_extracted/
|
_extracted/
|
||||||
_parse_*.js
|
_parse_*.js
|
||||||
|
tmp-*.js
|
||||||
|
tmp-*.py
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
+106
-27
@@ -30,7 +30,7 @@ curl.exe -sk https://devgallery.mysuperlab.netcraze.pro/api/bounds
|
|||||||
|
|
||||||
## Authentication
|
## 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.
|
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" }
|
{ "role": "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response (curator session)**
|
**Response (staff session)**
|
||||||
|
|
||||||
```json
|
```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`
|
### `POST /api/auth/login`
|
||||||
|
|
||||||
**Body:** `{ "username": "curator", "password": "…" }`
|
**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`
|
### `POST /api/auth/logout`
|
||||||
|
|
||||||
@@ -62,30 +68,57 @@ Destroys the session cookie.
|
|||||||
|
|
||||||
**Response:** `{ "ok": true }`
|
**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) |
|
Missing session → **`401`** `{ "error": "Curator login required" }`. Missing permission → **`403`** `{ "error": "Permission denied" }`.
|
||||||
|-------|-------------------------------|
|
|
||||||
| `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` |
|
|
||||||
|
|
||||||
**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`
|
## `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**
|
**Response**
|
||||||
|
|
||||||
@@ -564,7 +626,7 @@ Both lists are grouped by art movement and exclude the current artist. Each arti
|
|||||||
|
|
||||||
**Public** — no curator login required.
|
**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**
|
**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`
|
## `GET /api/paintings/:id`
|
||||||
|
|
||||||
Painting detail with influence graph neighbours.
|
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`.
|
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.
|
Uses `downloadImageForFix` in `scripts/image-fetcher.js` (browser User-Agent, referer fallbacks, Wikimedia URL upgrades) for reliable downloads from Google Arts, Commons, etc.
|
||||||
|
|
||||||
**Body**
|
**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.getArtistNavigation(id)` | `GET /api/artists/:id/navigation` |
|
||||||
| `api.getPainting(id)` | `GET /api/paintings/:id` |
|
| `api.getPainting(id)` | `GET /api/paintings/:id` |
|
||||||
| `preloadArtistImages(id)` | `POST /api/artists/:id/preload-images` |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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`
|
### `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 |
|
| Column | Type | Notes |
|
||||||
|--------|------|-------|
|
|--------|------|-------|
|
||||||
| `id` | SERIAL PK | |
|
| `id` | SERIAL PK | |
|
||||||
| `username` | VARCHAR(64) UNIQUE | Login name |
|
| `username` | VARCHAR(64) UNIQUE | Login name |
|
||||||
| `password_hash` | VARCHAR(255) | bcrypt hash |
|
| `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 | |
|
| `created_at` | TIMESTAMPTZ | |
|
||||||
| `last_login_at` | TIMESTAMPTZ | Updated on successful login |
|
| `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`
|
### `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 |
|
| Column | Type | Notes |
|
||||||
|--------|------|-------|
|
|--------|------|-------|
|
||||||
| `id` | BIGSERIAL PK | |
|
| `id` | BIGSERIAL PK | |
|
||||||
| `user_id` | FK → `users` | Who performed the action |
|
| `user_id` | FK → `users` | Who performed the action |
|
||||||
| `action` | VARCHAR(64) | e.g. `painting.fix_image`, `artist.upload_portrait` |
|
| `action` | VARCHAR(64) | e.g. `painting.fix_image`, `user.create` |
|
||||||
| `resource_type` | VARCHAR(32) | `painting` or `artist` |
|
| `resource_type` | VARCHAR(32) | `painting`, `artist`, `tour`, `user`, etc. |
|
||||||
| `resource_id` | INTEGER | Target row id |
|
| `resource_id` | INTEGER | Target row id |
|
||||||
| `details` | JSONB | Optional metadata (URL, mime type, flag values) |
|
| `details` | JSONB | Optional metadata (URL, mime type, flag values) |
|
||||||
| `ip_address` | VARCHAR(45) | Client IP (respects `TRUST_PROXY`) |
|
| `ip_address` | VARCHAR(45) | Client IP (respects `TRUST_PROXY`) |
|
||||||
| `created_at` | TIMESTAMPTZ | |
|
| `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:
|
Example query in pgAdmin:
|
||||||
|
|
||||||
|
|||||||
+10
-5
@@ -95,16 +95,19 @@ CURATOR_USERNAME=curator
|
|||||||
CURATOR_PASSWORD=your-secure-password
|
CURATOR_PASSWORD=your-secure-password
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open the gallery → **Curator login** (top-right) → use debug mode / Checkup / Translations / **Influences** / 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:**
|
**Roles:**
|
||||||
|
|
||||||
| Role | Access |
|
| Role | Access |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| Guest (`user`) | Timeline, movement flow, 3D halls, painting detail, bios |
|
| Guest (`user`) | Timeline, movement flow, 3D halls, painting detail, bios |
|
||||||
| Curator | Above + debug mode, Checkup, Translations, Influences (import/CRUD/graph), 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`):**
|
**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 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` | 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: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) |
|
| `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`.
|
**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:find-duplicates` | Duplicate / near-duplicate painting rows |
|
||||||
| `npm run dev:audit-influence-duplicates` | Duplicate influence edges |
|
| `npm run dev:audit-influence-duplicates` | Duplicate influence edges |
|
||||||
| `npm run dev:analyze-painter-palette` | CSV ↔ artist name match report |
|
| `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
|
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)
|
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)
|
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 ?
|
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.)
|
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`
|
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)
|
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.
|
1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries.
|
||||||
2. **Movement flow** — art movements as curved SVG streams on the same year axis; documented predecessor→successor branches; portrait thumbnails placed along each stream.
|
2. **Movement flow** — art movements as curved SVG streams on the same year axis; documented predecessor→successor branches; portrait thumbnails placed along each stream.
|
||||||
3. **3D gallery** — one personal hall per artist, a **movement gallery** (click a movement name), or a **guided tour** hall (timeline → **Tours**): period-themed or tour wings of up to ~55 works, 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.
|
4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), optional **curator notes** and **art-history notes** with image markers, prev/next catalog browsing, optional fullscreen, link to artist biography.
|
||||||
5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`). With debug mode on, the same image-audit panel as painting detail (portrait search, **Checked** / **Fix it** / **More** / **Clear** / **Upload**).
|
5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`). With debug mode on, the same image-audit panel as painting detail (portrait search, **Checked** / **Fix it** / **More** / **Clear** / **Upload**).
|
||||||
|
|
||||||
@@ -76,8 +76,8 @@ Gallery/
|
|||||||
│ ├── painter-palette-lib.js
|
│ ├── painter-palette-lib.js
|
||||||
│ ├── import-painter-palette.js
|
│ ├── import-painter-palette.js
|
||||||
│ └── image-fetcher.js
|
│ └── image-fetcher.js
|
||||||
├── Inputs/ # External datasets (e.g. PainterPalette.csv)
|
├── Inputs/ # External datasets & influence workbooks (PainterPalette, book extracts, …)
|
||||||
├── Output/ # Generated exports (e.g. paintings.csv)
|
├── Output/ # Generated exports (CSV dumps, scratch extracts) — gitignored
|
||||||
├── data/images/ # Local portraits and paintings (+ thumbs/)
|
├── data/images/ # Local portraits and paintings (+ thumbs/)
|
||||||
├── db/ # schema.sql, setup-admin.sql, migrate-*.sql
|
├── db/ # schema.sql, setup-admin.sql, migrate-*.sql
|
||||||
├── server/migrate.js # npm run dev:migrate — schema + incremental migrations
|
├── 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 |
|
| 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) |
|
| 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 **“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, HDR Environment settles, or door/hall shaders warm up after the hall opens (painting images continue loading after the overlay dismisses) |
|
| 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…”** | While wall painting textures are still downloading / uploading to the GPU |
|
| 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 |
|
| 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.
|
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
|
### 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 |
|
| Feature | Implementation |
|
||||||
|---------|----------------|
|
|---------|----------------|
|
||||||
| Lineage layout | `client/src/data/movement-lineage.ts` — curated predecessor→successor pairs (Met / ArtStory / museum essays); multiple parents allowed |
|
| 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 |
|
| 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 the **centre** of a parent stream to the **centre** of each child stream (siblings fan out along the parent’s length) |
|
| 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 |
|
||||||
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
### 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) |
|
| Scroll wheel anywhere on flow canvas | Zoom (same year range as timeline; works over portraits and labels too) |
|
||||||
| Drag on flow canvas | Pan |
|
| Drag on flow canvas | Pan |
|
||||||
| Click portrait | Open artist biography |
|
| 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.
|
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`) |
|
| Floor | Herringbone **parquet** procedural texture (`parquetFloorTexture.ts`) |
|
||||||
| Wall tint | Gallery walls blend the artist’s **movement colour** into cream plaster tones |
|
| 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** |
|
| 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` |
|
| Curator-note plates | A small brass plate hangs **beneath frames** that have non-empty `curator_notes` |
|
||||||
| Eye-level viewing | Frame centres sit at **eye height (~1.65 m)**; the camera stays **level with the floor** (no pitch up/down) |
|
| Eye-level viewing | Frame centres sit at **eye height (~1.65 m)**; the camera stays **level with the floor** (no pitch up/down) |
|
||||||
| Open centre | Floor and ceiling only — no columns, pedestals, or other centre objects |
|
| Open centre | Floor and ceiling only — no freestanding columns or pedestals in the walkway |
|
||||||
| Museum exit | Front-wall **double doors** with transom, brass hardware, sconces, marble threshold, and warm vestibule glow |
|
| Museum exit | Front-wall **double doors** with transom, brass hardware, sconces, marble threshold, and warm vestibule glow |
|
||||||
| Shadows | **Disabled** — no Canvas shadow maps / `castShadow` (performance; flat lighting only) |
|
| 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 |
|
| 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
|
### 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 |
|
| 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 |
|
| 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) |
|
| Wall order | Same U-shaped hang as artist halls (left → end → right) |
|
||||||
| Frame captions | **Year · artist** label below each frame |
|
| Frame captions | **Year · artist** label below each frame |
|
||||||
| Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, NYC loft, white cube, etc.) |
|
| Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, 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 — plus **single-colour painted walls** (`painted-lime`, `painted-oil-matte`, `painted-oil-satin`, `painted-emulsion`, `painted-flat`) tinted per movement for Renaissance salons through modern white cubes |
|
| Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco, **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 |
|
| 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 |
|
| 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 | Daylight from windows + ceiling track lights + ambient/sun fill |
|
| Lighting | Shared hall lights only (ambient / hemisphere / directional + capped ceiling track spots + one fill per window). See **light budget** below |
|
||||||
| Back wall | **Exit double doors** → **Wing navigator** (jump to any wing) or **Exit to Timeline** |
|
| 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 |
|
||||||
| Front wall | Open **“Next wing →”** archway when a later wing exists; walk through or press `E` when near |
|
| Back wall | Single wing: solid display wall. Multi-wing: **Exit double doors** → **Wing navigator** or **Exit to Timeline** |
|
||||||
| Influence lamps | Same golden upside-down lamps as artist halls when `has_influence_links` is true |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Walk / turn / drag | Same as artist hall |
|
||||||
| Click painting | Open detail view (returns to the same wing) |
|
| Click painting | Open detail view (returns to the same wing) |
|
||||||
| Back wall / `E` / **Wings / Exit →** | Open wing navigator |
|
| Entrance / back door / `E` / **Wings / Exit →** | Open wing navigator (or exit on single-wing halls) |
|
||||||
| Front archway / `E` (when near) | Advance to the **next chronological wing** |
|
| 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.
|
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
|
### 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.
|
**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).
|
- **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.
|
- **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.
|
- **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 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.
|
- **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.
|
- **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 |
|
| Role | Who | Can do |
|
||||||
|------|-----|--------|
|
|------|-----|--------|
|
||||||
| **`user`** | Anonymous visitor (default) | Browse timeline, movement flow, 3D artist/movement halls, painting detail, artist bios, images |
|
| **`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)
|
## 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 |
|
| Feature | Where | Purpose |
|
||||||
|---------|--------|---------|
|
|---------|--------|---------|
|
||||||
| **Catalog search** | Timeline header (all visitors) | Find artists, paintings, movements; `GET /api/search`; navigate to gallery or detail |
|
| **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 |
|
| **Curator login** | Home header (guests) | Username + password modal; unlocks permitted tools |
|
||||||
| **Debug mode** | Home header toggle (curators only) | Persists in `localStorage`; enables debug panel on painting detail and artist bio |
|
| **Debug mode** | Home header toggle (`images`) | 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 |
|
| **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** (curators only) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags |
|
| **Checkup page** | Home header → **Checkup** (`checkup`) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags |
|
||||||
| **Translations** | Home header → **Translations** (curators only) | Review/publish Russian `entity_translations` |
|
| **Translations** | Home header → **Translations** (`translations`) | 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) |
|
| **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** (curators only) | Create/publish guided tours and stop text — [tours.md](tours.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) |
|
| **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 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)
|
### 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.
|
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.
|
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.
|
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
|
## 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 |
|
| **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` |
|
| **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
|
## 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 |
|
| `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 |
|
| `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` |
|
| `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 |
|
| `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-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` |
|
| `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)
|
## 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)
|
## 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
|
- **Wikipedia search** when catalog labels fail
|
||||||
- Wikidata → Wikimedia Commons → Wikipedia page image
|
- Wikidata → Wikimedia Commons → Wikipedia page image
|
||||||
- Fallbacks: Met Museum, Art Institute of Chicago, Cleveland Museum, Rijksmuseum, Smithsonian*, Harvard*
|
- 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.
|
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
|
## 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).
|
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
|
## Catalog export
|
||||||
|
|
||||||
Export the full painting catalog as CSV:
|
Export helpers write under **`Output/`** (gitignored — regenerate as needed):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev:export-paintings
|
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
|
## Image fetcher overrides
|
||||||
|
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ npm run infra:db:split-dev-prod
|
|||||||
CURATOR_PASSWORD=your-secure-password
|
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:
|
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 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` | 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: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
|
## Image sync
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ npm run harmonize:dry-run
|
|||||||
| `backupProd` | `prod:db:backup` | Safety snapshot |
|
| `backupProd` | `prod:db:backup` | Safety snapshot |
|
||||||
| `schema` | `harmonize:schema` | Apply dev migrations to prod (when `schemaChanged: true`) |
|
| `schema` | `harmonize:schema` | Apply dev migrations to prod (when `schemaChanged: true`) |
|
||||||
| `db` | `harmonize:db` | Row-level catalog merge |
|
| `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 |
|
| `verify` | curl `/api/bounds` | Optional smoke check |
|
||||||
|
|
||||||
Reports are written to `db/SyncReports/harmonize_db_*.json` and `harmonize_images_*.json` (gitignored).
|
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` | Merge catalog rows by `updated_at` |
|
||||||
| `npm run harmonize:db -- --dry-run` | Preview DB changes |
|
| `npm run harmonize:db -- --dry-run` | Preview DB changes |
|
||||||
| `npm run harmonize:db -- --prefer=dev` | On equal `updated_at`, dev wins |
|
| `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: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 dev:backfill-updated-at` | Backfill dev `updated_at` from image mtimes |
|
||||||
| `npm run harmonize:backfill-updated-at` | Same backfill on prod |
|
| `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
|
### Presets
|
||||||
|
|
||||||
| Preset | Typical headers |
|
| Preset | Typical headers | Example file |
|
||||||
|--------|-----------------|
|
|--------|-----------------|--------------|
|
||||||
| Web sources | `Artist`, `Painting`, `Influenced by`, `Influenced`, `Reference (source + link)` |
|
| 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) |
|
| Story of Art | Same Title Case (+ chapter reference column) | [`Inputs/story_of_art_influences.xlsx`](../Inputs/story_of_art_influences.xlsx) |
|
||||||
| Art influences | `artist`, `painting`, `influenced_by`, `influenced`, `reference` |
|
| Gariff influential painters | `artist`, `painting`, `influenced by`, `influenced`, `reference` | [`Inputs/gariff_influential_painters_influences.xlsx`](../Inputs/gariff_influential_painters_influences.xlsx) |
|
||||||
| Custom | Map any columns manually |
|
| 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).
|
Token classification order: **artist → movement → painting title** (under subject artist, then global).
|
||||||
|
|
||||||
Committed edges use `confidence=curated`, `discovered_via=import-wizard`.
|
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
|
### Duplicate file / data guard
|
||||||
|
|
||||||
Each successful commit stores SHA-256 fingerprints in `curator_audit_log` (`influence.import` details):
|
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,
|
Artist,
|
||||||
ArtistDetail,
|
ArtistDetail,
|
||||||
MovementGalleryDetail,
|
MovementGalleryDetail,
|
||||||
|
ArtistSummary,
|
||||||
Painting,
|
Painting,
|
||||||
PaintingDetail,
|
PaintingDetail,
|
||||||
ArtistNavigation,
|
ArtistNavigation,
|
||||||
@@ -39,11 +40,41 @@ function localizedPath(path: string, params?: URLSearchParams): string {
|
|||||||
|
|
||||||
const fetchCredentials: RequestInit = { credentials: 'include' };
|
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 {
|
export interface AuthState {
|
||||||
role: AuthRole;
|
role: AuthRole;
|
||||||
username?: string;
|
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> {
|
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
@@ -146,6 +177,34 @@ export function galleryImageUrl(
|
|||||||
return null;
|
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(
|
export function galleryImageUrlWithRevision(
|
||||||
painting: {
|
painting: {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -272,6 +331,15 @@ export async function preloadArtistImages(artistId: number): Promise<{ fetched:
|
|||||||
return res.json();
|
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 {
|
export interface FixPaintingImageResult {
|
||||||
imagePath: string | null;
|
imagePath: string | null;
|
||||||
thumbnailPath: string | null;
|
thumbnailPath: string | null;
|
||||||
@@ -337,6 +405,52 @@ export interface PaintingCheckupData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
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`),
|
getBounds: () => fetchJson<YearBounds>(`${API}/bounds`),
|
||||||
|
|
||||||
getCatalogBootstrap: (start?: number, end?: number) => {
|
getCatalogBootstrap: (start?: number, end?: number) => {
|
||||||
@@ -373,6 +487,9 @@ export const api = {
|
|||||||
|
|
||||||
getMovementGallery: (id: number) => fetchJson<MovementGalleryDetail>(localizedPath(`${API}/movements/${id}/gallery`)),
|
getMovementGallery: (id: number) => fetchJson<MovementGalleryDetail>(localizedPath(`${API}/movements/${id}/gallery`)),
|
||||||
|
|
||||||
|
getMovementArtistsSummary: (id: number) =>
|
||||||
|
fetchJson<ArtistSummary[]>(localizedPath(`${API}/movements/${id}/artists-summary`)),
|
||||||
|
|
||||||
getArtistNavigation: (id: number) =>
|
getArtistNavigation: (id: number) =>
|
||||||
fetchJson<ArtistNavigation>(localizedPath(`${API}/artists/${id}/navigation`)),
|
fetchJson<ArtistNavigation>(localizedPath(`${API}/artists/${id}/navigation`)),
|
||||||
|
|
||||||
@@ -788,6 +905,7 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
preloadArtistImages,
|
preloadArtistImages,
|
||||||
|
preloadMovementImages,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface TranslationWorklistItem {
|
export interface TranslationWorklistItem {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface Props {
|
|||||||
artist: Artist & { movement_name?: string };
|
artist: Artist & { movement_name?: string };
|
||||||
debugMode?: boolean;
|
debugMode?: boolean;
|
||||||
debugShowMore?: boolean;
|
debugShowMore?: boolean;
|
||||||
|
canCheckup?: boolean;
|
||||||
portraitRevision?: number;
|
portraitRevision?: number;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onEnterGallery: () => void;
|
onEnterGallery: () => void;
|
||||||
@@ -36,6 +37,7 @@ export default function ArtistBio({
|
|||||||
artist,
|
artist,
|
||||||
debugMode = false,
|
debugMode = false,
|
||||||
debugShowMore = false,
|
debugShowMore = false,
|
||||||
|
canCheckup = true,
|
||||||
portraitRevision = 0,
|
portraitRevision = 0,
|
||||||
onBack,
|
onBack,
|
||||||
onEnterGallery,
|
onEnterGallery,
|
||||||
@@ -322,14 +324,16 @@ export default function ArtistBio({
|
|||||||
<p className="debug-image-status">No portrait image result found.</p>
|
<p className="debug-image-status">No portrait image result found.</p>
|
||||||
)}
|
)}
|
||||||
<div className="debug-action-buttons">
|
<div className="debug-action-buttons">
|
||||||
<button
|
{canCheckup && (
|
||||||
type="button"
|
<button
|
||||||
className="debug-checked-btn"
|
type="button"
|
||||||
onClick={handleMarkChecked}
|
className="debug-checked-btn"
|
||||||
disabled={!!artist.checkup_checked || markingChecked || uploading}
|
onClick={handleMarkChecked}
|
||||||
>
|
disabled={!!artist.checkup_checked || markingChecked || uploading}
|
||||||
{markingChecked ? '…' : 'Checked'}
|
>
|
||||||
</button>
|
{markingChecked ? '…' : 'Checked'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="debug-fix-btn"
|
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} />
|
<meshBasicMaterial color="#ffffff" transparent opacity={0.25} toneMapped={false} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
{/* Daylight into room */}
|
{/* One fill light per window — keep total scene lights well under WebGL limits. */}
|
||||||
<spotLight
|
<spotLight
|
||||||
position={[0, 0, 0.15]}
|
position={[0, 0, 0.15]}
|
||||||
angle={Math.min(1.2, (spec.width / Math.max(spec.height, 0.5)) * 0.55)}
|
angle={Math.min(1.2, (spec.width / Math.max(spec.height, 0.5)) * 0.55)}
|
||||||
penumbra={0.95}
|
penumbra={0.95}
|
||||||
intensity={spec.lightIntensity}
|
intensity={spec.lightIntensity * Math.PI * 0.85}
|
||||||
distance={14}
|
distance={14}
|
||||||
|
decay={0}
|
||||||
color={spec.lightColor}
|
color={spec.lightColor}
|
||||||
/>
|
/>
|
||||||
<pointLight
|
|
||||||
position={[0, 0, 0.25]}
|
|
||||||
intensity={spec.lightIntensity * 0.45}
|
|
||||||
distance={10}
|
|
||||||
color={glassColor}
|
|
||||||
decay={2}
|
|
||||||
/>
|
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -222,8 +216,9 @@ export function GalleryTrackLights({
|
|||||||
}) {
|
}) {
|
||||||
const positions = useMemo(() => {
|
const positions = useMemo(() => {
|
||||||
const pts: [number, number, number][] = [];
|
const pts: [number, number, number][] = [];
|
||||||
const cols = Math.max(2, Math.min(5, Math.floor(width / 4)));
|
// Cap fixtures so MeshStandard hall materials stay within WebGL light limits.
|
||||||
const rows = Math.max(1, Math.min(3, Math.floor(depth / 6)));
|
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 c = 0; c < cols; c++) {
|
||||||
for (let r = 0; r < rows; r++) {
|
for (let r = 0; r < rows; r++) {
|
||||||
const x = -width / 2 + (width / (cols + 1)) * (c + 1);
|
const x = -width / 2 + (width / (cols + 1)) * (c + 1);
|
||||||
@@ -244,10 +239,11 @@ export function GalleryTrackLights({
|
|||||||
</mesh>
|
</mesh>
|
||||||
<spotLight
|
<spotLight
|
||||||
position={[0, -0.04, 0]}
|
position={[0, -0.04, 0]}
|
||||||
angle={0.55}
|
angle={0.7}
|
||||||
penumbra={0.85}
|
penumbra={0.9}
|
||||||
intensity={intensity}
|
intensity={intensity}
|
||||||
distance={8}
|
distance={12}
|
||||||
|
decay={0}
|
||||||
color={color}
|
color={color}
|
||||||
/>
|
/>
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
.movements-flow-canvas {
|
.movements-flow-canvas {
|
||||||
--stream-stroke: 54px;
|
--stream-stroke: 54px;
|
||||||
--portrait-size: 52px;
|
--portrait-size: 39px;
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -96,12 +96,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.movement-branch-fast {
|
.movement-branch-fast {
|
||||||
stroke-opacity: 0.32;
|
stroke-opacity: 0.55;
|
||||||
stroke-width: calc(var(--stream-stroke) * 0.45);
|
stroke-width: calc(var(--stream-stroke) * 0.45);
|
||||||
|
stroke-linecap: round;
|
||||||
}
|
}
|
||||||
|
|
||||||
.movement-stream-fast {
|
.movement-stream-fast {
|
||||||
stroke-opacity: 0.42;
|
stroke-opacity: 0.62;
|
||||||
stroke-width: var(--stream-stroke);
|
stroke-width: var(--stream-stroke);
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
vector-effect: non-scaling-stroke;
|
vector-effect: non-scaling-stroke;
|
||||||
@@ -119,13 +120,14 @@
|
|||||||
stroke-width: var(--stream-stroke);
|
stroke-width: var(--stream-stroke);
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
vector-effect: non-scaling-stroke;
|
vector-effect: non-scaling-stroke;
|
||||||
|
filter: saturate(1.25) brightness(1.12) contrast(1.08);
|
||||||
transition: filter 0.2s ease, opacity 0.2s ease;
|
transition: filter 0.2s ease, opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.movement-stream-core {
|
.movement-stream-core {
|
||||||
stroke-width: 2px;
|
stroke-width: 2px;
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
opacity: 0.22;
|
opacity: 0.38;
|
||||||
stroke-dasharray: 8 6;
|
stroke-dasharray: 8 6;
|
||||||
animation: stream-shimmer 16s linear infinite;
|
animation: stream-shimmer 16s linear infinite;
|
||||||
vector-effect: non-scaling-stroke;
|
vector-effect: non-scaling-stroke;
|
||||||
@@ -133,29 +135,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.movements-flow-canvas.movements-flow-movement-hover .movement-stream-fill:not(.movement-stream-highlighted) {
|
.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) {
|
.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) {
|
.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 {
|
.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 {
|
.movement-stream-core.movement-stream-highlighted {
|
||||||
opacity: 0.72;
|
opacity: 0.82;
|
||||||
filter: brightness(1.5);
|
filter: brightness(1.55) saturate(1.2);
|
||||||
stroke-width: 2.5px;
|
stroke-width: 2.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.movement-branch.movement-branch-highlighted {
|
.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 {
|
@keyframes stream-shimmer {
|
||||||
@@ -173,7 +176,29 @@
|
|||||||
inset: 0;
|
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 {
|
.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;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,23 +264,16 @@
|
|||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 6;
|
z-index: 6;
|
||||||
}
|
transform: translate(-50%, -50%);
|
||||||
|
|
||||||
.movement-flow-label-above {
|
|
||||||
transform: translate(-4px, -100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.movement-flow-label-below {
|
|
||||||
transform: translate(-4px, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.movement-flow-label-btn {
|
.movement-flow-label-btn {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
border: none;
|
border: none;
|
||||||
background: rgba(12, 10, 18, 0.55);
|
background: rgba(12, 10, 18, 0.72);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: center;
|
||||||
transition: background 0.2s, box-shadow 0.2s;
|
transition: background 0.2s, box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +287,11 @@
|
|||||||
outline-offset: 2px;
|
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 {
|
.movement-name {
|
||||||
display: block;
|
display: block;
|
||||||
font-family: 'Georgia', serif;
|
font-family: 'Georgia', serif;
|
||||||
@@ -290,8 +313,8 @@
|
|||||||
background: none;
|
background: none;
|
||||||
border: 2px solid rgba(232, 213, 181, 0.75);
|
border: 2px solid rgba(232, 213, 181, 0.75);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
width: var(--portrait-size, 52px);
|
width: var(--portrait-size, 39px);
|
||||||
height: var(--portrait-size, 52px);
|
height: var(--portrait-size, 39px);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
@@ -335,7 +358,7 @@
|
|||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.movements-flow-canvas {
|
.movements-flow-canvas {
|
||||||
--stream-stroke: 44px;
|
--stream-stroke: 44px;
|
||||||
--portrait-size: 44px;
|
--portrait-size: 33px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.movements-flow-caption {
|
.movements-flow-caption {
|
||||||
@@ -343,8 +366,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.artist-portrait {
|
.artist-portrait {
|
||||||
width: 44px;
|
width: var(--portrait-size, 33px);
|
||||||
height: 44px;
|
height: var(--portrait-size, 33px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.movement-flow-label {
|
.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 }) {
|
function ByzantineDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) {
|
||||||
const columns = [
|
// Engaged porphyry colonnettes with Byzantine basket/impost capitals at the
|
||||||
[-halfW + 0.45, -halfD + 0.6],
|
// corners (flush — never proud of the ~0.7m frame hang margin), a marble
|
||||||
[halfW - 0.45, -halfD + 0.6],
|
// revetment dado at spring-line height, and hanging brass polycandela in
|
||||||
[-halfW + 0.45, halfD - 0.6],
|
// place of wall torches (a Byzantine basilica hangs its light, not sconces it).
|
||||||
[halfW - 0.45, halfD - 0.6],
|
const colonnettes = useMemo(
|
||||||
] as [number, number][];
|
() =>
|
||||||
|
[
|
||||||
|
[-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 (
|
return (
|
||||||
<group>
|
<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]}>
|
<group key={i} position={[x, 0, z]}>
|
||||||
<mesh position={[0, 1.85, 0]}>
|
<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} />
|
<meshStandardMaterial color="#f0ece8" roughness={0.72} metalness={0.12} />
|
||||||
</mesh>
|
</mesh>
|
||||||
<mesh position={[0, 3.85, 0]}>
|
<mesh position={[0, 3.78, 0]}>
|
||||||
<boxGeometry args={[0.42, 0.12, 0.42]} />
|
<boxGeometry args={[0.18, 0.1, 0.18]} />
|
||||||
<meshStandardMaterial color={trim} roughness={0.4} metalness={0.35} />
|
<meshStandardMaterial color={trim} roughness={0.4} metalness={0.35} />
|
||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
))}
|
))}
|
||||||
<mesh position={[0, 3.88, -halfD + 0.12]}>
|
{/* Cornice above wall title (title sits ~y 3.75) */}
|
||||||
<boxGeometry args={[2.8, 0.14, 0.2]} />
|
<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} />
|
<meshStandardMaterial color={trim} roughness={0.45} metalness={0.3} />
|
||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</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 (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{[
|
{columns.map(([x, z], i) => (
|
||||||
[-halfW + 0.5, 0],
|
|
||||||
[halfW - 0.5, 0],
|
|
||||||
].map(([x, z], i) => (
|
|
||||||
<group key={i} position={[x, 0, z]}>
|
<group key={i} position={[x, 0, z]}>
|
||||||
<mesh position={[0, 2, 0]}>
|
<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} />
|
<meshStandardMaterial color="#e0d8c8" roughness={0.88} />
|
||||||
</mesh>
|
</mesh>
|
||||||
<mesh position={[0, 4.05, 0]}>
|
<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} />
|
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.25} />
|
||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</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} />;
|
return <BaroqueDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
|
||||||
case 'medieval':
|
case 'medieval':
|
||||||
return <MedievalDetails halfW={halfW} halfD={halfD} />;
|
return <MedievalDetails halfW={halfW} halfD={halfD} />;
|
||||||
|
case 'byzantine':
|
||||||
|
return <ByzantineDetails halfW={halfW} halfD={halfD} trim={trim} />;
|
||||||
case 'neoclassical':
|
case 'neoclassical':
|
||||||
return <NeoclassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
|
return <NeoclassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
|
||||||
case 'classical':
|
case 'classical':
|
||||||
return <ClassicalDetails halfW={halfW} trim={trim} />;
|
return <ClassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
|
||||||
case 'salon':
|
case 'salon':
|
||||||
return <SalonDetails trim={trim} />;
|
return <SalonDetails trim={trim} />;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ interface Props {
|
|||||||
onArtistBio: () => void;
|
onArtistBio: () => void;
|
||||||
onInfluenceArtistClick?: (artistId: number) => void;
|
onInfluenceArtistClick?: (artistId: number) => void;
|
||||||
isCurator?: boolean;
|
isCurator?: boolean;
|
||||||
|
/** When false, hide the Checked action (needs checkup permission). Defaults to true when debugMode is on. */
|
||||||
|
canCheckup?: boolean;
|
||||||
debugMode?: boolean;
|
debugMode?: boolean;
|
||||||
debugShowMore?: boolean;
|
debugShowMore?: boolean;
|
||||||
onPaintingImageFixed?: (
|
onPaintingImageFixed?: (
|
||||||
@@ -206,6 +208,7 @@ export default function PaintingDetailView({
|
|||||||
onArtistBio,
|
onArtistBio,
|
||||||
onInfluenceArtistClick,
|
onInfluenceArtistClick,
|
||||||
isCurator = false,
|
isCurator = false,
|
||||||
|
canCheckup = true,
|
||||||
debugMode = false,
|
debugMode = false,
|
||||||
debugShowMore = false,
|
debugShowMore = false,
|
||||||
onPaintingImageFixed,
|
onPaintingImageFixed,
|
||||||
@@ -734,14 +737,16 @@ export default function PaintingDetailView({
|
|||||||
<p className="debug-image-status">No Google image result found.</p>
|
<p className="debug-image-status">No Google image result found.</p>
|
||||||
)}
|
)}
|
||||||
<div className="debug-action-buttons">
|
<div className="debug-action-buttons">
|
||||||
<button
|
{canCheckup && (
|
||||||
type="button"
|
<button
|
||||||
className="debug-checked-btn"
|
type="button"
|
||||||
onClick={handleMarkChecked}
|
className="debug-checked-btn"
|
||||||
disabled={!!painting.checkup_checked || markingChecked || uploading}
|
onClick={handleMarkChecked}
|
||||||
>
|
disabled={!!painting.checkup_checked || markingChecked || uploading}
|
||||||
{markingChecked ? '…' : 'Checked'}
|
>
|
||||||
</button>
|
{markingChecked ? '…' : 'Checked'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="debug-fix-btn"
|
className="debug-fix-btn"
|
||||||
|
|||||||
@@ -419,3 +419,9 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 8px;
|
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,
|
MovementArtistGroup,
|
||||||
TourGalleryDetail,
|
TourGalleryDetail,
|
||||||
} from '../types';
|
} 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 { comparePaintingsChronological, paintingHasCuratorNotes, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
|
||||||
import { cloneSurfaceTexture, getSurfaceTexture } from '../utils/galleryProceduralTextures';
|
import { cloneSurfaceTexture, getSurfaceTexture } from '../utils/galleryProceduralTextures';
|
||||||
import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles';
|
import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles';
|
||||||
@@ -98,9 +98,48 @@ const REVIEWED_MAT_BORDER = FRAME_MAT_BORDER * 2;
|
|||||||
const REVIEWED_RAIL = FRAME_RAIL * 2;
|
const REVIEWED_RAIL = FRAME_RAIL * 2;
|
||||||
const EYE_HEIGHT = 1.65;
|
const EYE_HEIGHT = 1.65;
|
||||||
const FRAME_GAP = 0.32;
|
const FRAME_GAP = 0.32;
|
||||||
const SHADER_WARM_TIMEOUT_MS = 4000;
|
const SHADER_WARM_TIMEOUT_MS = 2000;
|
||||||
/** Per-image fetch/decode deadline so a stuck request cannot hold the counter forever. */
|
/** Per-image fetch/decode deadline so a stuck request cannot hold the overlay counter forever. */
|
||||||
const TEXTURE_LOAD_TIMEOUT_MS = 10000;
|
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 MIN_FRAME_W = 0.45;
|
||||||
const MAX_FRAME_W = 1.05;
|
const MAX_FRAME_W = 1.05;
|
||||||
const MAX_FRAME_H = 1.35;
|
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. */
|
/** Every wall shows at most one row; side-wall depth grows to fit the catalog. */
|
||||||
const MAX_WALL_ROWS = 1;
|
const MAX_WALL_ROWS = 1;
|
||||||
const DOOR_WIDTH = 2.4;
|
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;
|
const DOOR_HEIGHT = 2.5;
|
||||||
/** Museum exit — dimensions derived from door opening. */
|
/** Museum exit — dimensions derived from door opening. */
|
||||||
const EXIT_JAMB = 0.13;
|
const EXIT_JAMB = 0.13;
|
||||||
@@ -176,6 +217,73 @@ interface HallLayout {
|
|||||||
segments: WallSegment[];
|
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 {
|
function paintingIsReviewed(painting: Painting): boolean {
|
||||||
return !!painting.checkup_checked;
|
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 [texture, setTexture] = useState<THREE.Texture | null>(null);
|
||||||
const [failed, setFailed] = useState(!url);
|
const [failed, setFailed] = useState(candidates.length === 0);
|
||||||
const textureLoad = useContext(GalleryTextureLoadContext);
|
const textureLoad = useContext(GalleryTextureLoadContext);
|
||||||
const { gl } = useThree();
|
const { gl } = useThree();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUrlIndex(0);
|
||||||
|
}, [candidateKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
setTexture(null);
|
setTexture(null);
|
||||||
setFailed(true);
|
setFailed(candidates.length === 0 || urlIndex >= candidates.length);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
|
setTexture(null);
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let loaded: THREE.Texture | null = null;
|
let loaded: THREE.Texture | null = null;
|
||||||
let settled = false;
|
let settled = false;
|
||||||
|
let releaseSlot: (() => void) | null = null;
|
||||||
const loader = new THREE.TextureLoader();
|
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 = () => {
|
const finish = () => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
textureLoad?.end();
|
textureLoad?.end();
|
||||||
|
releaseSlot?.();
|
||||||
|
releaseSlot = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
textureLoad?.begin();
|
textureLoad?.begin();
|
||||||
const loadTimeout = window.setTimeout(() => {
|
const loadTimeout = window.setTimeout(() => {
|
||||||
if (settled || disposed) return;
|
if (settled || disposed) return;
|
||||||
setFailed(true);
|
|
||||||
finish();
|
finish();
|
||||||
}, TEXTURE_LOAD_TIMEOUT_MS);
|
}, TEXTURE_LOAD_TIMEOUT_MS);
|
||||||
|
|
||||||
loader.load(
|
const slot = acquireTextureLoadSlot();
|
||||||
url,
|
void slot.promise.then((release) => {
|
||||||
(tex) => {
|
if (disposed) {
|
||||||
window.clearTimeout(loadTimeout);
|
release();
|
||||||
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.
|
|
||||||
finish();
|
finish();
|
||||||
if (!disposed) setTexture(tex);
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
);
|
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 () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
slot.cancel();
|
||||||
window.clearTimeout(loadTimeout);
|
window.clearTimeout(loadTimeout);
|
||||||
finish();
|
finish();
|
||||||
loaded?.dispose();
|
loaded?.dispose();
|
||||||
setTexture(null);
|
setTexture(null);
|
||||||
};
|
};
|
||||||
}, [url, textureLoad, gl]);
|
}, [url, urlIndex, candidates.length, textureLoad, gl]);
|
||||||
|
|
||||||
return { texture, failed };
|
return { texture, failed };
|
||||||
}
|
}
|
||||||
@@ -702,25 +844,11 @@ function InfluencePictureLamp({
|
|||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
color="#fff8e0"
|
color="#fff8e0"
|
||||||
emissive="#ffcc55"
|
emissive="#ffcc55"
|
||||||
emissiveIntensity={1.4 * glow}
|
emissiveIntensity={1.8 * glow}
|
||||||
toneMapped={false}
|
toneMapped={false}
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
<pointLight
|
{/* No per-painting lights — too many MeshStandard lights break hall shaders. */}
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
@@ -808,8 +936,8 @@ function PaintingFrame({
|
|||||||
const reviewed = paintingIsReviewed(painting);
|
const reviewed = paintingIsReviewed(painting);
|
||||||
const { matBorder, rail, depth: frameDepth } = frameDimsForReviewed(reviewed);
|
const { matBorder, rail, depth: frameDepth } = frameDimsForReviewed(reviewed);
|
||||||
const hasImage = paintingHasGalleryImage(painting);
|
const hasImage = paintingHasGalleryImage(painting);
|
||||||
const url = hasImage ? galleryImageUrlWithRevision(painting, imageRevision) : null;
|
const urls = hasImage ? galleryImageUrlCandidates(painting, imageRevision) : [];
|
||||||
const { texture, failed } = usePaintingTexture(url);
|
const { texture, failed } = usePaintingTexture(urls);
|
||||||
const showImage = hasImage && !failed && !!texture;
|
const showImage = hasImage && !failed && !!texture;
|
||||||
const showCanvas = !showImage;
|
const showCanvas = !showImage;
|
||||||
const hasInfluenceLinks = paintingHasInfluenceLinks(painting);
|
const hasInfluenceLinks = paintingHasInfluenceLinks(painting);
|
||||||
@@ -828,17 +956,12 @@ function PaintingFrame({
|
|||||||
const captionY =
|
const captionY =
|
||||||
-height / 2 - matBorder - rail - (hasCuratorNotes ? 0.18 : 0.1);
|
-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 (
|
return (
|
||||||
<group position={position} rotation={[0, rotationY, 0]}>
|
<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
|
<mesh
|
||||||
position={[0, 0, frameDepth / 2]}
|
position={[0, 0, frameDepth / 2]}
|
||||||
renderOrder={1}
|
renderOrder={1}
|
||||||
@@ -854,8 +977,8 @@ function PaintingFrame({
|
|||||||
color={finish.color}
|
color={finish.color}
|
||||||
roughness={finish.roughness}
|
roughness={finish.roughness}
|
||||||
metalness={finish.metalness}
|
metalness={finish.metalness}
|
||||||
emissive={finish.emissive}
|
emissive={reviewed ? finish.emissive : '#d8c090'}
|
||||||
emissiveIntensity={finish.emissiveIntensity}
|
emissiveIntensity={finish.emissiveIntensity + frameEmissiveBoost}
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
@@ -1299,7 +1422,7 @@ function GalleryFloor({
|
|||||||
normalScale={new THREE.Vector2(texture.normalScale, texture.normalScale)}
|
normalScale={new THREE.Vector2(texture.normalScale, texture.normalScale)}
|
||||||
roughness={texture.roughness}
|
roughness={texture.roughness}
|
||||||
metalness={texture.metalness}
|
metalness={texture.metalness}
|
||||||
envMapIntensity={0.65}
|
envMapIntensity={0.3}
|
||||||
color={interiorStyle.tints.floor}
|
color={interiorStyle.tints.floor}
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
@@ -1349,6 +1472,7 @@ function ArtistHall({
|
|||||||
nearPassage?: boolean;
|
nearPassage?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { width, depth, segments } = layout;
|
const { width, depth, segments } = layout;
|
||||||
|
const endWallHasDoor = movementMode && 'endWallHasDoor' in layout ? layout.endWallHasDoor : false;
|
||||||
const halfW = width / 2;
|
const halfW = width / 2;
|
||||||
const halfD = depth / 2;
|
const halfD = depth / 2;
|
||||||
const walls = useMemo(
|
const walls = useMemo(
|
||||||
@@ -1360,6 +1484,7 @@ function ArtistHall({
|
|||||||
);
|
);
|
||||||
const titleColor = interiorStyle?.titleColor ?? '#4a3020';
|
const titleColor = interiorStyle?.titleColor ?? '#4a3020';
|
||||||
const warmLight = interiorStyle?.warmLight ?? '#fff5e8';
|
const warmLight = interiorStyle?.warmLight ?? '#fff5e8';
|
||||||
|
const hallLightScale = interiorStyle?.lightScale ?? 1;
|
||||||
const wallRoughness = interiorStyle ? 0.75 : 0.92;
|
const wallRoughness = interiorStyle ? 0.75 : 0.92;
|
||||||
const wallMetalness = interiorStyle ? 0.08 : 0.06;
|
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 ? (
|
{movementMode && hasNextHall ? (
|
||||||
<>
|
<>
|
||||||
<GalleryWall
|
<GalleryWall
|
||||||
@@ -1458,10 +1583,8 @@ function ArtistHall({
|
|||||||
trimColor={walls.trim}
|
trimColor={walls.trim}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : movementMode ? (
|
) : movementMode && endWallHasDoor ? (
|
||||||
<>
|
<GalleryWall position={[0, WALL_HEIGHT / 2, halfD]} size={[width, WALL_HEIGHT]} color={walls.main} />
|
||||||
<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} />
|
<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 */}
|
{/* Back wall — multi-wing exit / navigator, or solid end wall for paintings */}
|
||||||
{movementMode ? (
|
{movementMode && endWallHasDoor ? (
|
||||||
<>
|
<>
|
||||||
<GalleryWall
|
<GalleryWall
|
||||||
position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, -halfD]}
|
position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, -halfD]}
|
||||||
@@ -1507,20 +1630,34 @@ function ArtistHall({
|
|||||||
/>
|
/>
|
||||||
</group>
|
</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}
|
) : null}
|
||||||
|
|
||||||
{interiorStyle && computedWindows && computedWindows.length > 0 && (
|
{interiorStyle && (
|
||||||
<>
|
<>
|
||||||
<GalleryWindows
|
{computedWindows && computedWindows.length > 0 && (
|
||||||
windows={computedWindows}
|
<GalleryWindows
|
||||||
halfW={halfW}
|
windows={computedWindows}
|
||||||
halfD={halfD}
|
halfW={halfW}
|
||||||
trimColor={interiorStyle.tints.trim}
|
halfD={halfD}
|
||||||
/>
|
trimColor={interiorStyle.tints.trim}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Always light the hall — do not gate track lights on window gaps. */}
|
||||||
<GalleryTrackLights
|
<GalleryTrackLights
|
||||||
width={width}
|
width={width}
|
||||||
depth={depth}
|
depth={depth}
|
||||||
intensity={interiorStyle.trackLights}
|
intensity={Math.max(1.8, interiorStyle.trackLights * 2.8) * Math.PI * interiorStyle.lightScale}
|
||||||
color={interiorStyle.warmLight}
|
color={interiorStyle.warmLight}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -1598,15 +1735,24 @@ function ArtistHall({
|
|||||||
</group>
|
</group>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<pointLight position={[0, WALL_HEIGHT - 0.4, 0]} intensity={interiorStyle ? interiorStyle.ambient * 1.2 : 0.5} color={warmLight} distance={width + depth} />
|
<pointLight
|
||||||
<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)} />
|
position={[0, WALL_HEIGHT - 0.4, 0]}
|
||||||
{depth > 14 && (
|
intensity={(interiorStyle ? Math.max(1.0, interiorStyle.ambient * 1.8) : 0.7) * Math.PI * hallLightScale}
|
||||||
<pointLight position={[0, WALL_HEIGHT - 0.4, -halfD + 1.2]} intensity={interiorStyle ? interiorStyle.ambient * 0.7 : 0.3} color={warmLight} distance={14} />
|
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 && (
|
{interiorStyle && (
|
||||||
<directionalLight
|
<directionalLight
|
||||||
position={[0, 6, -halfD - 2]}
|
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}
|
color={interiorStyle.sunLight}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1627,20 +1773,6 @@ function FrameloopSync({ active }: { active: boolean }) {
|
|||||||
return null;
|
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. */
|
/** Compile all scene materials (incl. culled doors) before dismissing the loading overlay. */
|
||||||
function WarmHallGpu({
|
function WarmHallGpu({
|
||||||
enabled,
|
enabled,
|
||||||
@@ -1749,7 +1881,7 @@ function MovementHallNavPanel({
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<button type="button" className="gallery-exit-btn movement-hall-exit-timeline" onClick={onExitTimeline}>
|
<button type="button" className="gallery-exit-btn movement-hall-exit-timeline" onClick={onExitTimeline}>
|
||||||
Exit to Timeline
|
Back to Timeline
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1762,11 +1894,13 @@ function NavigationPanel({
|
|||||||
navigation,
|
navigation,
|
||||||
loading,
|
loading,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onExitTimeline,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
navigation: ArtistNavigation | null;
|
navigation: ArtistNavigation | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onSelect: (artistId: number) => void;
|
onSelect: (artistId: number) => void;
|
||||||
|
onExitTimeline: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const renderColumn = (title: string, groups: MovementArtistGroup[], emptyHint: string) => (
|
const renderColumn = (title: string, groups: MovementArtistGroup[], emptyHint: string) => (
|
||||||
@@ -1822,6 +1956,11 @@ function NavigationPanel({
|
|||||||
'No documented successors via painting influences.'
|
'No documented successors via painting influences.'
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1880,7 +2019,6 @@ export default function VirtualGallery(props: Props) {
|
|||||||
const [isLooking, setIsLooking] = useState(false);
|
const [isLooking, setIsLooking] = useState(false);
|
||||||
const [texturesPending, setTexturesPending] = useState(0);
|
const [texturesPending, setTexturesPending] = useState(0);
|
||||||
const [canvasReady, setCanvasReady] = useState(false);
|
const [canvasReady, setCanvasReady] = useState(false);
|
||||||
const [envReady, setEnvReady] = useState(false);
|
|
||||||
const [shadersWarmed, setShadersWarmed] = useState(false);
|
const [shadersWarmed, setShadersWarmed] = useState(false);
|
||||||
const [glEpoch, setGlEpoch] = useState(0);
|
const [glEpoch, setGlEpoch] = useState(0);
|
||||||
const [glLost, setGlLost] = useState(false);
|
const [glLost, setGlLost] = useState(false);
|
||||||
@@ -1893,16 +2031,16 @@ export default function VirtualGallery(props: Props) {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleEnvReady = useCallback(() => {
|
|
||||||
setEnvReady(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleShadersWarmed = useCallback(() => {
|
const handleShadersWarmed = useCallback(() => {
|
||||||
setShadersWarmed(true);
|
setShadersWarmed(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCanvasCreated = useCallback((state: { gl: THREE.WebGLRenderer }) => {
|
const handleCanvasCreated = useCallback((state: { gl: THREE.WebGLRenderer }) => {
|
||||||
const canvas = state.gl.domElement;
|
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
|
// A freshly created canvas has a healthy context, so clear any lingering
|
||||||
// "restoring" state from a previous loss/remount.
|
// "restoring" state from a previous loss/remount.
|
||||||
setGlLost(false);
|
setGlLost(false);
|
||||||
@@ -1968,7 +2106,6 @@ export default function VirtualGallery(props: Props) {
|
|||||||
}, [hallKey]);
|
}, [hallKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEnvReady(false);
|
|
||||||
setShadersWarmed(false);
|
setShadersWarmed(false);
|
||||||
setTexturesPending(0);
|
setTexturesPending(0);
|
||||||
}, [hallKey, glEpoch]);
|
}, [hallKey, glEpoch]);
|
||||||
@@ -1977,19 +2114,12 @@ export default function VirtualGallery(props: Props) {
|
|||||||
setShadersWarmed(false);
|
setShadersWarmed(false);
|
||||||
}, [hallIndex]);
|
}, [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.
|
// If shader warm-up never settles, dismiss the overlay anyway.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (shadersWarmed || !canvasReady || !envReady) return;
|
if (shadersWarmed || !canvasReady) return;
|
||||||
const t = window.setTimeout(() => setShadersWarmed(true), SHADER_WARM_TIMEOUT_MS + 1000);
|
const t = window.setTimeout(() => setShadersWarmed(true), SHADER_WARM_TIMEOUT_MS + 500);
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
}, [shadersWarmed, canvasReady, envReady, hallKey, glEpoch, hallIndex]);
|
}, [shadersWarmed, canvasReady, hallKey, glEpoch, hallIndex]);
|
||||||
|
|
||||||
const layout = useMemo(() => {
|
const layout = useMemo(() => {
|
||||||
if (isWingedHall && movementHalls.length > 0) {
|
if (isWingedHall && movementHalls.length > 0) {
|
||||||
@@ -1998,17 +2128,17 @@ export default function VirtualGallery(props: Props) {
|
|||||||
return buildHallLayout(paintings, periods);
|
return buildHallLayout(paintings, periods);
|
||||||
}, [isWingedHall, movementHalls, hallIndex, paintings, periods]);
|
}, [isWingedHall, movementHalls, hallIndex, paintings, periods]);
|
||||||
|
|
||||||
// Do not block the hall on every painting texture — large artists (e.g. Duccio, 50+)
|
// Do not block hall entry on HDR Environment (CDN) — warm shaders as soon as
|
||||||
// otherwise sit on "Loading paintings…" for a long time. Textures keep loading after.
|
// the canvas exists; Environment continues loading in the background.
|
||||||
const gallerySceneLoading =
|
const gallerySceneLoading =
|
||||||
active &&
|
active &&
|
||||||
!glLost &&
|
!glLost &&
|
||||||
(!canvasReady || !envReady || !shadersWarmed);
|
(!canvasReady || !shadersWarmed);
|
||||||
|
|
||||||
const galleryLoadingMessage =
|
const galleryLoadingMessage =
|
||||||
canvasReady && texturesPending > 0 ? 'Loading paintings…' : 'Loading gallery…';
|
canvasReady && texturesPending > 0 ? 'Loading paintings…' : 'Loading gallery…';
|
||||||
|
|
||||||
const warmGpuEnabled = canvasReady && envReady && !shadersWarmed;
|
const warmGpuEnabled = canvasReady && !shadersWarmed;
|
||||||
|
|
||||||
const computedWindows = useMemo(() => {
|
const computedWindows = useMemo(() => {
|
||||||
if (!isMovement || !interiorStyle || !('hallIndex' in layout)) return undefined;
|
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 hasNextHall = isWingedHall && movementHalls.length > 1 && hallIndex < movementHalls.length - 1;
|
||||||
|
|
||||||
const halfW = layout.width / 2 - 0.55;
|
const { halfW: wallInnerHalfW, halfD: wallInnerHalfD } = useMemo(
|
||||||
const halfD = layout.depth / 2 - 0.35;
|
() => innerWallHalfExtents(layout.width, layout.depth),
|
||||||
const exitZ = layout.depth / 2 - 0.55;
|
[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 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(
|
const initialPos = useMemo(
|
||||||
() => new THREE.Vector3(0, EYE_HEIGHT, layout.depth / 2 - 2.2),
|
() => new THREE.Vector3(0, EYE_HEIGHT, layout.depth / 2 - 2.2),
|
||||||
[layout.depth]
|
[layout.depth]
|
||||||
@@ -2091,8 +2287,31 @@ export default function VirtualGallery(props: Props) {
|
|||||||
}
|
}
|
||||||
}, [isWingedHall, onBack, !isWingedHall ? props.data.artist.id : undefined]);
|
}, [isWingedHall, onBack, !isWingedHall ? props.data.artist.id : undefined]);
|
||||||
|
|
||||||
const backExitZ = isWingedHall ? -layout.depth / 2 + 0.55 : layout.depth / 2 - 0.55;
|
const backExitZ = isWingedHall && endWallHasDoor ? -playHalfD : exitZ;
|
||||||
const frontPassageZ = layout.depth / 2 - 0.55;
|
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(
|
const moveCamera = useCallback(
|
||||||
(forward: number, strafe: number, rotY: number) => {
|
(forward: number, strafe: number, rotY: number) => {
|
||||||
@@ -2100,43 +2319,49 @@ export default function VirtualGallery(props: Props) {
|
|||||||
const target = camTargetRef.current.clone();
|
const target = camTargetRef.current.clone();
|
||||||
const angle = Math.atan2(target.x - pos.x, target.z - pos.z);
|
const angle = Math.atan2(target.x - pos.x, target.z - pos.z);
|
||||||
|
|
||||||
|
// Turn in place: never move; only change look direction.
|
||||||
if (rotY !== 0) {
|
if (rotY !== 0) {
|
||||||
const newAngle = angle + rotY;
|
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.x = pos.x + Math.sin(newAngle) * dist;
|
||||||
target.z = pos.z + Math.cos(newAngle) * dist;
|
target.z = pos.z + Math.cos(newAngle) * dist;
|
||||||
} else {
|
levelHorizontalView(pos, target);
|
||||||
const newAngle = angle;
|
updateProximityFlags(pos);
|
||||||
pos.x += Math.sin(newAngle) * forward + Math.sin(newAngle + Math.PI / 2) * strafe;
|
setCamPos(pos);
|
||||||
pos.z += Math.cos(newAngle) * forward + Math.cos(newAngle + Math.PI / 2) * strafe;
|
setCamTarget(target);
|
||||||
target.x += Math.sin(newAngle) * forward + Math.sin(newAngle + Math.PI / 2) * strafe;
|
return;
|
||||||
target.z += Math.cos(newAngle) * forward + Math.cos(newAngle + Math.PI / 2) * strafe;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pos.x = Math.max(-halfW, Math.min(halfW, pos.x));
|
const dx = Math.sin(angle) * forward + Math.sin(angle + Math.PI / 2) * strafe;
|
||||||
target.x = Math.max(-halfW, Math.min(halfW, target.x));
|
const dz = Math.cos(angle) * forward + Math.cos(angle + Math.PI / 2) * strafe;
|
||||||
|
if (dx === 0 && dz === 0) {
|
||||||
if (isWingedHall) {
|
updateProximityFlags(pos);
|
||||||
pos.z = Math.max(-halfD, Math.min(halfD, pos.z));
|
return;
|
||||||
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 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);
|
levelHorizontalView(pos, target);
|
||||||
|
updateProximityFlags(pos);
|
||||||
setCamPos(pos);
|
setCamPos(pos);
|
||||||
setCamTarget(target);
|
setCamTarget(target);
|
||||||
},
|
},
|
||||||
[halfW, halfD, exitZ, isWingedHall, backExitZ, frontPassageZ, hasNextHall]
|
[resolvePlayerPosition, updateProximityFlags]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2205,7 +2430,10 @@ export default function VirtualGallery(props: Props) {
|
|||||||
|
|
||||||
const sceneBackground = interiorStyle?.background ?? '#0d0906';
|
const sceneBackground = interiorStyle?.background ?? '#0d0906';
|
||||||
const sceneFog = interiorStyle?.fog ?? '#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>) => {
|
const handleCanvasPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
if (!active || showExitNav || e.button !== 0) return;
|
if (!active || showExitNav || e.button !== 0) return;
|
||||||
@@ -2312,25 +2540,43 @@ export default function VirtualGallery(props: Props) {
|
|||||||
>
|
>
|
||||||
<FrameloopSync active={active} />
|
<FrameloopSync active={active} />
|
||||||
<color attach="background" args={[sceneBackground]} />
|
<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} />
|
<ambientLight intensity={ambientIntensity} />
|
||||||
|
<hemisphereLight
|
||||||
|
color={interiorStyle?.warmLight ?? '#fff8f0'}
|
||||||
|
groundColor={sceneFog}
|
||||||
|
intensity={0.55 * Math.PI * lightScale}
|
||||||
|
/>
|
||||||
<directionalLight
|
<directionalLight
|
||||||
position={interiorStyle ? [2, 10, -4] : [3, 9, 4]}
|
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'}
|
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
|
<SceneErrorBoundary
|
||||||
key={`env-${hallKey}-${glEpoch}`}
|
key={`env-${hallKey}-${glEpoch}`}
|
||||||
fallback={null}
|
fallback={null}
|
||||||
onError={handleEnvReady}
|
|
||||||
>
|
>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<EnvironmentGate onReady={handleEnvReady}>
|
<Environment
|
||||||
<Environment
|
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
|
||||||
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
|
environmentIntensity={(interiorStyle ? 0.55 : 0.25) * lightScale}
|
||||||
environmentIntensity={interiorStyle ? 0.35 : 0.15}
|
/>
|
||||||
/>
|
|
||||||
</EnvironmentGate>
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</SceneErrorBoundary>
|
</SceneErrorBoundary>
|
||||||
<GalleryTextureLoadContext.Provider value={textureLoad}>
|
<GalleryTextureLoadContext.Provider value={textureLoad}>
|
||||||
@@ -2362,6 +2608,7 @@ export default function VirtualGallery(props: Props) {
|
|||||||
navigation={navigation}
|
navigation={navigation}
|
||||||
loading={navLoading}
|
loading={navLoading}
|
||||||
onSelect={handleNavigate}
|
onSelect={handleNavigate}
|
||||||
|
onExitTimeline={onBack}
|
||||||
onClose={() => setShowExitNav(false)}
|
onClose={() => setShowExitNav(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -2398,8 +2645,12 @@ export default function VirtualGallery(props: Props) {
|
|||||||
{isWingedHall ? (
|
{isWingedHall ? (
|
||||||
<>
|
<>
|
||||||
<li>Date and artist labels appear below each frame</li>
|
<li>Date and artist labels appear below each frame</li>
|
||||||
<li>Works hang on left & right walls — up to ~55 per wing</li>
|
<li>Works hang on left, end, and right walls — up to ~55 per wing</li>
|
||||||
<li>Back door: wing navigator & exit to timeline</li>
|
<li>
|
||||||
|
{movementHalls.length > 1
|
||||||
|
? 'Back door: wing navigator & exit to timeline · Front: next wing'
|
||||||
|
: 'Entrance door: exit to timeline'}
|
||||||
|
</li>
|
||||||
{movementHalls.length > 1 && (
|
{movementHalls.length > 1 && (
|
||||||
<li>Front archway: walk to the next chronological wing</li>
|
<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>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>
|
</ul>
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
import { useCallback, useContext, useEffect, useMemo, useState, type ReactNode, createContext } from 'react';
|
||||||
import { getAuthMe, loginCurator, logoutCurator, type AuthRole } from '../api/client';
|
import {
|
||||||
|
getAuthMe,
|
||||||
|
loginCurator,
|
||||||
|
logoutCurator,
|
||||||
|
type AuthRole,
|
||||||
|
type StaffPermission,
|
||||||
|
} from '../api/client';
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
role: AuthRole;
|
role: AuthRole;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
permissions: StaffPermission[];
|
||||||
isCurator: boolean;
|
isCurator: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
can: (permission: StaffPermission) => boolean;
|
||||||
login: (username: string, password: string) => Promise<void>;
|
login: (username: string, password: string) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
@@ -13,15 +22,26 @@ interface AuthContextValue {
|
|||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
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 }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [role, setRole] = useState<AuthRole>('user');
|
const [role, setRole] = useState<AuthRole>('user');
|
||||||
const [username, setUsername] = useState<string | undefined>();
|
const [username, setUsername] = useState<string | undefined>();
|
||||||
|
const [permissions, setPermissions] = useState<StaffPermission[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
const me = await getAuthMe();
|
const me = await getAuthMe();
|
||||||
setRole(me.role);
|
applyAuthState(me, setRole, setUsername, setPermissions);
|
||||||
setUsername(me.username);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -30,27 +50,39 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const login = useCallback(async (user: string, password: string) => {
|
const login = useCallback(async (user: string, password: string) => {
|
||||||
const me = await loginCurator(user, password);
|
const me = await loginCurator(user, password);
|
||||||
setRole(me.role);
|
applyAuthState(me, setRole, setUsername, setPermissions);
|
||||||
setUsername(me.username);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
await logoutCurator();
|
await logoutCurator();
|
||||||
setRole('user');
|
setRole('user');
|
||||||
setUsername(undefined);
|
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(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
role,
|
role,
|
||||||
username,
|
username,
|
||||||
isCurator: role === 'curator',
|
permissions,
|
||||||
|
isCurator: role === 'admin' || role === 'curator',
|
||||||
|
isAdmin: role === 'admin',
|
||||||
loading,
|
loading,
|
||||||
|
can,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
refresh,
|
refresh,
|
||||||
}),
|
}),
|
||||||
[role, username, loading, login, logout, refresh]
|
[role, username, permissions, loading, can, login, logout, refresh]
|
||||||
);
|
);
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ export interface MovementInteriorStyle {
|
|||||||
doorWood: [string, string, string];
|
doorWood: [string, string, string];
|
||||||
windows: GalleryWindowSpec[];
|
windows: GalleryWindowSpec[];
|
||||||
trackLights: number;
|
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[] {
|
function windows(...specs: GalleryWindowSpec[]): GalleryWindowSpec[] {
|
||||||
@@ -85,6 +87,7 @@ function mk(
|
|||||||
doorWood: opts.doorWood ?? ['#3d2818', '#4e3624', '#261a10'],
|
doorWood: opts.doorWood ?? ['#3d2818', '#4e3624', '#261a10'],
|
||||||
windows: opts.windows,
|
windows: opts.windows,
|
||||||
trackLights: opts.trackLights ?? 0.8,
|
trackLights: opts.trackLights ?? 0.8,
|
||||||
|
lightScale: opts.lightScale ?? 1,
|
||||||
details: opts.details,
|
details: opts.details,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -114,25 +117,29 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
),
|
),
|
||||||
28: mk(
|
28: mk(
|
||||||
'byzantine-sanctuary',
|
'byzantine-sanctuary',
|
||||||
'Byzantine chapel',
|
'Byzantine basilica',
|
||||||
'Gold mosaic walls · amber light through arched windows',
|
'Marble revetment · coffered timber ceiling · Cosmatesque stone floor',
|
||||||
{ wall: 'mosaic-byzantine', ceiling: 'gilded-stucco', floor: 'marble-checker' },
|
{ wall: 'marble-revetment', ceiling: 'coffered-wood', floor: 'marble-opus-sectile' },
|
||||||
{ wall: '#d4af37', ceiling: '#c8a840', floor: '#ddd8cc', trim: '#b8860b' },
|
{ wall: '#a89880', ceiling: '#6a4526', floor: '#a09079', trim: '#8a6440' },
|
||||||
{
|
{
|
||||||
details: 'medieval',
|
details: 'byzantine',
|
||||||
titleColor: '#f0e8c0',
|
titleColor: '#e8d8b0',
|
||||||
ambient: 0.48,
|
ambient: 0.44,
|
||||||
warmLight: '#ffd898',
|
warmLight: '#ffdca8',
|
||||||
sunLight: '#ffe8b0',
|
sunLight: '#ffdca0',
|
||||||
fog: '#0a0806',
|
fog: '#100b07',
|
||||||
background: '#060504',
|
background: '#0a0705',
|
||||||
windows: windows(
|
windows: windows(
|
||||||
{ wall: 'back', x: 0, y: 2.8, width: 1.0, height: 2.2, style: 'roman-arch', lightColor: '#ffb860', lightIntensity: 2.4 },
|
{ wall: 'back', x: 0, y: 3.1, width: 0.9, height: 1.9, style: 'roman-arch', lightColor: '#ffd28a', lightIntensity: 3.4 },
|
||||||
{ wall: 'left', x: -1, y: 2.6, width: 0.7, height: 1.8, style: 'roman-arch', lightColor: '#ffb860', lightIntensity: 1.8 },
|
{ 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: 1, y: 2.6, width: 0.7, height: 1.8, style: 'roman-arch', lightColor: '#ffb860', lightIntensity: 1.8 }
|
{ 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,
|
trackLights: 0.35,
|
||||||
doorWood: ['#3a3020', '#4a3830', '#2a2018'],
|
// Basilica interiors are lit by shafts from high windows, not evenly flooded.
|
||||||
|
lightScale: 0.3,
|
||||||
|
doorWood: ['#3a2416', '#4c3220', '#241608'],
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
29: mk(
|
29: mk(
|
||||||
@@ -144,17 +151,19 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
{
|
{
|
||||||
details: 'medieval',
|
details: 'medieval',
|
||||||
titleColor: '#e8dcc8',
|
titleColor: '#e8dcc8',
|
||||||
ambient: 0.52,
|
ambient: 0.72,
|
||||||
warmLight: '#e8d8c0',
|
warmLight: '#e8d8c0',
|
||||||
sunLight: '#d0e8ff',
|
sunLight: '#d0e8ff',
|
||||||
fog: '#0c0c10',
|
// Keep atmosphere dark, but not pure void (walls must stay readable).
|
||||||
|
fog: '#1a1816',
|
||||||
|
background: '#141210',
|
||||||
windows: windows(
|
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: '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: '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 }
|
{ 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(
|
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. */
|
/** Fallback name-based resolver for movements not in the catalog. */
|
||||||
function fallbackByName(movement: ArtMovement & { era_name?: string }): MovementInteriorStyle {
|
function fallbackByName(movement: ArtMovement & { era_name?: string }): MovementInteriorStyle {
|
||||||
const n = movement.name.toLowerCase();
|
const n = movement.name.toLowerCase();
|
||||||
const era = (movement.era_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('renaissance')) return BY_MOVEMENT_ID[31];
|
||||||
if (n.includes('baroque') || n.includes('rococo')) return BY_MOVEMENT_ID[34];
|
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 (n.includes('impression')) return BY_MOVEMENT_ID[39];
|
||||||
if (era.includes('modern') || era.includes('contemporary')) return BY_MOVEMENT_ID[52];
|
if (era.includes('modern') || era.includes('contemporary')) return BY_MOVEMENT_ID[52];
|
||||||
return BY_MOVEMENT_ID[36];
|
return BY_MOVEMENT_ID[36];
|
||||||
@@ -593,7 +637,8 @@ function fallbackByName(movement: ArtMovement & { era_name?: string }): Movement
|
|||||||
export function resolveMovementInteriorStyle(
|
export function resolveMovementInteriorStyle(
|
||||||
movement: ArtMovement & { era_name?: string }
|
movement: ArtMovement & { era_name?: string }
|
||||||
): MovementInteriorStyle {
|
): 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);
|
return blendAccent(base, movement.color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ export function useTexturedMaterial(
|
|||||||
color: tint,
|
color: tint,
|
||||||
roughness: surf.roughness,
|
roughness: surf.roughness,
|
||||||
metalness: surf.metalness,
|
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]);
|
}, [kind, tint, spanW, spanH]);
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import enDebug from '../locales/en/debug.json';
|
|||||||
import enTranslations from '../locales/en/translations.json';
|
import enTranslations from '../locales/en/translations.json';
|
||||||
import enInfluences from '../locales/en/influences.json';
|
import enInfluences from '../locales/en/influences.json';
|
||||||
import enTours from '../locales/en/tours.json';
|
import enTours from '../locales/en/tours.json';
|
||||||
|
import enUsers from '../locales/en/users.json';
|
||||||
|
|
||||||
import ruCommon from '../locales/ru/common.json';
|
import ruCommon from '../locales/ru/common.json';
|
||||||
import ruHome from '../locales/ru/home.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 ruTranslations from '../locales/ru/translations.json';
|
||||||
import ruInfluences from '../locales/ru/influences.json';
|
import ruInfluences from '../locales/ru/influences.json';
|
||||||
import ruTours from '../locales/ru/tours.json';
|
import ruTours from '../locales/ru/tours.json';
|
||||||
|
import ruUsers from '../locales/ru/users.json';
|
||||||
|
|
||||||
const initialLocale = readStoredLocale();
|
const initialLocale = readStoredLocale();
|
||||||
writeStoredLocale(initialLocale);
|
writeStoredLocale(initialLocale);
|
||||||
@@ -33,7 +35,7 @@ void i18n.use(initReactI18next).init({
|
|||||||
lng: initialLocale,
|
lng: initialLocale,
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
supportedLngs: ['en', 'ru'],
|
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',
|
defaultNS: 'common',
|
||||||
resources: {
|
resources: {
|
||||||
en: {
|
en: {
|
||||||
@@ -48,6 +50,7 @@ void i18n.use(initReactI18next).init({
|
|||||||
translations: enTranslations,
|
translations: enTranslations,
|
||||||
influences: enInfluences,
|
influences: enInfluences,
|
||||||
tours: enTours,
|
tours: enTours,
|
||||||
|
users: enUsers,
|
||||||
},
|
},
|
||||||
ru: {
|
ru: {
|
||||||
common: ruCommon,
|
common: ruCommon,
|
||||||
@@ -61,6 +64,7 @@ void i18n.use(initReactI18next).init({
|
|||||||
translations: ruTranslations,
|
translations: ruTranslations,
|
||||||
influences: ruInfluences,
|
influences: ruInfluences,
|
||||||
tours: ruTours,
|
tours: ruTours,
|
||||||
|
users: ruUsers,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
interpolation: { escapeValue: false },
|
interpolation: { escapeValue: false },
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"influences": "Influences",
|
"influences": "Influences",
|
||||||
"tours": "Tours",
|
"tours": "Tours",
|
||||||
"toursEditor": "Tour editor",
|
"toursEditor": "Tour editor",
|
||||||
|
"users": "Users",
|
||||||
"openingTourGallery": "Opening guided tour…",
|
"openingTourGallery": "Opening guided tour…",
|
||||||
"tourEmpty": "This tour has no paintings yet.",
|
"tourEmpty": "This tour has no paintings yet.",
|
||||||
"tourLoadFailed": "Failed to load the tour.",
|
"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": "Влияния",
|
"influences": "Влияния",
|
||||||
"tours": "Экскурсии",
|
"tours": "Экскурсии",
|
||||||
"toursEditor": "Редактор экскурсий",
|
"toursEditor": "Редактор экскурсий",
|
||||||
|
"users": "Пользователи",
|
||||||
"openingTourGallery": "Открытие экскурсии…",
|
"openingTourGallery": "Открытие экскурсии…",
|
||||||
"tourEmpty": "В этой экскурсии пока нет картин.",
|
"tourEmpty": "В этой экскурсии пока нет картин.",
|
||||||
"tourLoadFailed": "Не удалось загрузить экскурсию.",
|
"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 TranslationsPage from '../pages/TranslationsPage';
|
||||||
import InfluencesPage from '../pages/InfluencesPage';
|
import InfluencesPage from '../pages/InfluencesPage';
|
||||||
import ToursPage from '../pages/ToursPage';
|
import ToursPage from '../pages/ToursPage';
|
||||||
|
import UsersPage from '../pages/UsersPage';
|
||||||
import CuratorLoginModal from '../components/CuratorLoginModal';
|
import CuratorLoginModal from '../components/CuratorLoginModal';
|
||||||
|
import ArtistFilterModal from '../components/ArtistFilterModal';
|
||||||
import ToursPopup from '../components/ToursPopup';
|
import ToursPopup from '../components/ToursPopup';
|
||||||
import CatalogSearchBar from '../components/CatalogSearchBar';
|
import CatalogSearchBar from '../components/CatalogSearchBar';
|
||||||
import LocaleSwitcher from '../components/LocaleSwitcher';
|
import LocaleSwitcher from '../components/LocaleSwitcher';
|
||||||
import GalleryLoadingMarker from '../components/GalleryLoadingMarker';
|
import GalleryLoadingMarker from '../components/GalleryLoadingMarker';
|
||||||
import '../components/CatalogSearchBar.css';
|
import '../components/CatalogSearchBar.css';
|
||||||
import '../components/CuratorLoginModal.css';
|
import '../components/CuratorLoginModal.css';
|
||||||
|
import '../components/ArtistFilterModal.css';
|
||||||
import '../components/ToursPopup.css';
|
import '../components/ToursPopup.css';
|
||||||
import '../components/LocaleSwitcher.css';
|
import '../components/LocaleSwitcher.css';
|
||||||
import '../pages/TranslationsPage.css';
|
import '../pages/TranslationsPage.css';
|
||||||
import '../pages/InfluencesPage.css';
|
import '../pages/InfluencesPage.css';
|
||||||
import '../pages/ToursPage.css';
|
import '../pages/ToursPage.css';
|
||||||
|
import '../pages/UsersPage.css';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
||||||
import type {
|
import type {
|
||||||
@@ -32,6 +36,7 @@ import type {
|
|||||||
PaintingDetail,
|
PaintingDetail,
|
||||||
MovementGalleryDetail,
|
MovementGalleryDetail,
|
||||||
TourGalleryDetail,
|
TourGalleryDetail,
|
||||||
|
ArtistSummary,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { createViewChangeScheduler } from '../utils/timelineView';
|
import { createViewChangeScheduler } from '../utils/timelineView';
|
||||||
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
|
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
|
||||||
@@ -44,6 +49,7 @@ type View =
|
|||||||
| { type: 'translations' }
|
| { type: 'translations' }
|
||||||
| { type: 'influences' }
|
| { type: 'influences' }
|
||||||
| { type: 'tours' }
|
| { type: 'tours' }
|
||||||
|
| { type: 'users' }
|
||||||
| { type: 'gallery'; artistId: number; data: ArtistDetail }
|
| { type: 'gallery'; artistId: number; data: ArtistDetail }
|
||||||
| { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail }
|
| { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail }
|
||||||
| { type: 'tour-gallery'; tourId: number; data: TourGalleryDetail }
|
| { type: 'tour-gallery'; tourId: number; data: TourGalleryDetail }
|
||||||
@@ -55,7 +61,7 @@ type GallerySession =
|
|||||||
| { kind: 'movement'; movementId: number; data: MovementGalleryDetail }
|
| { kind: 'movement'; movementId: number; data: MovementGalleryDetail }
|
||||||
| { kind: 'tour'; tourId: number; data: TourGalleryDetail };
|
| { kind: 'tour'; tourId: number; data: TourGalleryDetail };
|
||||||
|
|
||||||
type CuratorLoginRedirect = 'checkup' | 'translations' | 'influences' | 'tours' | null;
|
type CuratorLoginRedirect = 'checkup' | 'translations' | 'influences' | 'tours' | 'users' | null;
|
||||||
|
|
||||||
function patchPaintingInMovementDetail(
|
function patchPaintingInMovementDetail(
|
||||||
detail: MovementGalleryDetail,
|
detail: MovementGalleryDetail,
|
||||||
@@ -147,7 +153,14 @@ function catalogNavigateTarget(
|
|||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const { t } = useTranslation('home');
|
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 [view, setView] = useState<View>({ type: 'timeline' });
|
||||||
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
|
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
|
||||||
const [bounds, setBounds] = useState({ min: -800, max: 2025 });
|
const [bounds, setBounds] = useState({ min: -800, max: 2025 });
|
||||||
@@ -168,7 +181,12 @@ export default function HomePage() {
|
|||||||
const [loginOpen, setLoginOpen] = useState(false);
|
const [loginOpen, setLoginOpen] = useState(false);
|
||||||
const [loginRedirect, setLoginRedirect] = useState<CuratorLoginRedirect>(null);
|
const [loginRedirect, setLoginRedirect] = useState<CuratorLoginRedirect>(null);
|
||||||
const [toursPopupOpen, setToursPopupOpen] = useState(false);
|
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 [galleryRevision, setGalleryRevision] = useState(0);
|
||||||
const viewRef = useRef(view);
|
const viewRef = useRef(view);
|
||||||
viewRef.current = view;
|
viewRef.current = view;
|
||||||
@@ -279,6 +297,8 @@ export default function HomePage() {
|
|||||||
setView({ type: 'influences' });
|
setView({ type: 'influences' });
|
||||||
} else if (loginRedirect === 'tours') {
|
} else if (loginRedirect === 'tours') {
|
||||||
setView({ type: 'tours' });
|
setView({ type: 'tours' });
|
||||||
|
} else if (loginRedirect === 'users') {
|
||||||
|
setView({ type: 'users' });
|
||||||
}
|
}
|
||||||
setLoginRedirect(null);
|
setLoginRedirect(null);
|
||||||
};
|
};
|
||||||
@@ -291,14 +311,15 @@ export default function HomePage() {
|
|||||||
view.type === 'checkup' ||
|
view.type === 'checkup' ||
|
||||||
view.type === 'translations' ||
|
view.type === 'translations' ||
|
||||||
view.type === 'influences' ||
|
view.type === 'influences' ||
|
||||||
view.type === 'tours'
|
view.type === 'tours' ||
|
||||||
|
view.type === 'users'
|
||||||
) {
|
) {
|
||||||
goToTimelineHome();
|
goToTimelineHome();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openCheckup = () => {
|
const openCheckup = () => {
|
||||||
if (!isCurator) {
|
if (!canCheckup) {
|
||||||
openCuratorLogin('checkup');
|
openCuratorLogin('checkup');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -306,7 +327,7 @@ export default function HomePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openTranslations = () => {
|
const openTranslations = () => {
|
||||||
if (!isCurator) {
|
if (!canTranslations) {
|
||||||
openCuratorLogin('translations');
|
openCuratorLogin('translations');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -314,7 +335,7 @@ export default function HomePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openInfluences = () => {
|
const openInfluences = () => {
|
||||||
if (!isCurator) {
|
if (!canInfluences) {
|
||||||
openCuratorLogin('influences');
|
openCuratorLogin('influences');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -322,13 +343,21 @@ export default function HomePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openToursEditor = () => {
|
const openToursEditor = () => {
|
||||||
if (!isCurator) {
|
if (!canTours) {
|
||||||
openCuratorLogin('tours');
|
openCuratorLogin('tours');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setView({ type: 'tours' });
|
setView({ type: 'tours' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openUsers = () => {
|
||||||
|
if (!canUsers) {
|
||||||
|
openCuratorLogin('users');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setView({ type: 'users' });
|
||||||
|
};
|
||||||
|
|
||||||
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
|
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
|
||||||
const data = await api.getPainting(paintingId);
|
const data = await api.getPainting(paintingId);
|
||||||
const patch: Partial<Painting> = {
|
const patch: Partial<Painting> = {
|
||||||
@@ -607,7 +636,7 @@ export default function HomePage() {
|
|||||||
const handleArtistClick = async (artistId: number) => {
|
const handleArtistClick = async (artistId: number) => {
|
||||||
setGalleryEntryLoading('Opening artist gallery…');
|
setGalleryEntryLoading('Opening artist gallery…');
|
||||||
try {
|
try {
|
||||||
await api.preloadArtistImages(artistId).catch(() => undefined);
|
void api.preloadArtistImages(artistId).catch(() => undefined);
|
||||||
const data = await api.getArtist(artistId);
|
const data = await api.getArtist(artistId);
|
||||||
openArtistGallery(artistId, data);
|
openArtistGallery(artistId, data);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -618,10 +647,46 @@ export default function HomePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMovementClick = async (movementId: number) => {
|
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…');
|
setGalleryEntryLoading('Opening movement gallery…');
|
||||||
try {
|
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);
|
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 {
|
} catch {
|
||||||
setError('Failed to load movement gallery.');
|
setError('Failed to load movement gallery.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -952,9 +1017,10 @@ export default function HomePage() {
|
|||||||
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo: view });
|
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo: view });
|
||||||
}}
|
}}
|
||||||
onInfluenceArtistClick={handleArtistClick}
|
onInfluenceArtistClick={handleArtistClick}
|
||||||
isCurator={isCurator}
|
isCurator={canNotes}
|
||||||
|
canCheckup={canCheckup}
|
||||||
debugMode={effectiveDebugMode}
|
debugMode={effectiveDebugMode}
|
||||||
debugShowMore={debugShowMore && isCurator}
|
debugShowMore={debugShowMore && canImages}
|
||||||
onPaintingImageFixed={handlePaintingImageFixed}
|
onPaintingImageFixed={handlePaintingImageFixed}
|
||||||
onPaintingCheckupFlagsUpdated={handlePaintingCheckupFlagsUpdated}
|
onPaintingCheckupFlagsUpdated={handlePaintingCheckupFlagsUpdated}
|
||||||
onPaintingRemoved={handlePaintingRemoved}
|
onPaintingRemoved={handlePaintingRemoved}
|
||||||
@@ -968,7 +1034,8 @@ export default function HomePage() {
|
|||||||
<ArtistBio
|
<ArtistBio
|
||||||
artist={view.data.artist}
|
artist={view.data.artist}
|
||||||
debugMode={effectiveDebugMode}
|
debugMode={effectiveDebugMode}
|
||||||
debugShowMore={debugShowMore && isCurator}
|
debugShowMore={debugShowMore && canImages}
|
||||||
|
canCheckup={canCheckup}
|
||||||
portraitRevision={portraitRevisions[view.data.artist.id]}
|
portraitRevision={portraitRevisions[view.data.artist.id]}
|
||||||
onBack={() => setView(view.returnTo)}
|
onBack={() => setView(view.returnTo)}
|
||||||
onEnterGallery={() =>
|
onEnterGallery={() =>
|
||||||
@@ -981,7 +1048,7 @@ export default function HomePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{view.type === 'influences' && (
|
{view.type === 'influences' && (
|
||||||
isCurator ? (
|
canInfluences ? (
|
||||||
<InfluencesPage onBack={goToTimelineHome} />
|
<InfluencesPage onBack={goToTimelineHome} />
|
||||||
) : (
|
) : (
|
||||||
<div className="curator-login-gate">
|
<div className="curator-login-gate">
|
||||||
@@ -1000,7 +1067,7 @@ export default function HomePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{view.type === 'tours' && (
|
{view.type === 'tours' && (
|
||||||
isCurator ? (
|
canTours ? (
|
||||||
<ToursPage onBack={goToTimelineHome} />
|
<ToursPage onBack={goToTimelineHome} />
|
||||||
) : (
|
) : (
|
||||||
<div className="curator-login-gate">
|
<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' && (
|
{view.type === 'translations' && (
|
||||||
isCurator ? (
|
canTranslations ? (
|
||||||
<TranslationsPage onBack={goToTimelineHome} />
|
<TranslationsPage onBack={goToTimelineHome} />
|
||||||
) : (
|
) : (
|
||||||
<div className="curator-login-gate">
|
<div className="curator-login-gate">
|
||||||
@@ -1038,21 +1124,21 @@ export default function HomePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{view.type === 'checkup' && (
|
{view.type === 'checkup' && (
|
||||||
isCurator ? (
|
canCheckup ? (
|
||||||
<CheckupPage
|
<CheckupPage
|
||||||
onBack={goToTimelineHome}
|
onBack={goToTimelineHome}
|
||||||
onOpenPainting={handlePaintingClick}
|
onOpenPainting={handlePaintingClick}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="curator-login-gate">
|
<div className="curator-login-gate">
|
||||||
<h2>Curator access required</h2>
|
<h2>{t('curatorRequiredTitle')}</h2>
|
||||||
<p>The painting checkup table is available to logged-in curators only.</p>
|
<p>{t('curatorRequiredBody')}</p>
|
||||||
<div className="curator-login-gate-actions">
|
<div className="curator-login-gate-actions">
|
||||||
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('checkup')}>
|
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('checkup')}>
|
||||||
Curator login
|
{t('curatorLogin')}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
|
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
|
||||||
Back to gallery
|
{t('backToGalleryBtn')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1074,6 +1160,17 @@ export default function HomePage() {
|
|||||||
onSelectTour={(tourId) => void handleSelectPublishedTour(tourId)}
|
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' && (
|
{view.type === 'timeline' && (
|
||||||
<div className="home-page">
|
<div className="home-page">
|
||||||
<header className="site-header">
|
<header className="site-header">
|
||||||
@@ -1083,57 +1180,79 @@ export default function HomePage() {
|
|||||||
<span className="curator-session-label" title={`Signed in as ${username}`}>
|
<span className="curator-session-label" title={`Signed in as ${username}`}>
|
||||||
{username}
|
{username}
|
||||||
</span>
|
</span>
|
||||||
<button
|
{canImages && (
|
||||||
type="button"
|
<>
|
||||||
className={`debug-mode-toggle${debugMode ? ' debug-mode-toggle-active' : ''}`}
|
<button
|
||||||
onClick={toggleDebugMode}
|
type="button"
|
||||||
title="Toggle developer image audit mode on painting details and artist bios"
|
className={`debug-mode-toggle${debugMode ? ' debug-mode-toggle-active' : ''}`}
|
||||||
>
|
onClick={toggleDebugMode}
|
||||||
Debug mode{debugMode ? ': ON' : ''}
|
title="Toggle developer image audit mode on painting details and artist bios"
|
||||||
</button>
|
>
|
||||||
<label
|
Debug mode{debugMode ? ': ON' : ''}
|
||||||
className={`debug-show-more-toggle${debugShowMore ? ' debug-show-more-toggle-active' : ''}${!debugMode ? ' debug-show-more-toggle-muted' : ''}`}
|
</button>
|
||||||
title="When debug mode is on, open the More search results popup automatically on each painting or artist page"
|
<label
|
||||||
>
|
className={`debug-show-more-toggle${debugShowMore ? ' debug-show-more-toggle-active' : ''}${!debugMode ? ' debug-show-more-toggle-muted' : ''}`}
|
||||||
<input
|
title="When debug mode is on, open the More search results popup automatically on each painting or artist page"
|
||||||
type="checkbox"
|
>
|
||||||
checked={debugShowMore}
|
<input
|
||||||
onChange={(e) => setDebugShowMoreEnabled(e.target.checked)}
|
type="checkbox"
|
||||||
/>
|
checked={debugShowMore}
|
||||||
Show more
|
onChange={(e) => setDebugShowMoreEnabled(e.target.checked)}
|
||||||
</label>
|
/>
|
||||||
<button
|
Show more
|
||||||
type="button"
|
</label>
|
||||||
className="checkup-link-btn"
|
</>
|
||||||
onClick={openInfluences}
|
)}
|
||||||
title="Manage influence links"
|
{canInfluences && (
|
||||||
>
|
<button
|
||||||
{t('influences')}
|
type="button"
|
||||||
</button>
|
className="checkup-link-btn"
|
||||||
<button
|
onClick={openInfluences}
|
||||||
type="button"
|
title="Manage influence links"
|
||||||
className="checkup-link-btn"
|
>
|
||||||
onClick={openToursEditor}
|
{t('influences')}
|
||||||
title="Create and edit guided tours"
|
</button>
|
||||||
>
|
)}
|
||||||
{t('toursEditor')}
|
{canTours && (
|
||||||
</button>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
className="checkup-link-btn"
|
||||||
className="checkup-link-btn"
|
onClick={openToursEditor}
|
||||||
onClick={openTranslations}
|
title="Create and edit guided tours"
|
||||||
title="Review and publish Russian translations"
|
>
|
||||||
>
|
{t('toursEditor')}
|
||||||
{t('translations')}
|
</button>
|
||||||
</button>
|
)}
|
||||||
<button
|
{canTranslations && (
|
||||||
type="button"
|
<button
|
||||||
className="checkup-link-btn"
|
type="button"
|
||||||
onClick={openCheckup}
|
className="checkup-link-btn"
|
||||||
title="Open painting image checkup table"
|
onClick={openTranslations}
|
||||||
>
|
title="Review and publish Russian translations"
|
||||||
{t('checkup')}
|
>
|
||||||
</button>
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="curator-logout-btn"
|
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;
|
era_name?: string;
|
||||||
description: string;
|
description: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
/** Influence edges on paintings by artists in this movement (timeline band weight). */
|
||||||
|
influence_link_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Artist {
|
export interface Artist {
|
||||||
@@ -134,6 +136,19 @@ export interface MovementGalleryDetail {
|
|||||||
paintings: Painting[];
|
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 {
|
export interface TourSummary {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export type SurfaceTextureKind =
|
|||||||
| 'marble-veined-carrara'
|
| 'marble-veined-carrara'
|
||||||
| 'marble-veined-emerald'
|
| 'marble-veined-emerald'
|
||||||
| 'marble-checker'
|
| 'marble-checker'
|
||||||
|
| 'marble-opus-sectile'
|
||||||
|
| 'marble-revetment'
|
||||||
|
| 'coffered-wood'
|
||||||
| 'limestone'
|
| 'limestone'
|
||||||
| 'sandstone'
|
| 'sandstone'
|
||||||
| 'rough-stone'
|
| 'rough-stone'
|
||||||
@@ -244,6 +247,43 @@ function marbleVeined(ctx: CanvasRenderingContext2D, size: number, base: string,
|
|||||||
noiseOverlay(ctx, size, 0.035, seed + 1);
|
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(
|
function woodPanelSurface(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
size: number,
|
size: number,
|
||||||
@@ -346,6 +386,161 @@ function paintSurface(kind: SurfaceTextureKind, size: number): ImageData {
|
|||||||
noiseOverlay(ctx, size, 0.03, 103);
|
noiseOverlay(ctx, size, 0.03, 103);
|
||||||
break;
|
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':
|
case 'limestone':
|
||||||
fill(ctx, size, '#ddd4c4');
|
fill(ctx, size, '#ddd4c4');
|
||||||
for (let y = 0; y < size; y += 4) {
|
for (let y = 0; y < size; y += 4) {
|
||||||
@@ -460,15 +655,59 @@ function paintSurface(kind: SurfaceTextureKind, size: number): ImageData {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'mosaic-byzantine': {
|
case 'mosaic-byzantine': {
|
||||||
fill(ctx, size, '#1a1814');
|
// Gold-ground tessera field (gold-leaf glass, individually jittered) with
|
||||||
const cell = size / 32;
|
// sparse inlaid accent tesserae and a meander border course — Hagia Sophia /
|
||||||
const colors = ['#d4af37', '#8a3020', '#2060a0', '#f0ece0', '#408040'];
|
// Ravenna style, not a flat multicolor checker.
|
||||||
for (let y = 0; y < size; y += cell) {
|
fill(ctx, size, '#1a1408');
|
||||||
for (let x = 0; x < size; x += cell) {
|
const grid = 32;
|
||||||
ctx.fillStyle = colors[(x / cell + y / cell) % colors.length];
|
const cell = size / grid;
|
||||||
ctx.fillRect(x + 1, y + 1, cell - 2, cell - 2);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case 'mosaic-roman': {
|
case 'mosaic-roman': {
|
||||||
@@ -583,7 +822,7 @@ function imageDataToNormalMap(data: ImageData, size: number, strength = 2.5): Im
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageDataToTexture(data: ImageData): THREE.CanvasTexture {
|
function imageDataToTexture(data: ImageData, colorSpace: THREE.ColorSpace): THREE.CanvasTexture {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = data.width;
|
canvas.width = data.width;
|
||||||
canvas.height = data.height;
|
canvas.height = data.height;
|
||||||
@@ -591,8 +830,8 @@ function imageDataToTexture(data: ImageData): THREE.CanvasTexture {
|
|||||||
const tex = new THREE.CanvasTexture(canvas);
|
const tex = new THREE.CanvasTexture(canvas);
|
||||||
tex.wrapS = THREE.RepeatWrapping;
|
tex.wrapS = THREE.RepeatWrapping;
|
||||||
tex.wrapT = THREE.RepeatWrapping;
|
tex.wrapT = THREE.RepeatWrapping;
|
||||||
tex.colorSpace = THREE.SRGBColorSpace;
|
tex.colorSpace = colorSpace;
|
||||||
tex.anisotropy = 16;
|
tex.anisotropy = 8;
|
||||||
return tex;
|
return tex;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,6 +850,9 @@ const ROUGHNESS: Partial<Record<SurfaceTextureKind, number>> = {
|
|||||||
'marble-veined-carrara': 0.16,
|
'marble-veined-carrara': 0.16,
|
||||||
'marble-veined-emerald': 0.18,
|
'marble-veined-emerald': 0.18,
|
||||||
'marble-checker': 0.14,
|
'marble-checker': 0.14,
|
||||||
|
'marble-opus-sectile': 0.2,
|
||||||
|
'marble-revetment': 0.34,
|
||||||
|
'coffered-wood': 0.72,
|
||||||
'gilded-stucco': 0.22,
|
'gilded-stucco': 0.22,
|
||||||
'velvet-crimson': 0.92,
|
'velvet-crimson': 0.92,
|
||||||
'velvet-navy': 0.92,
|
'velvet-navy': 0.92,
|
||||||
@@ -638,6 +880,8 @@ const ROUGHNESS: Partial<Record<SurfaceTextureKind, number>> = {
|
|||||||
const METALNESS: Partial<Record<SurfaceTextureKind, number>> = {
|
const METALNESS: Partial<Record<SurfaceTextureKind, number>> = {
|
||||||
'marble-white': 0.08,
|
'marble-white': 0.08,
|
||||||
'marble-veined-carrara': 0.1,
|
'marble-veined-carrara': 0.1,
|
||||||
|
'marble-opus-sectile': 0.14,
|
||||||
|
'marble-revetment': 0.1,
|
||||||
'gilded-stucco': 0.65,
|
'gilded-stucco': 0.65,
|
||||||
'concrete-polished': 0.12,
|
'concrete-polished': 0.12,
|
||||||
'terrazzo': 0.15,
|
'terrazzo': 0.15,
|
||||||
@@ -647,6 +891,9 @@ const METALNESS: Partial<Record<SurfaceTextureKind, number>> = {
|
|||||||
|
|
||||||
const METERS: Partial<Record<SurfaceTextureKind, number>> = {
|
const METERS: Partial<Record<SurfaceTextureKind, number>> = {
|
||||||
'marble-checker': 2.4,
|
'marble-checker': 2.4,
|
||||||
|
'marble-opus-sectile': 5.0,
|
||||||
|
'marble-revetment': 4.4,
|
||||||
|
'coffered-wood': 4.2,
|
||||||
flagstone: 3.0,
|
flagstone: 3.0,
|
||||||
'mosaic-byzantine': 1.8,
|
'mosaic-byzantine': 1.8,
|
||||||
'mosaic-roman': 2.0,
|
'mosaic-roman': 2.0,
|
||||||
@@ -669,6 +916,9 @@ const NORMAL_SCALE: Partial<Record<SurfaceTextureKind, number>> = {
|
|||||||
'painted-lime': 0.62,
|
'painted-lime': 0.62,
|
||||||
'marble-white': 0.75,
|
'marble-white': 0.75,
|
||||||
'marble-veined-carrara': 0.8,
|
'marble-veined-carrara': 0.8,
|
||||||
|
'marble-opus-sectile': 0.55,
|
||||||
|
'marble-revetment': 0.6,
|
||||||
|
'coffered-wood': 1.0,
|
||||||
'velvet-crimson': 0.5,
|
'velvet-crimson': 0.5,
|
||||||
'velvet-navy': 0.5,
|
'velvet-navy': 0.5,
|
||||||
'velvet-emerald': 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 normalData = imageDataToNormalMap(colorData, TEXTURE_SIZE, normalStrengthFor(kind));
|
||||||
|
|
||||||
const set: SurfaceTextureSet = {
|
const set: SurfaceTextureSet = {
|
||||||
map: imageDataToTexture(colorData),
|
map: imageDataToTexture(colorData, THREE.SRGBColorSpace),
|
||||||
normalMap: imageDataToTexture(normalData),
|
// Normal maps must stay linear — sRGB encoding breaks lighting on walls.
|
||||||
|
normalMap: imageDataToTexture(normalData, THREE.NoColorSpace),
|
||||||
roughness: ROUGHNESS[kind] ?? 0.75,
|
roughness: ROUGHNESS[kind] ?? 0.75,
|
||||||
metalness: METALNESS[kind] ?? 0.04,
|
metalness: METALNESS[kind] ?? 0.04,
|
||||||
metersPerRepeat: METERS[kind] ?? 2.8,
|
metersPerRepeat: METERS[kind] ?? 2.8,
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export interface MovementHallLayout {
|
|||||||
segments: WallSegment[];
|
segments: WallSegment[];
|
||||||
paintingCount: number;
|
paintingCount: number;
|
||||||
yearLabel: string;
|
yearLabel: string;
|
||||||
|
/** When true, far (−Z) wall has a center exit; paintings hang on flanks only. */
|
||||||
|
endWallHasDoor: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WALL_HEIGHT = 4.2;
|
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(
|
function layoutBackSlots(
|
||||||
paintings: Painting[],
|
paintings: Painting[],
|
||||||
width: number,
|
width: number,
|
||||||
halfD: number,
|
halfD: number,
|
||||||
inset: number
|
inset: number,
|
||||||
|
hasDoor: boolean
|
||||||
): FrameSlot[] {
|
): FrameSlot[] {
|
||||||
if (paintings.length === 0) return [];
|
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 y = EYE_HEIGHT;
|
||||||
const z = -halfD + inset + WALL_STANDOFF;
|
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 (!hasDoor) {
|
||||||
if (group.length === 0) return [];
|
const { slots: rowSlots } = layoutRow(paintings, width);
|
||||||
const { slots: rowSlots } = layoutRow(group, flankSpan);
|
|
||||||
return rowSlots.map((s) => ({
|
return rowSlots.map((s) => ({
|
||||||
maxW: s.maxW,
|
maxW: s.maxW,
|
||||||
maxH: s.maxH,
|
maxH: s.maxH,
|
||||||
rotationY: 0,
|
rotationY: 0,
|
||||||
side: 'back' as const,
|
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(
|
export function buildMovementHallLayout(
|
||||||
@@ -206,7 +231,21 @@ export function buildMovementHallLayout(
|
|||||||
const leftSpan = layoutRow(left, MIN_HALL_SIZE);
|
const leftSpan = layoutRow(left, MIN_HALL_SIZE);
|
||||||
const rightSpan = layoutRow(right, MIN_HALL_SIZE);
|
const rightSpan = layoutRow(right, MIN_HALL_SIZE);
|
||||||
const depth = Math.max(MIN_HALL_SIZE, leftSpan.spanNeeded, rightSpan.spanNeeded);
|
const depth = Math.max(MIN_HALL_SIZE, leftSpan.spanNeeded, rightSpan.spanNeeded);
|
||||||
const width = MIN_HALL_WIDTH;
|
|
||||||
|
// 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 halfW = width / 2;
|
||||||
const halfD = depth / 2;
|
const halfD = depth / 2;
|
||||||
const inset = WALL_THICKNESS / 2 + MOUNT_OFFSET;
|
const inset = WALL_THICKNESS / 2 + MOUNT_OFFSET;
|
||||||
@@ -216,7 +255,7 @@ export function buildMovementHallLayout(
|
|||||||
side: 'back',
|
side: 'back',
|
||||||
label: back.length > 0 ? `Wing ${hallIndex + 1} · End wall` : '',
|
label: back.length > 0 ? `Wing ${hallIndex + 1} · End wall` : '',
|
||||||
paintings: back,
|
paintings: back,
|
||||||
slots: layoutBackSlots(back, width, halfD, inset),
|
slots: layoutBackSlots(back, width, halfD, inset, endWallHasDoor),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
side: 'left',
|
side: 'left',
|
||||||
@@ -240,6 +279,7 @@ export function buildMovementHallLayout(
|
|||||||
segments,
|
segments,
|
||||||
paintingCount: paintings.length,
|
paintingCount: paintings.length,
|
||||||
yearLabel: formatYearLabel(paintings),
|
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 };
|
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(
|
export function computeSideWallWindows(
|
||||||
layout: MovementHallLayout,
|
layout: MovementHallLayout,
|
||||||
interiorStyle: MovementInteriorStyle
|
interiorStyle: MovementInteriorStyle
|
||||||
@@ -324,6 +364,25 @@ export function computeSideWallWindows(
|
|||||||
lightIntensity: tmpl.lightIntensity,
|
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;
|
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 (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE';
|
||||||
if (value instanceof Date) return `'${value.toISOString()}'`;
|
if (value instanceof Date) return `'${value.toISOString()}'`;
|
||||||
if (typeof value === 'number' || typeof value === 'bigint') return String(value);
|
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') {
|
if (typeof value === 'object') {
|
||||||
return `'${JSON.stringify(value).replace(/'/g, "''")}'`;
|
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:
|
* Usage:
|
||||||
* npm run harmonize:images
|
* npm run harmonize:images
|
||||||
* npm run harmonize:images -- --dry-run
|
* npm run harmonize:images -- --dry-run
|
||||||
|
* npm run harmonize:images -- --skip-thumbnails
|
||||||
|
* npm run harmonize:images -- --skip-db
|
||||||
*/
|
*/
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
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 { loadHarmonizeConfig, prodImagesPath } = require('./lib/harmonize-config');
|
||||||
const { printCliResult } = require('./lib/cli-result');
|
const { printCliResult } = require('./lib/cli-result');
|
||||||
|
|
||||||
const IMAGE_SUBDIRS = ['portraits', 'paintings'];
|
const IMAGE_SUBDIRS = ['portraits', 'paintings'];
|
||||||
const MTIME_TOLERANCE_MS = 1000;
|
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) {
|
function parseArgs(argv) {
|
||||||
const configArg = argv.find((a) => a.startsWith('--config='));
|
const configArg = argv.find((a) => a.startsWith('--config='));
|
||||||
|
const preferArg = argv.find((a) => a.startsWith('--prefer='));
|
||||||
return {
|
return {
|
||||||
dryRun: argv.includes('--dry-run'),
|
dryRun: argv.includes('--dry-run'),
|
||||||
verbose: argv.includes('--verbose'),
|
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,
|
configPath: configArg ? configArg.slice('--config='.length) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -78,6 +95,65 @@ function copyFile(src, dest, dryRun) {
|
|||||||
fs.copyFileSync(src, dest);
|
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()) {
|
function timestampSlug(date = new Date()) {
|
||||||
const pad = (n) => String(n).padStart(2, '0');
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
return [
|
return [
|
||||||
@@ -107,7 +183,7 @@ async function main() {
|
|||||||
|
|
||||||
if (!dryRun && process.env.CONFIRM_PROD !== '1') {
|
if (!dryRun && process.env.CONFIRM_PROD !== '1') {
|
||||||
await confirmProdAction(
|
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');
|
const reportDir = path.join(rootDir, 'db', 'SyncReports');
|
||||||
fs.mkdirSync(reportDir, { recursive: true });
|
fs.mkdirSync(reportDir, { recursive: true });
|
||||||
const reportPath = path.join(reportDir, `harmonize_images_${timestampSlug()}.json`);
|
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({
|
fs.writeFileSync(reportPath, JSON.stringify({
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
dryRun,
|
dryRun,
|
||||||
|
skipThumbnails: options.skipThumbnails,
|
||||||
|
skipDb: options.skipDb,
|
||||||
|
prefer: options.prefer,
|
||||||
devDir,
|
devDir,
|
||||||
prodDir,
|
prodDir,
|
||||||
stats: {
|
stats: {
|
||||||
@@ -175,6 +295,7 @@ async function main() {
|
|||||||
prodToDev: stats.prodToDev,
|
prodToDev: stats.prodToDev,
|
||||||
skipped: stats.skipped,
|
skipped: stats.skipped,
|
||||||
},
|
},
|
||||||
|
steps: extraDetails,
|
||||||
actions: options.verbose ? stats.actions : stats.actions.slice(0, 500),
|
actions: options.verbose ? stats.actions : stats.actions.slice(0, 500),
|
||||||
}, null, 2));
|
}, null, 2));
|
||||||
|
|
||||||
@@ -183,7 +304,12 @@ async function main() {
|
|||||||
script: 'harmonize-images',
|
script: 'harmonize-images',
|
||||||
ok: true,
|
ok: true,
|
||||||
summary: `Image harmonize complete${mode}: dev→prod ${stats.devToProd}, prod→dev ${stats.prodToDev}, skipped ${stats.skipped}.`,
|
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;
|
if (!fs.existsSync(fullDest)) return false;
|
||||||
|
|
||||||
const ext = path.extname(thumbDest).toLowerCase();
|
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') {
|
if (ext === '.png') {
|
||||||
pipeline = pipeline.png({ quality: 85 });
|
pipeline = pipeline.png({ quality: 85 });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
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 DEFAULT_CONFIG_PATH = path.join(rootDir, 'infra', 'deploy', 'harmonize.config.json');
|
||||||
const FALLBACK_CONFIG_PATH = path.join(rootDir, 'infra', 'deploy', 'devtoprod.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) {
|
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;
|
if (!meta.width || !meta.height) return null;
|
||||||
return meta.width / meta.height;
|
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.
|
* Use when login fails after changing .env, or after a DB restore with a different hash.
|
||||||
*
|
*
|
||||||
* npm run dev:reset-curator
|
* npm run dev:reset-curator
|
||||||
@@ -8,6 +8,16 @@ require('dotenv').config();
|
|||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const pool = require('../server/db');
|
const pool = require('../server/db');
|
||||||
|
|
||||||
|
const ADMIN_PERMISSIONS = [
|
||||||
|
'images',
|
||||||
|
'checkup',
|
||||||
|
'curator_notes',
|
||||||
|
'translations',
|
||||||
|
'influences',
|
||||||
|
'tours',
|
||||||
|
'users',
|
||||||
|
];
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const username = (process.env.CURATOR_USERNAME || 'curator').trim();
|
const username = (process.env.CURATOR_USERNAME || 'curator').trim();
|
||||||
const password = process.env.CURATOR_PASSWORD;
|
const password = process.env.CURATOR_PASSWORD;
|
||||||
@@ -21,17 +31,23 @@ async function main() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
await pool.query(`INSERT INTO users (username, password_hash) VALUES ($1, $2)`, [
|
await pool.query(
|
||||||
username,
|
`INSERT INTO users (username, password_hash, role, permissions, is_active)
|
||||||
passwordHash,
|
VALUES ($1, $2, 'admin', $3::text[], true)`,
|
||||||
]);
|
[username, passwordHash, ADMIN_PERMISSIONS]
|
||||||
console.log(`Created curator account: ${username}`);
|
);
|
||||||
|
console.log(`Created admin account: ${username}`);
|
||||||
} else {
|
} else {
|
||||||
await pool.query(`UPDATE users SET password_hash = $2 WHERE id = $1`, [
|
await pool.query(
|
||||||
rows[0].id,
|
`UPDATE users
|
||||||
passwordHash,
|
SET password_hash = $2,
|
||||||
]);
|
role = 'admin',
|
||||||
console.log(`Updated password for curator account: ${username}`);
|
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.
|
// Drop stale sessions so a fresh login is required.
|
||||||
|
|||||||
@@ -27,6 +27,28 @@ function getInsertTableName(statement) {
|
|||||||
return match ? match[1] : null;
|
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) {
|
function filterInsertsForRestore(inserts, prod) {
|
||||||
if (!prod) return inserts;
|
if (!prod) return inserts;
|
||||||
return inserts.filter((statement) => {
|
return inserts.filter((statement) => {
|
||||||
@@ -113,7 +135,9 @@ async function main() {
|
|||||||
|
|
||||||
const config = prod ? loadProdPgConfig() : loadDevPgConfig();
|
const config = prod ? loadProdPgConfig() : loadDevPgConfig();
|
||||||
const dbName = config.database;
|
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 inserts = filterInsertsForRestore(allInserts, prod);
|
||||||
const skippedInserts = prod ? allInserts.length - inserts.length : 0;
|
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));
|
return fs.existsSync(path.join(IMAGE_DIR, relPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncPaintingFromDisk(row) {
|
function isPaintingThumbRel(rel) {
|
||||||
const safeBase = `${row.artist_name}_${row.title}`.replace(/[^a-zA-Z0-9_-]/g, '_');
|
if (!rel) return false;
|
||||||
let imagePath = row.image_path;
|
return rel.replace(/\\/g, '/').startsWith('paintings/thumbs/');
|
||||||
let thumbPath = row.thumbnail_path;
|
}
|
||||||
|
|
||||||
|
/** 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']) {
|
for (const ext of ['.jpg', '.jpeg', '.png', '.webp', '.JPG']) {
|
||||||
const full = path.join(IMAGE_DIR, 'paintings', safeBase + ext);
|
const full = path.join(IMAGE_DIR, 'paintings', safeBase + ext);
|
||||||
@@ -111,30 +116,61 @@ function syncPaintingFromDisk(row) {
|
|||||||
thumbPath = `paintings/thumbs/${safeBase}_thumb${ext}`;
|
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 };
|
return { imagePath, thumbPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fast preload: link local files only, no external API calls */
|
/**
|
||||||
async function preloadArtistImagesLocal(artistId) {
|
* Ensure a dedicated thumbs/ file exists for a full painting image.
|
||||||
const rows = await pool.query(
|
* Regenerates from the full file when missing.
|
||||||
`SELECT p.id, p.title, p.image_path, p.thumbnail_path, a.name AS artist_name
|
*/
|
||||||
FROM paintings p
|
async function ensurePaintingThumbFromFull(fullRel, safeBase) {
|
||||||
JOIN artists a ON a.id = p.artist_id
|
if (!fullRel || !localFileExists(fullRel)) return null;
|
||||||
WHERE p.artist_id = $1`,
|
const expectedThumb = `paintings/thumbs/${safeBase}_thumb.jpg`;
|
||||||
[artistId]
|
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;
|
let linked = 0;
|
||||||
for (const row of rows.rows) {
|
for (const row of rows) {
|
||||||
const hasLocal =
|
const hasFull = localFileExists(row.image_path);
|
||||||
localFileExists(row.thumbnail_path) || localFileExists(row.image_path);
|
const hasThumb = localFileExists(row.thumbnail_path) && isPaintingThumbRel(row.thumbnail_path);
|
||||||
if (hasLocal) {
|
if (hasFull && hasThumb) {
|
||||||
linked++;
|
linked++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const synced = syncPaintingFromDisk(row);
|
const synced = await syncPaintingFromDisk(row);
|
||||||
if (synced.imagePath || synced.thumbPath) {
|
if (synced.imagePath || synced.thumbPath) {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE paintings SET image_path = COALESCE($1, image_path), thumbnail_path = COALESCE($2, thumbnail_path) WHERE id = $3`,
|
`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') {
|
async function ensurePaintingImages(paintingId, size = 'thumb') {
|
||||||
@@ -163,12 +221,27 @@ async function ensurePaintingImages(paintingId, size = 'thumb') {
|
|||||||
|
|
||||||
const row = result.rows[0];
|
const row = result.rows[0];
|
||||||
const wantThumb = size !== 'full';
|
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;
|
||||||
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) {
|
if (synced.imagePath || synced.thumbPath) {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE paintings
|
`UPDATE paintings
|
||||||
@@ -177,8 +250,20 @@ async function ensurePaintingImages(paintingId, size = 'thumb') {
|
|||||||
WHERE id = $3`,
|
WHERE id = $3`,
|
||||||
[synced.imagePath, synced.thumbPath, paintingId]
|
[synced.imagePath, synced.thumbPath, paintingId]
|
||||||
);
|
);
|
||||||
if (wantThumb && localFileExists(synced.thumbPath)) return synced.thumbPath;
|
if (wantThumb && localFileExists(synced.thumbPath) && isPaintingThumbRel(synced.thumbPath)) {
|
||||||
if (wantThumb && localFileExists(synced.imagePath)) return synced.imagePath;
|
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;
|
if (!wantThumb && localFileExists(synced.imagePath)) return synced.imagePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,6 +627,7 @@ async function replaceArtistPortraitFromUrl(artistId, imageUrl, context = {}) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
ensurePaintingImages,
|
ensurePaintingImages,
|
||||||
preloadArtistImagesLocal,
|
preloadArtistImagesLocal,
|
||||||
|
preloadMovementImagesLocal,
|
||||||
replacePaintingImageFromUrl,
|
replacePaintingImageFromUrl,
|
||||||
replaceArtistPortraitFromUrl,
|
replaceArtistPortraitFromUrl,
|
||||||
clearPaintingImage,
|
clearPaintingImage,
|
||||||
|
|||||||
+65
-20
@@ -8,10 +8,11 @@ require('dotenv').config();
|
|||||||
|
|
||||||
const pool = require('./db');
|
const pool = require('./db');
|
||||||
const { createSessionMiddleware } = require('./middleware/session');
|
const { createSessionMiddleware } = require('./middleware/session');
|
||||||
const { requireCurator } = require('./middleware/auth');
|
const { requirePermission } = require('./middleware/auth');
|
||||||
const { logCuratorAction } = require('./audit-log');
|
const { logCuratorAction } = require('./audit-log');
|
||||||
const authRoutes = require('./routes/auth');
|
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 { getVersionInfo } = require('./version-info');
|
||||||
const { searchCatalog } = require('./search-service');
|
const { searchCatalog } = require('./search-service');
|
||||||
const {
|
const {
|
||||||
@@ -44,6 +45,7 @@ app.use(compression());
|
|||||||
app.use(express.json({ limit: '20mb' }));
|
app.use(express.json({ limit: '20mb' }));
|
||||||
app.use(createSessionMiddleware());
|
app.use(createSessionMiddleware());
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/users', usersRoutes);
|
||||||
app.use('/api/translations', translationRoutes);
|
app.use('/api/translations', translationRoutes);
|
||||||
app.use('/api/influences', influenceRoutes);
|
app.use('/api/influences', influenceRoutes);
|
||||||
app.use('/api/tours', tourRoutes);
|
app.use('/api/tours', tourRoutes);
|
||||||
@@ -88,10 +90,18 @@ async function fetchTimelineErasAndMovements(startYear, endYear) {
|
|||||||
[startYear, endYear]
|
[startYear, endYear]
|
||||||
),
|
),
|
||||||
pool.query(
|
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
|
FROM art_movements m
|
||||||
LEFT JOIN historical_eras e ON m.era_id = e.id
|
LEFT JOIN historical_eras e ON m.era_id = e.id
|
||||||
INNER JOIN artists a ON a.movement_id = m.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
|
WHERE m.end_year >= $1 AND m.start_year <= $2
|
||||||
AND (a.death_year IS NULL OR a.death_year >= $1)
|
AND (a.death_year IS NULL OR a.death_year >= $1)
|
||||||
AND (a.birth_year IS NULL OR a.birth_year <= $2)
|
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 artists) AS artist_count,
|
||||||
(SELECT COUNT(*)::int FROM art_movements) AS movement_count,
|
(SELECT COUNT(*)::int FROM art_movements) AS movement_count,
|
||||||
(SELECT COUNT(*)::int FROM historical_eras) AS era_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];
|
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
|
// Artists for a movement in a time range
|
||||||
app.get('/api/movements/:id/artists', async (req, res) => {
|
app.get('/api/movements/:id/artists', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -469,7 +503,7 @@ app.get('/api/artists/:id/navigation', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update artist portrait checkup flags (checked / fixed)
|
// 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 {
|
try {
|
||||||
const artistId = parseInt(req.params.id, 10);
|
const artistId = parseInt(req.params.id, 10);
|
||||||
const { checked, fixed } = req.body ?? {};
|
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
|
// 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 {
|
try {
|
||||||
const artistId = parseInt(req.params.id, 10);
|
const artistId = parseInt(req.params.id, 10);
|
||||||
const limit = Math.min(20, Math.max(1, parseInt(req.query.limit, 10) || 20));
|
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 {
|
try {
|
||||||
const artistId = parseInt(req.params.id, 10);
|
const artistId = parseInt(req.params.id, 10);
|
||||||
const result = await pool.query(`SELECT name FROM artists WHERE id = $1`, [artistId]);
|
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
|
// 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 {
|
try {
|
||||||
const artistId = parseInt(req.params.id, 10);
|
const artistId = parseInt(req.params.id, 10);
|
||||||
const { imageUrl, searchUrl, source, pageUrl, thumbUrl } = req.body ?? {};
|
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 {
|
try {
|
||||||
const artistId = parseInt(req.params.id, 10);
|
const artistId = parseInt(req.params.id, 10);
|
||||||
const updated = await clearArtistPortrait(artistId);
|
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 {
|
try {
|
||||||
const artistId = parseInt(req.params.id, 10);
|
const artistId = parseInt(req.params.id, 10);
|
||||||
const { imageData, mimeType } = req.body ?? {};
|
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
|
// 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 {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT p.id, p.title, p.year, p.image_path, p.thumbnail_path,
|
`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)
|
// 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 {
|
try {
|
||||||
const paintingId = parseInt(req.params.id, 10);
|
const paintingId = parseInt(req.params.id, 10);
|
||||||
const { checked, fixed } = req.body ?? {};
|
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
|
// 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 {
|
try {
|
||||||
const paintingId = parseInt(req.params.id, 10);
|
const paintingId = parseInt(req.params.id, 10);
|
||||||
if (!Number.isFinite(paintingId)) {
|
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
|
// 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 {
|
try {
|
||||||
const paintingId = parseInt(req.params.id, 10);
|
const paintingId = parseInt(req.params.id, 10);
|
||||||
const limit = Math.min(20, Math.max(1, parseInt(req.query.limit, 10) || 20));
|
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 {
|
try {
|
||||||
const paintingId = parseInt(req.params.id, 10);
|
const paintingId = parseInt(req.params.id, 10);
|
||||||
const result = await pool.query(
|
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
|
// 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 {
|
try {
|
||||||
const paintingId = parseInt(req.params.id, 10);
|
const paintingId = parseInt(req.params.id, 10);
|
||||||
const { imageUrl, searchUrl, source, pageUrl, thumbUrl } = req.body ?? {};
|
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 {
|
try {
|
||||||
const paintingId = parseInt(req.params.id, 10);
|
const paintingId = parseInt(req.params.id, 10);
|
||||||
if (!Number.isFinite(paintingId)) {
|
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 {
|
try {
|
||||||
const paintingId = parseInt(req.params.id, 10);
|
const paintingId = parseInt(req.params.id, 10);
|
||||||
const updated = await clearPaintingImage(paintingId);
|
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 {
|
try {
|
||||||
const paintingId = parseInt(req.params.id, 10);
|
const paintingId = parseInt(req.params.id, 10);
|
||||||
const { imageData, mimeType } = req.body ?? {};
|
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)
|
// 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 {
|
try {
|
||||||
const imageUrl = req.query.url;
|
const imageUrl = req.query.url;
|
||||||
const searchUrl = req.query.searchUrl;
|
const searchUrl = req.query.searchUrl;
|
||||||
|
|||||||
@@ -1,5 +1,34 @@
|
|||||||
const pool = require('../db');
|
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) {
|
async function requireCurator(req, res, next) {
|
||||||
const userId = req.session?.userId;
|
const userId = req.session?.userId;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
@@ -7,16 +36,13 @@ async function requireCurator(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(
|
const user = await loadStaffUser(userId);
|
||||||
`SELECT id, username FROM users WHERE id = $1`,
|
if (!user || !user.is_active) {
|
||||||
[userId]
|
|
||||||
);
|
|
||||||
if (rows.length === 0) {
|
|
||||||
req.session.destroy(() => {});
|
req.session.destroy(() => {});
|
||||||
return res.status(401).json({ error: 'Curator login required' });
|
return res.status(401).json({ error: 'Curator login required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
req.curatorUser = rows[0];
|
attachStaff(req, user);
|
||||||
next();
|
next();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Auth middleware error:', err.message);
|
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-i18n.sql',
|
||||||
'migrate-tours.sql',
|
'migrate-tours.sql',
|
||||||
'migrate-curator-notes.sql',
|
'migrate-curator-notes.sql',
|
||||||
|
'migrate-user-roles.sql',
|
||||||
|
];
|
||||||
|
|
||||||
|
const BOOTSTRAP_ADMIN_PERMISSIONS = [
|
||||||
|
'images',
|
||||||
|
'checkup',
|
||||||
|
'curator_notes',
|
||||||
|
'translations',
|
||||||
|
'influences',
|
||||||
|
'tours',
|
||||||
|
'users',
|
||||||
];
|
];
|
||||||
|
|
||||||
async function bootstrapCurator() {
|
async function bootstrapCurator() {
|
||||||
@@ -36,10 +47,11 @@ async function bootstrapCurator() {
|
|||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const passwordHash = await bcrypt.hash(password, 10);
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO users (username, password_hash) VALUES ($1, $2)`,
|
`INSERT INTO users (username, password_hash, role, permissions, is_active)
|
||||||
[username, passwordHash]
|
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) {
|
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 express = require('express');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const pool = require('../db');
|
const pool = require('../db');
|
||||||
|
const { loadStaffUser, staffAuthPayload } = require('../middleware/auth');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -11,19 +12,13 @@ router.get('/me', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(
|
const user = await loadStaffUser(userId);
|
||||||
`SELECT id, username FROM users WHERE id = $1`,
|
if (!user || !user.is_active) {
|
||||||
[userId]
|
|
||||||
);
|
|
||||||
if (rows.length === 0) {
|
|
||||||
req.session.destroy(() => {});
|
req.session.destroy(() => {});
|
||||||
return res.json({ role: 'user' });
|
return res.json({ role: 'user' });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json(staffAuthPayload(user));
|
||||||
role: 'curator',
|
|
||||||
username: rows[0].username,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Auth me error:', err.message);
|
console.error('Auth me error:', err.message);
|
||||||
res.status(500).json({ error: 'Failed to read session' });
|
res.status(500).json({ error: 'Failed to read session' });
|
||||||
@@ -38,23 +33,36 @@ router.post('/login', async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT id, username, password_hash FROM users WHERE LOWER(username) = LOWER($1)`,
|
`SELECT id, username, password_hash, role, permissions, is_active
|
||||||
|
FROM users WHERE LOWER(username) = LOWER($1)`,
|
||||||
[username.trim()]
|
[username.trim()]
|
||||||
);
|
);
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return res.status(401).json({ error: 'Invalid username or password' });
|
return res.status(401).json({ error: 'Invalid username or password' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = rows[0];
|
const row = rows[0];
|
||||||
const valid = await bcrypt.compare(password, user.password_hash);
|
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) {
|
if (!valid) {
|
||||||
return res.status(401).json({ error: 'Invalid username or password' });
|
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.userId = row.id;
|
||||||
req.session.username = user.username;
|
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).
|
// Ensure the store writes before the response finishes (proxy / HTTPS).
|
||||||
req.session.save((err) => {
|
req.session.save((err) => {
|
||||||
@@ -62,10 +70,7 @@ router.post('/login', async (req, res) => {
|
|||||||
console.error('Auth session save error:', err.message);
|
console.error('Auth session save error:', err.message);
|
||||||
return res.status(500).json({ error: 'Login failed' });
|
return res.status(500).json({ error: 'Login failed' });
|
||||||
}
|
}
|
||||||
res.json({
|
res.json(staffAuthPayload(user));
|
||||||
role: 'curator',
|
|
||||||
username: user.username,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Auth login error:', err.message);
|
console.error('Auth login error:', err.message);
|
||||||
|
|||||||
+10
-10
@@ -1,6 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const pool = require('../db');
|
const pool = require('../db');
|
||||||
const { requireCurator } = require('../middleware/auth');
|
const { requirePermission } = require('../middleware/auth');
|
||||||
const { logCuratorAction } = require('../audit-log');
|
const { logCuratorAction } = require('../audit-log');
|
||||||
const {
|
const {
|
||||||
COLUMN_ROLES,
|
COLUMN_ROLES,
|
||||||
@@ -24,7 +24,7 @@ function parseId(value) {
|
|||||||
return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
|
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({
|
res.json({
|
||||||
roles: COLUMN_ROLES,
|
roles: COLUMN_ROLES,
|
||||||
presets: Object.values(PRESETS).map((p) => ({
|
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 {
|
try {
|
||||||
const artistId = parseId(req.query.artistId);
|
const artistId = parseId(req.query.artistId);
|
||||||
const paintingId = parseId(req.query.paintingId);
|
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 {
|
try {
|
||||||
const artistId = parseId(req.query.artistId);
|
const artistId = parseId(req.query.artistId);
|
||||||
const paintingId = parseId(req.query.paintingId);
|
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 {
|
try {
|
||||||
const {
|
const {
|
||||||
paintingId,
|
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 {
|
try {
|
||||||
const id = parseId(req.params.id);
|
const id = parseId(req.params.id);
|
||||||
if (!id) return res.status(400).json({ error: 'Invalid 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 {
|
try {
|
||||||
const id = parseId(req.params.id);
|
const id = parseId(req.params.id);
|
||||||
if (!id) return res.status(400).json({ error: 'Invalid 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 {
|
try {
|
||||||
const { filename, sheet, contentBase64, content } = req.body || {};
|
const { filename, sheet, contentBase64, content } = req.body || {};
|
||||||
let buffer;
|
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 {
|
try {
|
||||||
const { rows, mapping, sourceLabel, contentHash, payloadHash } = req.body || {};
|
const { rows, mapping, sourceLabel, contentHash, payloadHash } = req.body || {};
|
||||||
if (!Array.isArray(rows) || !rows.length) {
|
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 {
|
try {
|
||||||
const { proposals, fileName, contentHash, payloadHash, force } = req.body || {};
|
const { proposals, fileName, contentHash, payloadHash, force } = req.body || {};
|
||||||
if (!Array.isArray(proposals)) {
|
if (!Array.isArray(proposals)) {
|
||||||
|
|||||||
+8
-11
@@ -1,6 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const pool = require('../db');
|
const pool = require('../db');
|
||||||
const { requireCurator } = require('../middleware/auth');
|
const { requirePermission, loadStaffUser } = require('../middleware/auth');
|
||||||
const { logCuratorAction } = require('../audit-log');
|
const { logCuratorAction } = require('../audit-log');
|
||||||
const { enrichPaintingRow } = require('../image-service');
|
const { enrichPaintingRow } = require('../image-service');
|
||||||
const { localizePaintings, resolveLocale, translationStatuses } = require('../translation-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 {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT t.*,
|
`SELECT t.*,
|
||||||
@@ -125,11 +125,8 @@ router.get('/:id', async (req, res) => {
|
|||||||
const tour = rows[0];
|
const tour = rows[0];
|
||||||
let isCurator = false;
|
let isCurator = false;
|
||||||
if (req.session?.userId) {
|
if (req.session?.userId) {
|
||||||
const { rows: users } = await pool.query(
|
const user = await loadStaffUser(req.session.userId);
|
||||||
'SELECT id FROM users WHERE id = $1',
|
isCurator = Boolean(user && user.is_active);
|
||||||
[req.session.userId],
|
|
||||||
);
|
|
||||||
isCurator = users.length > 0;
|
|
||||||
}
|
}
|
||||||
if (tour.status !== 'published' && !isCurator) {
|
if (tour.status !== 'published' && !isCurator) {
|
||||||
return res.status(404).json({ error: 'Tour not found' });
|
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 {
|
try {
|
||||||
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
|
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
|
||||||
if (!title) return res.status(400).json({ error: 'title required' });
|
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 {
|
try {
|
||||||
const id = parseId(req.params.id);
|
const id = parseId(req.params.id);
|
||||||
if (!id) return res.status(400).json({ error: 'Invalid 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 {
|
try {
|
||||||
const id = parseId(req.params.id);
|
const id = parseId(req.params.id);
|
||||||
if (!id) return res.status(400).json({ error: 'Invalid 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();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
const id = parseId(req.params.id);
|
const id = parseId(req.params.id);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const pool = require('../db');
|
const pool = require('../db');
|
||||||
const { requireCurator } = require('../middleware/auth');
|
const { requirePermission } = require('../middleware/auth');
|
||||||
const { logCuratorAction } = require('../audit-log');
|
const { logCuratorAction } = require('../audit-log');
|
||||||
const {
|
const {
|
||||||
TRANSLATABLE_FIELDS,
|
TRANSLATABLE_FIELDS,
|
||||||
@@ -14,7 +14,7 @@ const router = express.Router();
|
|||||||
|
|
||||||
const VALID_ENTITY_TYPES = new Set(Object.keys(TRANSLATABLE_FIELDS));
|
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 {
|
try {
|
||||||
const entityType = req.params.entityType;
|
const entityType = req.params.entityType;
|
||||||
const locale = typeof req.query.locale === 'string' ? req.query.locale : 'ru';
|
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 {
|
try {
|
||||||
const locale = typeof req.query.locale === 'string' ? req.query.locale : 'ru';
|
const locale = typeof req.query.locale === 'string' ? req.query.locale : 'ru';
|
||||||
const coverage = await getTranslationCoverage(locale);
|
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 {
|
try {
|
||||||
const entityType = typeof req.query.entityType === 'string' ? req.query.entityType : undefined;
|
const entityType = typeof req.query.entityType === 'string' ? req.query.entityType : undefined;
|
||||||
const locale = typeof req.query.locale === 'string' ? req.query.locale : 'ru';
|
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 {
|
try {
|
||||||
const entityType = req.params.entityType;
|
const entityType = req.params.entityType;
|
||||||
const entityId = parseInt(req.params.id, 10);
|
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 {
|
try {
|
||||||
const entityType = req.params.entityType;
|
const entityType = req.params.entityType;
|
||||||
const entityId = parseInt(req.params.id, 10);
|
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 {
|
try {
|
||||||
const entityType = req.params.entityType;
|
const entityType = req.params.entityType;
|
||||||
const entityId = parseInt(req.params.id, 10);
|
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