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