Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2a256132c | ||
|
|
8e52415ea8 | ||
|
|
3fe88ffcfa | ||
|
|
1c9fa20191 | ||
|
|
44092d102b | ||
|
|
33b8ae5a5f | ||
|
|
971c1e8dd8 | ||
|
|
4088d7d57b | ||
|
|
f20f811f21 | ||
|
|
cfee69c9a6 | ||
|
|
8a68e98258 | ||
|
|
8c823a6dfe | ||
|
|
dc0ac81081 | ||
|
|
f9b0fb0496 | ||
|
|
1cb9eef935 | ||
|
|
1853222e01 | ||
|
|
710d262516 | ||
|
|
0a5918c4dd | ||
|
|
ad1572b3aa | ||
|
|
8111cd0163 | ||
|
|
23e1210e86 | ||
|
|
485c7d3e6f | ||
|
|
08ff4651e2 | ||
|
|
0466b77328 | ||
|
|
bfa21989c9 | ||
|
|
67594ea4d3 | ||
|
|
41d2d5d844 | ||
|
|
8d3ebcce60 | ||
|
|
89e39d408a | ||
|
|
6656f91f25 | ||
|
|
8005252881 | ||
|
|
d709e98640 | ||
|
|
8e444fa461 | ||
|
|
f6c73c1792 | ||
|
|
bc8369e373 | ||
|
|
77befd94b2 | ||
|
|
cc39c2b138 | ||
|
|
5ddc3fd7f0 | ||
|
|
48bd17e985 | ||
|
|
62d7ebbe6a | ||
|
|
a2c27ea526 | ||
|
|
ca58c43648 | ||
|
|
f247b418d8 | ||
|
|
81ebad2120 | ||
|
|
acc4a91a08 | ||
|
|
50fc253ab2 | ||
|
|
f78c14f307 | ||
|
|
62096e8210 | ||
|
|
0ceb1c1c8c | ||
|
|
f72ddcc3e4 | ||
|
|
3810e61bb8 | ||
|
|
cdca271e08 | ||
|
|
aa125ccb9b | ||
|
|
0de95fd151 | ||
|
|
c391fcc21e |
@@ -0,0 +1,25 @@
|
||||
---
|
||||
description: Gallery UI interaction and component standards for client screens
|
||||
globs: client/src/**/*.{tsx,css}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# UI interaction standards
|
||||
|
||||
Follow [Documentation/ui-interaction-and-component-standards.md](Documentation/ui-interaction-and-component-standards.md) (MUST / SHOULD / MAY). Do not invent a second nav, toast, or CSS framework.
|
||||
|
||||
## MUST
|
||||
|
||||
- Drill-down via `HomePage` `View` state. Nested screens get an explicit Back; do not rely on the browser Back button.
|
||||
- **Back to Timeline** → `goToTimelineHome()` (clears hall, resets year range). **Back to Gallery** → same hall session (camera/wing kept).
|
||||
- Hide actions the user cannot `can()`; do not leave buttons that 403.
|
||||
- Visitor chrome strings in `locales/{en,ru}`. Movement colours via `utils/movementColor.ts`. Timeline zoom/pan via `utils/timelineView.ts`.
|
||||
- Loading: `GalleryLoadingMarker`. Errors/empty states: visible copy, not a blank canvas.
|
||||
- Modals: `role="dialog"`, visible close, Escape. No nested modals. Destructive actions confirm first.
|
||||
- Colocated CSS; museum gold `#c9a96e` / navy `/ Georgia`. No MUI/Ant Design.
|
||||
|
||||
## SHOULD
|
||||
|
||||
- Reuse `CatalogSearchBar`, `CuratorLoginModal`, `ArtistFilterModal`, `PaintingLightbox`, `DebugSearchResultsModal`, `DebugUploadButton`.
|
||||
- Curator filters above the table; expensive work behind an explicit button.
|
||||
- Update the standards doc when changing Back, search, layout query, or loading behaviour.
|
||||
+2
-1
@@ -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=
|
||||
|
||||
|
||||
+12
@@ -6,13 +6,25 @@ client/node_modules/
|
||||
.env.*.local
|
||||
infra/docker/.env.prod
|
||||
infra/deploy/devtoprod.config.json
|
||||
infra/deploy/harmonize.config.json
|
||||
db/SyncReports/
|
||||
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
|
||||
|
||||
+304
-34
@@ -17,6 +17,8 @@ All JSON responses use `Content-Type: application/json`. Errors return `{ "error
|
||||
|
||||
Static images are served at `/images/<relative-path>` from `IMAGE_DIR`.
|
||||
|
||||
**Caching:** `/images` responses use `Cache-Control: public, max-age=0, must-revalidate` with `ETag` / `Last-Modified`. Painting and artist JSON payloads include optional **`image_cache_key`** / **`thumbnail_cache_key`** (and **`portrait_cache_key`** / **`portrait_thumb_cache_key`** on artists) — Unix ms from the file’s `mtime` on disk. The client appends `?v=<key>` to image URLs so fix/upload/clear updates show immediately after reload even when the relative path is unchanged.
|
||||
|
||||
**Quick check:**
|
||||
|
||||
```powershell
|
||||
@@ -28,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.
|
||||
|
||||
@@ -40,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`
|
||||
|
||||
@@ -60,29 +68,70 @@ 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" }`. Admin-only routes (no matching permission flag) → **`403`** `{ "error": "Admin access required" }`.
|
||||
|
||||
**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`.
|
||||
|
||||
### Audit log (admin)
|
||||
|
||||
Admin-only reports over `curator_audit_log`. Responses include `database` (`process.env.DB_NAME`) so the UI shows whether you are reading **dev** or **prod**.
|
||||
|
||||
| Method | Path | Notes |
|
||||
|--------|------|-------|
|
||||
| `GET` | `/api/audit` | Paginated entries + resource labels. Query: `user_id`, `username`, `action`, `resource_type`, `resource_id`, `from`, `to`, `q`, `limit`, `offset` |
|
||||
| `GET` | `/api/audit/summary` | Totals (all / 24h / 7d), breakdowns by user / action / resource_type. Same filters as list |
|
||||
| `GET` | `/api/audit/meta` | Distinct users, actions, resource types for filter dropdowns |
|
||||
|
||||
Each list entry includes `created_at`, `username`, `user_role`, `action`, `resource_type`, `resource_id`, `resource_label`, `details`, `ip_address`.
|
||||
|
||||
### 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.*` |
|
||||
| `/api/audit/*` | admin role | — (read) |
|
||||
|
||||
**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)). Browse via admin **Activity** UI or `/api/audit`.
|
||||
|
||||
---
|
||||
|
||||
@@ -169,6 +218,147 @@ Movements are filtered to those with at least one artist active in the requested
|
||||
|
||||
---
|
||||
|
||||
## Locale (`?locale=ru`)
|
||||
|
||||
Public catalog endpoints accept optional **`locale`** query param (`en` default, `ru` supported) or `Accept-Language: ru`.
|
||||
|
||||
Affected routes: `/api/catalog/bootstrap`, `/api/timeline`, `/api/search`, `/api/artists`, `/api/artists/:id`, `/api/paintings/:id`, `/api/movements/:id/gallery`, `/api/movements/:id/artists`, `/api/artists/:id/navigation`.
|
||||
|
||||
Responses include `"locale": "ru"` when resolved. Display field names are unchanged; values come from `entity_translations` when `status = published`, else canonical English.
|
||||
|
||||
Full guide: [i18n-russian.md](i18n-russian.md).
|
||||
|
||||
---
|
||||
|
||||
## Translations (curator)
|
||||
|
||||
Requires curator session. Base path: `/api/translations`.
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/translations/coverage?locale=ru` | Coverage stats |
|
||||
| `GET` | `/api/translations/worklist/:entityType?locale=ru` | Artists/paintings/movements with translation status |
|
||||
| `GET` | `/api/translations/:entityType/:id` | Canonical + all translation rows |
|
||||
| `PUT` | `/api/translations/:entityType/:id` | Upsert fields `{ locale, fields, status }` |
|
||||
| `POST` | `/api/translations/:entityType/:id/publish` | Publish all draft/reviewed rows for locale |
|
||||
|
||||
## Influences (curator)
|
||||
|
||||
Requires curator session. Base path: `/api/influences`. See [influence-import.md](influence-import.md).
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/influences` | List edges (`artistId`, `paintingId`, `q`, pagination) |
|
||||
| `GET` | `/api/influences/graph?artistId=` | Neighborhood nodes/edges for visualization |
|
||||
| `POST` | `/api/influences` | Create edge `{ paintingId, sourceType, sourceArtistId\|sourcePaintingId\|sourceMovementId, … }` |
|
||||
| `PATCH` | `/api/influences/:id` | Update notes / remap source |
|
||||
| `DELETE` | `/api/influences/:id` | Delete edge |
|
||||
| `POST` | `/api/influences/import/parse` | Parse upload `{ filename, contentBase64, sheet? }` — returns `contentHash` / `payloadHash` / `alreadyImported` |
|
||||
| `POST` | `/api/influences/import/preview` | Validate `{ rows, mapping, contentHash?, payloadHash? }` |
|
||||
| `POST` | `/api/influences/import/commit` | Insert `{ proposals, contentHash?, payloadHash?, force? }` — `409` if duplicate unless `force` |
|
||||
|
||||
Public painting detail still exposes read-only `influencedBy` / `influenced` (unchanged).
|
||||
|
||||
---
|
||||
|
||||
## Tours
|
||||
|
||||
Base path: `/api/tours`. Full guide: [tours.md](tours.md).
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|------|------|---------|
|
||||
| `GET` | `/api/tours` | public | Published tour summaries (`id`, `title`, `description`, cover thumb, `stopCount`) |
|
||||
| `GET` | `/api/tours/:id` | public* | Full tour + ordered `paintings` + `stopBodies` (*draft tours require curator session) |
|
||||
| `GET` | `/api/tours/admin` | curator | All tours (any status) |
|
||||
| `POST` | `/api/tours` | curator | Create `{ title, description?, status? }` |
|
||||
| `PATCH` | `/api/tours/:id` | curator | Update title / description / status / cover |
|
||||
| `DELETE` | `/api/tours/:id` | curator | Delete tour + stops |
|
||||
| `PUT` | `/api/tours/:id/stops` | curator | Replace ordered stops `{ stops: [{ paintingId, body }] }` |
|
||||
|
||||
Detail response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"tour": { "id": 1, "title": "…", "status": "published", "stopCount": 5 },
|
||||
"paintings": [ /* Painting rows in stop order */ ],
|
||||
"stopBodies": { "42": "Tour text for this stop…" }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `GET /api/search`
|
||||
|
||||
Public catalog search over **artists**, **paintings**, and **art movements**. Used by the timeline header search bar (`CatalogSearchBar.tsx`).
|
||||
|
||||
**Query**
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `q` | string | — | Search text (min **2** characters after trim; shorter returns `{ q, results: [] }`) |
|
||||
| `limit` | int | 20 | Max results total (capped at **50**) |
|
||||
| `types` | string | all | Optional comma list: `artist`, `painting`, `movement` |
|
||||
| `locale` | string | `en` | `ru` — search and return published Russian aliases when available |
|
||||
|
||||
**Matching (case-insensitive `ILIKE`):**
|
||||
|
||||
| Entity | Fields |
|
||||
|--------|--------|
|
||||
| Artist | `name`, `wikipedia_title`, movement name |
|
||||
| Movement | movement `name`, era name |
|
||||
| Painting | `title`, `wikipedia_title`, `year` (as text), artist name, movement name |
|
||||
| All (when `locale=ru`) | Published rows in `entity_translations` for `name` / `title` |
|
||||
|
||||
Prefix matches on primary labels (`name` / `title`) rank before substring matches.
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"q": "monet",
|
||||
"results": [
|
||||
{
|
||||
"type": "artist",
|
||||
"id": 19,
|
||||
"name": "Claude Monet",
|
||||
"birth_year": 1840,
|
||||
"death_year": 1926,
|
||||
"movement_name": "Impressionism",
|
||||
"portrait_path": "portraits/Claude_Monet.jpg",
|
||||
"portrait_thumb_path": "portraits/thumbs/Claude_Monet_thumb.jpg"
|
||||
},
|
||||
{
|
||||
"type": "movement",
|
||||
"id": 12,
|
||||
"name": "Impressionism",
|
||||
"color": "#87CEEB",
|
||||
"start_year": 1860,
|
||||
"end_year": 1890,
|
||||
"era_name": "Modern"
|
||||
},
|
||||
{
|
||||
"type": "painting",
|
||||
"id": 241,
|
||||
"title": "Water Lilies",
|
||||
"year": 1919,
|
||||
"artist_id": 19,
|
||||
"artist_name": "Claude Monet",
|
||||
"movement_name": "Impressionism",
|
||||
"thumbnail_path": "paintings/thumbs/Claude_Monet_Water_Lilies_thumb.jpg",
|
||||
"image_path": "paintings/Claude_Monet_Water_Lilies.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Indexes:** applied by `npm run dev:migrate` (`db/migrate-search.sql`) or standalone `npm run dev:migrate:search`.
|
||||
|
||||
**Client:** `api.search(q, { limit?, types? })`.
|
||||
|
||||
**Navigation from search:** choosing a **painting** opens detail with `returnTo: timeline`; the client shows **← Back to Timeline** and calls `goToTimelineHome()` (clears gallery session, resets timeline zoom). Choosing an **artist** or **movement** uses the normal gallery entry handlers.
|
||||
|
||||
---
|
||||
|
||||
## `GET /api/artists`
|
||||
|
||||
Artists for timeline portraits and the movement flow diagram.
|
||||
@@ -196,9 +386,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**
|
||||
|
||||
@@ -275,7 +494,7 @@ Each painting includes:
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `has_influence_links` | `true` when the work appears in any influence row — 3D gallery shows a golden lamp above the frame |
|
||||
| `has_influence_links` | `true` when the work appears in any influence row — 3D gallery shows a golden lamp on a shared rail (40 cm above the tallest frame in the hall) |
|
||||
| `checkup_checked` | Reviewed in checkup / debug workflow (gold frame in 3D when true) |
|
||||
| `checkup_fixed` | Image replaced via **Fix it** |
|
||||
|
||||
@@ -420,7 +639,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**
|
||||
|
||||
@@ -432,6 +651,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.
|
||||
@@ -440,7 +673,19 @@ Painting detail with influence graph neighbours.
|
||||
|
||||
```json
|
||||
{
|
||||
"painting": { "id": 10, "title": "...", "artist_name": "...", "image_path": "...", "checkup_checked": false, "checkup_fixed": false, "has_influence_links": true, ... },
|
||||
"painting": {
|
||||
"id": 10,
|
||||
"title": "...",
|
||||
"artist_name": "...",
|
||||
"image_path": "paintings/Artist_Title.jpg",
|
||||
"thumbnail_path": "paintings/thumbs/Artist_Title_thumb.jpg",
|
||||
"image_cache_key": 1739123456789,
|
||||
"thumbnail_cache_key": 1739123456790,
|
||||
"checkup_checked": false,
|
||||
"checkup_fixed": false,
|
||||
"curator_notes": "",
|
||||
"has_influence_links": true
|
||||
},
|
||||
"influencedBy": [
|
||||
{
|
||||
"source_type": "painting",
|
||||
@@ -544,6 +789,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.
|
||||
@@ -579,6 +840,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**
|
||||
@@ -600,6 +863,8 @@ Only `imageUrl` is required; optional fields improve fetch success for hotlinked
|
||||
{
|
||||
"imagePath": "paintings/Artist_Title.jpg",
|
||||
"thumbnailPath": "paintings/thumbs/Artist_Title_thumb.jpg",
|
||||
"image_cache_key": 1739123456789,
|
||||
"thumbnail_cache_key": 1739123456790,
|
||||
"fixed": true,
|
||||
"checked": true
|
||||
}
|
||||
@@ -660,7 +925,7 @@ Upload a local painting image (base64 JSON body). Validates with `sharp`, writes
|
||||
}
|
||||
```
|
||||
|
||||
Max decoded size 15 MB (JSON body limit 20 MB on the server). **Response** — same shape as `fix-image` (`imagePath`, `thumbnailPath`, `fixed`, `checked`).
|
||||
Max decoded size 15 MB (JSON body limit 20 MB on the server). **Response** — same shape as `fix-image` (`imagePath`, `thumbnailPath`, `image_cache_key`, `thumbnail_cache_key`, `fixed`, `checked`).
|
||||
|
||||
---
|
||||
|
||||
@@ -705,19 +970,24 @@ The React client wraps these endpoints in `client/src/api/client.ts`. All reques
|
||||
| `logoutCurator()` | `POST /api/auth/logout` |
|
||||
| `api.getBounds()` | `GET /api/bounds` |
|
||||
| `api.getTimeline(start, end)` | `GET /api/timeline` |
|
||||
| `api.search(q, options?)` | `GET /api/search` |
|
||||
| `api.getArtists(...)` | `GET /api/artists` |
|
||||
| `api.getTimelineArtists()` | `GET /api/artists?timeline=1` |
|
||||
| `api.getArtist(id)` | `GET /api/artists/:id` |
|
||||
| `api.getArtistNavigation(id)` | `GET /api/artists/:id/navigation` |
|
||||
| `api.getPainting(id)` | `GET /api/paintings/:id` |
|
||||
| `preloadArtistImages(id)` | `POST /api/artists/:id/preload-images` |
|
||||
| `imageUrl(path)` | `/images/<path>` or placeholder |
|
||||
| `galleryImageUrl(painting)` | Local thumb/full only (3D) |
|
||||
| `galleryImageUrlWithRevision(painting, revision)` | Local URL with `?v=` cache buster after fix |
|
||||
| `paintingImageUrl(painting)` | Local file, on-demand API, or `null` when cleared (`checkup_fixed` + no paths) |
|
||||
| `portraitUrl(path, revision?)` | `/images/<path>` with optional `?v=` cache buster |
|
||||
| `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 |
|
||||
| `galleryImageUrlWithRevision(painting, sessionRevision?)` | Alias of `galleryImageUrl` |
|
||||
| `paintingImageUrl(painting, sessionRevision?)` | Local file, on-demand API, or `null` when cleared (`checkup_fixed` + no paths) |
|
||||
| `portraitUrl(path, revision?, artist?)` | `/images/<path>` with `?v=` from revision or artist cache keys |
|
||||
| `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`
|
||||
|
||||
@@ -136,6 +137,34 @@ Short art-history notes shown on painting detail (`PaintingAnnotations.tsx`).
|
||||
|
||||
Applied by `npm run dev:migrate:painting-annotations` (`db/migrate-painting-annotations.sql`). Load data with `npm run dev:update-painting-annotations` (curated entries in `scripts/painting-annotations-data.js`; add `--wikipedia` for intro sentences from each work’s `wikipedia_title`).
|
||||
|
||||
### `tours` / `tour_stops`
|
||||
|
||||
Curated guided tours. See [tours.md](tours.md).
|
||||
|
||||
**`tours`**
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `id` | SERIAL PK | |
|
||||
| `title` | VARCHAR(200) | |
|
||||
| `description` | TEXT | Default `''` |
|
||||
| `status` | VARCHAR(20) | `draft` \| `published` |
|
||||
| `cover_painting_id` | FK → `paintings` | ON DELETE SET NULL |
|
||||
| `created_at` / `updated_at` | TIMESTAMPTZ | `updated_at` via trigger |
|
||||
|
||||
**`tour_stops`**
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `id` | SERIAL PK | |
|
||||
| `tour_id` | FK → `tours` | ON DELETE CASCADE |
|
||||
| `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`; `updated_at` also covered by `migrate-sync-timestamps.sql` when the table already exists).
|
||||
|
||||
### `painting_influences`
|
||||
|
||||
Directed edges: *this painting* was influenced by *that painting*.
|
||||
@@ -186,34 +215,43 @@ 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`.
|
||||
**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`.
|
||||
|
||||
Admins browse this table in the app (**Activity** / `GET /api/audit*`). Each environment’s API uses its own DB (`gallery_dev` vs `gallery_prod`); **`users`**, **`session`**, and audit history are not synced by harmonize/`devtoprod:db:restore`.
|
||||
|
||||
Example query in pgAdmin:
|
||||
|
||||
|
||||
+35
-9
@@ -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,16 +95,23 @@ CURATOR_USERNAME=curator
|
||||
CURATOR_PASSWORD=your-secure-password
|
||||
```
|
||||
|
||||
Then open the gallery → **Curator login** (top-right) → use debug mode / Checkup. 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**, **Activity**). Mutations are logged in `curator_audit_log` per user.
|
||||
|
||||
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, image fix/upload/delete APIs |
|
||||
| Curator | Public browse + assigned permission flags (`images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`) |
|
||||
| Admin | All curator tools + **Users** + **Activity** audit reports |
|
||||
|
||||
**Audit log (pgAdmin on `gallery_dev` or `gallery_prod`):**
|
||||
**Users page:** after admin login, header → **Users** — create/edit staff, reset passwords, disable accounts. Prod and dev keep **separate** `users` tables: `devtoprod:db:restore` and harmonize never copy staff accounts. If create fails with a confusing “already exists” after a restore, serial sequences may be lagging — current restore syncs them to `MAX(id)`, and user create re-syncs `users_id_seq` before insert.
|
||||
|
||||
**Activity page:** after admin login, header → **Activity** — filterable curator action log (date/time, curator, action, resource, details, IP) plus summary charts. Reads the DB for that environment (`gallery_dev` on devgallery / `npm run dev:web`, `gallery_prod` on prod).
|
||||
|
||||
**Audit log (SQL / pgAdmin on `gallery_dev` or `gallery_prod`):**
|
||||
|
||||
```sql
|
||||
SELECT l.created_at, u.username, l.action, l.resource_type, l.resource_id
|
||||
@@ -137,7 +145,11 @@ Run `npm run dev:migrate` against prod DB after first deploy with auth vars set
|
||||
| `npm run dev:db:backup` | Dev data-only backup → `db/DataBackup/*.txt` + `.zip` |
|
||||
| `npm run prod:db:backup` | Prod backup (reads `infra/docker/.env.prod`) |
|
||||
| `npm run dev:db:restore -- --file <path>` | Restore backup into **dev** (truncates tables first; prompts `yes`) |
|
||||
| `npm run devtoprod:db:restore -- --file <path>` | Restore into **prod** (requires confirmation) |
|
||||
| `npm run devtoprod:db:restore -- --file <path>` | Restore catalog into **prod** (skips `users` / `session` / `curator_audit_log`; syncs serial sequences; 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 (`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`.
|
||||
|
||||
@@ -148,7 +160,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
|
||||
```
|
||||
@@ -206,7 +218,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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -236,7 +250,18 @@ 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.
|
||||
@@ -353,4 +378,5 @@ Remote: https://gitea.mysuperlab.netcraze.pro/Danilka/Art-gallery
|
||||
| [setup.md](setup.md) | Install, env vars, troubleshooting |
|
||||
| [data-and-images.md](data-and-images.md) | Catalog and image pipeline |
|
||||
| [API.md](API.md) | REST endpoints |
|
||||
| [tours.md](tours.md) | Guided tours |
|
||||
| [DB_structure.md](DB_structure.md) | PostgreSQL schema |
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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 ?~~ — done for monitoring: admin **Activity** page + `/api/audit` over `curator_audit_log` (filters, summary, per-env DB). Checkup flags remain the painting review markers.
|
||||
4. ~~tool to sync prod /env resources (both ways), db structure, db data, images, users etc~~ — done for catalog DB + images: `npm run harmonize` (schema dev→prod only; users/audit excluded) — [harmonize-dev-prod.md](harmonize-dev-prod.md)
|
||||
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); browse via admin **Activity** page — [DB_structure.md](DB_structure.md#curator_audit_log) / [API.md](API.md#audit-log-admin).
|
||||
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)
|
||||
|
||||
+183
-64
@@ -6,12 +6,14 @@ Interactive virtual museum spanning art history: zoomable timeline with event gu
|
||||
|
||||
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 *or* a **movement gallery** (click a movement name on the flow diagram): period-themed interiors, chronological wings of up to ~55 works, side-wall hang only.
|
||||
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.
|
||||
1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries; classic left→right, vertical bottom→up, or **tree** bottom→up layout (header links).
|
||||
2. **Movement flow** — art movements as SVG streams on the same year axis; documented predecessor→successor branches (classic); portrait thumbnails along each stream. The tree layout redraws the same lineage as a growing tree — see [movement-tree.md](movement-tree.md).
|
||||
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.
|
||||
|
||||
All artwork images are stored locally under `data/images/` — the UI never hot-links to Wikipedia or Commons at runtime (except optional on-demand fetch when a file is missing).
|
||||
|
||||
## Stack
|
||||
@@ -35,23 +37,38 @@ Gallery/
|
||||
│ │ ├── components/GalleryLoadingMarker.tsx # Loading spinner overlay/banner (catalog, portraits, halls)
|
||||
│ │ ├── components/GalleryWindows.tsx # Side-wall daylight windows (movement)
|
||||
│ │ ├── components/HallPassage.tsx # Open archway between movement wings
|
||||
│ │ ├── components/MovementHallDetails.tsx # Period architectural details
|
||||
│ │ ├── components/MovementHallDetails.tsx # Period architecture dispatcher (Gothic + Byzantine reference)
|
||||
│ │ ├── components/hall-details/ # One period interior per movement + shared primitives
|
||||
│ │ ├── data/movement-interior-styles.ts # Per-movement interior themes
|
||||
│ │ ├── utils/movementHallLayout.ts # Wing split + window gap placement
|
||||
│ │ ├── utils/galleryProceduralTextures.ts # Hi-res wall/floor textures
|
||||
│ │ ├── components/PaintingDetail.tsx # Detail view + debug panel
|
||||
│ │ ├── components/ArtistBio.tsx # Biography + portrait debug panel
|
||||
│ │ ├── components/DebugSearchResultsModal.tsx # “More” search picker (20 results)
|
||||
│ │ ├── components/Timeline.tsx # Era bar, year ticks, event markers
|
||||
│ │ ├── components/TimelineEventGuides.tsx # Event vertical guides into movement flow
|
||||
│ │ ├── components/Timeline.tsx # Classic horizontal era bar
|
||||
│ │ ├── components/VerticalTimeline.tsx # Bottom-up vertical era rail
|
||||
│ │ ├── components/MovementBands.tsx # Movement flow (SVG streams + branches)
|
||||
│ │ ├── components/VerticalMovementBands.tsx # Bottom-up movement streams
|
||||
│ │ ├── components/MovementTree.tsx # Bottom-up movement tree (alternative start page)
|
||||
│ │ ├── components/TimelineEventGuides.tsx # Event vertical guides into movement flow
|
||||
│ │ ├── components/CatalogSearchBar.tsx # Timeline header catalog search
|
||||
│ │ ├── components/PaintingAnnotations.tsx # Art-history notes on painting detail
|
||||
│ │ ├── pages/CheckupPage.tsx # Image audit table
|
||||
│ │ ├── pages/TranslationsPage.tsx # Russian translation review
|
||||
│ │ ├── pages/InfluencesPage.tsx # Influence links CRUD + import wizard
|
||||
│ │ ├── pages/ToursPage.tsx # Guided tour editor
|
||||
│ │ ├── pages/UsersPage.tsx # Staff accounts
|
||||
│ │ ├── pages/AuditPage.tsx # Admin curator activity reports
|
||||
│ │ ├── components/ToursPopup.tsx # Public published-tours modal
|
||||
│ │ ├── i18n/ # react-i18next bootstrap
|
||||
│ │ ├── locales/{en,ru}/ # UI chrome strings (incl. timeline layout + captions)
|
||||
│ │ ├── data/historical-events.ts # Timeline event markers (UI)
|
||||
│ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI)
|
||||
│ │ ├── utils/parquetFloorTexture.ts # Procedural parquet floor
|
||||
│ │ ├── utils/debugMode.ts # Debug mode + “Show more” localStorage prefs
|
||||
│ │ └── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
|
||||
│ │ ├── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
|
||||
│ │ ├── utils/movementColor.ts # Shared vivid/shade hex helpers for all movement charts
|
||||
│ │ └── utils/movementTree.ts # View-independent Tree of Art layout engine
|
||||
│ └── dist/ # Production build (served by API when present)
|
||||
├── scripts/ # Seed, bios, catalog expansion, image fetch, checkup tools
|
||||
│ ├── seed-wikipedia.js
|
||||
@@ -67,8 +84,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
|
||||
@@ -117,9 +134,13 @@ Use for fast frontend iteration without Keenetic. Legacy nginx config in [`deplo
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Home — timeline + movement flow] -->|scroll / drag / zoom| A
|
||||
A -->|catalog search| S[Search results dropdown]
|
||||
S -->|artist| C
|
||||
S -->|movement| G
|
||||
S -->|painting| D
|
||||
A -->|click movement name| G[Movement gallery — 3D wings]
|
||||
G -->|click painting| D
|
||||
D -->|Back| G
|
||||
D -->|Back to Gallery| G
|
||||
G -->|back door / Wings / Exit| H[Wing navigator]
|
||||
H -->|pick wing| G
|
||||
H -->|Exit to Timeline| A
|
||||
@@ -131,26 +152,64 @@ flowchart TD
|
||||
D -->|prev / next| D
|
||||
D -->|click centre image| F[Fullscreen lightbox]
|
||||
F -->|close| D
|
||||
D -->|Back| C
|
||||
D -->|Back to Gallery| C
|
||||
C -->|exit doorway / E key| E[Path picker]
|
||||
E -->|predecessors| C
|
||||
E -->|successors| C
|
||||
D -->|influence thumbnail| D
|
||||
D -->|artist link| B
|
||||
B -->|Back| A
|
||||
C -->|Back| A
|
||||
B -->|Back| C
|
||||
C -->|Back to Timeline| A
|
||||
D -->|Back to Timeline when opened from search| A
|
||||
```
|
||||
|
||||
### Back navigation
|
||||
|
||||
| Control | Behaviour |
|
||||
|---------|-----------|
|
||||
| **← Back to Timeline** (3D gallery header, movement **Exit to Timeline**) | Always returns to the **home timeline**: unmounts the hall, clears the gallery session, resets timeline zoom to the full catalog year range |
|
||||
| **← Back to Gallery** (painting detail from a hall) | Returns to the **same hall session** — camera position and wing are preserved |
|
||||
| **← Back to Timeline** (painting detail opened from catalog search) | Returns to the home timeline (same as gallery **Back to Timeline**) |
|
||||
| **← Back** (artist bio) | Returns to wherever you opened bio from (usually the artist hall) |
|
||||
|
||||
Implementation: `goToTimelineHome()` in `HomePage.tsx` — do not use the browser **Back** button; it is not wired to app navigation.
|
||||
|
||||
## Timeline and movement flow
|
||||
|
||||
The home page shows two linked views over the **same year window** (`viewStart` / `viewEnd` in `HomePage.tsx`):
|
||||
The home page shows linked era + movement views over the **same year window** (`viewStart` / `viewEnd` in `HomePage.tsx`). Header links switch among three layouts; the active layout is also deep-linked:
|
||||
|
||||
| Layout | Era rail | Movement flow | Time direction | URL |
|
||||
|--------|----------|---------------|----------------|-----|
|
||||
| **Classic** (default) | `Timeline.tsx` (top bar) | `MovementBands.tsx` | Left → right | omit or `?layout=classic` |
|
||||
| **Vertical** | `VerticalTimeline.tsx` (left rail) | `VerticalMovementBands.tsx` (streams + lineage; no portraits) | Bottom → top | `?layout=vertical` |
|
||||
| **Tree of art** | `VerticalTimeline.tsx` (left rail) | `MovementTree.tsx` (lineage as a growing tree; no portraits) | Bottom → top | `?layout=tree` |
|
||||
|
||||
`HomePage` reads `?layout=` once on load and calls `history.replaceState` when the user switches. Classic clears the param so the default URL stays clean. Alias `horizontal` maps to classic.
|
||||
|
||||
| View | Component | Purpose |
|
||||
|------|-----------|---------|
|
||||
| Era bar | `Timeline.tsx` | Historical eras, major event markers, click-to-zoom |
|
||||
| Movement flow | `MovementBands.tsx` | Curved streams per movement, lineage branches, artist portraits |
|
||||
| Era bar / rail | `Timeline.tsx` / `VerticalTimeline.tsx` | Historical eras, major event markers, click-to-zoom |
|
||||
| Movement flow | `MovementBands.tsx` / `VerticalMovementBands.tsx` / `MovementTree.tsx` | Streams or tree limbs per movement; classic also places artist portraits |
|
||||
| Stream colours | `utils/movementColor.ts` | Shared `vividMovementColor` / `shadeMovementColor` for all three charts |
|
||||
|
||||
Both views share zoom/pan behaviour via `client/src/utils/timelineView.ts` (`zoomTimelineView`, `panTimelineView`, `chooseTimelineTickInterval`, `createViewChangeScheduler`). The home page uses a **fixed viewport** (`100vh`): timeline + movement flow sit in a shared `home-timeline-stack` so event guide lines can extend from the era bar down through the movement canvas. The movement flow compresses vertically so all movements in the visible year range fit without page scrolling.
|
||||
Hint captions under each rail/chart (`captionClassicTimeline`, `captionClassicFlow`, `captionVerticalTimeline`, `captionVerticalFlow`, `captionTreeFlow`) live in `locales/{en,ru}/home.json` and follow the EN|RU toggle.
|
||||
|
||||
All three layouts share zoom/pan behaviour via `client/src/utils/timelineView.ts` (`zoomTimelineView`, `panTimelineView`, `chooseTimelineTickInterval`, `createViewChangeScheduler`). The home page uses a **fixed viewport** (`100vh`). Classic stacks timeline above movements; vertical and tree place the year rail beside the flow (`home-timeline-stack-vertical`). The tree layout keeps its horizontal geometry fixed across zoom — rules in [movement-tree.md](movement-tree.md).
|
||||
### Catalog search (timeline header)
|
||||
|
||||
`CatalogSearchBar.tsx` calls `GET /api/search?q=…` (public, no login). The dropdown is stacked above the timeline (`z-index` on `.site-header`) so results are not hidden by movement bands.
|
||||
|
||||
| UX | Detail |
|
||||
|----|--------|
|
||||
| Minimum query | 2 characters after trim |
|
||||
| Debounce | 300 ms |
|
||||
| Result groups | Artists, Movements, Paintings (with thumb or movement colour swatch) |
|
||||
| Open artist | Preload images → artist 3D hall |
|
||||
| Open movement | Movement gallery (wing 1) |
|
||||
| Open painting | Painting detail with `returnTo: timeline` → **Back to Timeline** |
|
||||
| Keyboard | `↑`/`↓` highlight, `Enter` open, `Escape` close |
|
||||
|
||||
Run `npm run dev:migrate:search` once on existing databases before first use, or rely on `npm run dev:migrate` (includes `migrate-search.sql`). See [API.md — GET /api/search](API.md#get-apsearch).
|
||||
|
||||
### Timeline data loading
|
||||
|
||||
@@ -168,9 +227,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.
|
||||
@@ -200,16 +259,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
|
||||
|
||||
@@ -230,9 +290,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.
|
||||
|
||||
@@ -240,7 +300,9 @@ Only **mousedown** on portraits and movement labels stops propagation (so drag-t
|
||||
|
||||
## Virtual gallery (3D halls)
|
||||
|
||||
The 3D scene supports two modes in `VirtualGallery.tsx`: **artist halls** (personal catalog) and **movement galleries** (full movement collection, chronological).
|
||||
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 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
|
||||
|
||||
@@ -250,16 +312,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 **back, left, and right** walls in **one row per wall**; room **depth grows** when the catalog is large |
|
||||
| Corridor layout | **15+ paintings:** short back wall (up to 8 works), remaining works on extended **left/right** side walls — a long gallery corridor |
|
||||
| Wall order | On each wall, left → right: **later works on the left**, **earlier works on the right**; undated works sort toward the left |
|
||||
| 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 marks works with any influence-graph edge (`has_influence_links` from the API). All lamps in a hall share one rail height: **40 cm above the tallest allocated frame** in that hall (so they line up regardless of canvas size). 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 |
|
||||
@@ -281,25 +344,29 @@ 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 | Along each side wall: **later works on the left**, **earlier on the right** (same convention as artist halls) |
|
||||
| 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 |
|
||||
| Period architecture | Each style names a `details` kind (26 of them) that selects one component under `components/hall-details/` — the hall is built from the architecture of the **same period as the movement** (Roman entablature, Brunelleschi arcade, Flemish beam ceiling, Horta whiplash brackets, Rodchenko lattice, 1960s troffer grid). Gothic and Byzantine remain the reference implementations in `MovementHallDetails.tsx`; the geometry budget every module works to is documented in `hall-details/geometry.ts` |
|
||||
| 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, **blind-arcaded ashlar** (`gothic-ashlar`) and **ribbed vaulting** (`gothic-vault`) — 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 |
|
||||
| Architecture in texture | Period halls that need arcades, vaults, or panelling draw that **relief into the texture** rather than adding geometry: `gothic-ashlar` paints a full storey (plinth → blind arcade → string course → triforium → cornice) into one tile whose `metersPerRepeat` equals `WALL_HEIGHT`, so it maps **once vertically** onto the wall; `gothic-vault` paints a quadripartite bay with tiercerons and a boss. Light/dark banding plus a high `normalStrengthFor()` makes it read as carved stone, keeping the mesh and light budget flat |
|
||||
| Door-flanking panels | Wall segments beside exit doors and passages take the hall's own wall texture (`GalleryWall` accepts `kind`/`tint`) instead of rendering as flat single-colour blocks next to textured walls |
|
||||
| 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`). Styles are drawn in `GalleryWindows.tsx`; `gothic-lancet` builds a two-centred arch head from chords with a glazed spandrel, mullions, and transoms |
|
||||
| Lighting | Shared hall lights only (ambient / hemisphere / directional + capped ceiling track spots + one fill per window). See **light budget** below |
|
||||
| Period details | `MovementHallDetails.tsx` dispatches to one module per movement. `byzantine` adds engaged porphyry colonnettes with basket capitals, a marble revetment dado, and hanging brass polycandela; `gothic` adds bay-spaced compound piers with vault springers, transverse ribs arching across the nave, and a moulded string course. Every other movement follows the same rules in `components/hall-details/`: orders and piers stay **flush to the side walls** (≤ 0.17 m proud), freestanding masses only in the corner pockets, and no more than two added lights per hall — see `hall-details/geometry.ts` |
|
||||
| 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 (shared rail: 40 cm above the tallest frame in the wing) |
|
||||
| Missing images | Draped canvas cover in frame |
|
||||
| Detail return | Hall stays mounted; camera preserved on **Back to Timeline** / **Back to Gallery** |
|
||||
| Detail return | **Back to Gallery** from painting detail returns to the same wing with camera preserved; **Back to Timeline** exits the hall entirely |
|
||||
|
||||
**Controls (movement gallery):**
|
||||
|
||||
@@ -307,24 +374,54 @@ 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.
|
||||
|
||||
### Guided tour halls
|
||||
|
||||
Enter from the home page **Tours** popup (`GET /api/tours/:id`). Layout reuses the movement winged hall (`mode: 'tour'` in `VirtualGallery.tsx`).
|
||||
|
||||
| Rule | Implementation |
|
||||
|------|----------------|
|
||||
| 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 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) |
|
||||
|
||||
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. That floor is high enough to blow out a pale wall, so **`lightScale`** in `movement-interior-styles.ts` is the knob that holds a hall back — it multiplies the scene ambient/hemisphere/directional/fill lights, the hall point lights, the ceiling track spots, and the HDR `environmentIntensity`. Window fill lights are deliberately **not** scaled, so daylight shafts still read against the darker room.
|
||||
|
||||
It defaults to **1**, but most halls now set it: Gothic uses **0.26**, Byzantine **0.30**, and the 19 formerly pale interiors (Roman through Pop Art) also use **0.30**. Only the five halls that are already dark by albedo — Symbolist, Expressionist, Surrealist, Baroque, Romantic — stay at 1, since scaling their lights would crush walls that already sit at 0.15–0.27 luminance.
|
||||
|
||||
**Wall brightness is albedo × `lightScale`.** Gallery walls are authored well below white for that reason: the reworked interiors sit around **0.50–0.56** wall luminance (0.66 for the two white-cube halls), landing at an effective 0.14–0.20 — the range Gothic and Byzantine established. Reach for `lightScale` before darkening a tint further; the two multiply, and dimming the hall dims the paintings with it.
|
||||
|
||||
**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.
|
||||
|
||||
## Painting detail view
|
||||
|
||||
Opened from the 3D hall (artist or movement wing — click a frame) or from influence thumbnails on another work’s detail page.
|
||||
Opened from the 3D hall (artist, movement, or tour wing — click a frame) or from influence thumbnails on another work’s detail page.
|
||||
|
||||
| Layer | What you see |
|
||||
|-------|----------------|
|
||||
| **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 |
|
||||
|
||||
@@ -332,17 +429,18 @@ Opened from the 3D hall (artist or movement wing — click a frame) or from infl
|
||||
|
||||
| Input | Action |
|
||||
|-------|--------|
|
||||
| `‹` / `›` beside image | Previous / next work by the **same artist** (chronological order) |
|
||||
| `‹` / `›` beside image | Previous / next in the **current catalog** — artist chronology, movement chronology, or **tour stop order** |
|
||||
| `←` / `→` | Same as prev / next (disabled while fullscreen is open) |
|
||||
| Click centre image | Open fullscreen lightbox |
|
||||
| Click influence thumbnail | Open that work’s detail (different artist allowed) |
|
||||
| Click influence artist portrait | Open that artist’s 3D gallery hall |
|
||||
| **← Back to Gallery** / **← Back to Timeline** | Return to the hall or movement wing you entered from — **3D camera position is preserved** |
|
||||
| **← Back to Gallery** | Return to the hall or movement wing you entered from — **3D camera position is preserved** |
|
||||
| **← Back to Timeline** | Return to the home timeline (from search result, or from the 3D gallery header / movement **Exit to Timeline**) — hall unmounts, timeline zoom resets |
|
||||
| **About {artist}** | Open artist biography |
|
||||
|
||||
**Navigation rules:**
|
||||
|
||||
- **Catalog browsing** (‹ › / arrow keys) walks the current artist’s works earliest → latest. It does **not** change the back target: after browsing several works, **Back to Gallery** still returns directly to the hall.
|
||||
- **Catalog browsing** (‹ › / arrow keys) walks the active catalog (artist / movement chronology, or tour stops). It does **not** change the back target: after browsing several works, **Back to Gallery** still returns directly to the hall.
|
||||
- **Influence links** push a new detail layer; **Back** from an influenced work returns to the painting you came from (and from there back to the gallery if applicable).
|
||||
- Side-panel influence images use **`object-fit: contain`** so tall or wide works are not cropped (dark letterbox background).
|
||||
- The 3D hall stays mounted in the background while detail is open so nothing is lost on return.
|
||||
@@ -364,11 +462,13 @@ Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens
|
||||
- **Movement lineage** (`movement-lineage.ts`) documents art-historical predecessor→successor links for the flow diagram; extend that file to add or correct branches.
|
||||
- **One hall per artist** keeps navigation predictable: enter from the timeline or bio, leave via the single exit or back button.
|
||||
- **Movement galleries** complement artist halls: full movement corpus in period-themed wings, entered from the flow diagram.
|
||||
- **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
|
||||
@@ -376,23 +476,35 @@ 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**, debug API mutations |
|
||||
| **`curator`** | Named staff account | Public browse + tools allowed by their **permission flags** |
|
||||
| **`admin`** | Named staff account | All curator tools + **Users** management + **Activity** audit reports |
|
||||
|
||||
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. Admins browse reports on the **Activity** page (`AuditPage.tsx` / `/api/audit`); the API reads whatever DB the server is connected to (`DB_NAME`: `gallery_dev` on dev, `gallery_prod` on prod). 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 |
|
||||
|---------|--------|---------|
|
||||
| **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 |
|
||||
| **Logout** | Home header (curators) | Ends session; hides debug tools |
|
||||
| **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 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 |
|
||||
| **Activity** | Home header → **Activity** (admin only) | Curator audit reports: filters, summary, dated action log from `curator_audit_log` (env DB) |
|
||||
| **Tours** | Home header → **Tours** (everyone) | Open published tours in a 3D hall — [tours.md](tours.md) |
|
||||
| **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)
|
||||
@@ -405,11 +517,13 @@ When debug mode is on, a panel at the bottom-left shows the image search query,
|
||||
| **Fix it** | Replaces full image from search result; **regenerates painting thumb** (~400px JPEG) from that file | Replaces portrait; **regenerates timeline thumb** (256px) |
|
||||
| **More** | Modal with up to **20** results (resolution shown when known); thumb regenerated from chosen full image | Same |
|
||||
| **Clear** | Deletes files, clears DB paths, empty frame | Clears portrait slot |
|
||||
| **Upload** | Local file picker → full image + **auto-generated painting thumb** | Local file → portrait + **auto-generated portrait thumb** |
|
||||
| **Upload** | Local file picker (`DebugUploadButton`) → full image + **auto-generated painting thumb**; full-page **Loading…** overlay; hides current image and pauses search/fix until upload finishes | Local file → portrait + **auto-generated portrait thumb**; same upload overlay behaviour |
|
||||
| **Remove entry** | **Painting detail only** — deletes row from DB, removes image files, refreshes 3D gallery, navigates to next/previous work in catalog (or back to gallery if last work). No confirmation dialog. | — |
|
||||
|
||||
After **Fix it**, **More**, **Upload**, or **Clear**, the main view, gallery textures (paintings), and timeline portrait (artists) update without a full page reload. Painting and portrait thumbs under `data/images/*/thumbs/` are rebuilt on the server whenever a curator replaces the full image. **Remove entry** refetches artist (and movement gallery when relevant) from the API and remounts the 3D hall so the deleted frame disappears immediately.
|
||||
|
||||
Pressing **Upload** clears the debug search preview and closes **More** before the file picker opens. While uploading, **Fix it**, **More**, and **Checked** are disabled and the main painting/portrait is hidden behind a centered loading overlay.
|
||||
|
||||
Reviewed portraits show a gold border on the bio page; reviewed paintings use gold frames in the 3D hall. **Back to Gallery** returns to the live hall session, not a stale snapshot.
|
||||
|
||||
Influence side-panel thumbnails use **letterboxing** (`object-fit: contain`) so full compositions are visible.
|
||||
@@ -432,6 +546,11 @@ See [API.md](API.md#authentication) and [data-and-images.md](data-and-images.md#
|
||||
|----------|----------|
|
||||
| [setup.md](setup.md) | Install, database, npm scripts |
|
||||
| [deploy-dev-to-prod.md](deploy-dev-to-prod.md) | Release runbook + one-command `devtoprod:release` |
|
||||
| [harmonize-dev-prod.md](harmonize-dev-prod.md) | Incremental dev ↔ prod merge (catalog DB + images) |
|
||||
| [DB_structure.md](DB_structure.md) | Tables and relationships |
|
||||
| [API.md](API.md) | REST endpoints |
|
||||
| [influence-import.md](influence-import.md) | Curator Influences tool — import wizard, CRUD, graph |
|
||||
| [tours.md](tours.md) | Guided tours — editor, public popup, 3D tour hall |
|
||||
| [i18n-russian.md](i18n-russian.md) | Russian UI + entity_translations |
|
||||
| [data-and-images.md](data-and-images.md) | Image pipeline and seeding |
|
||||
| [ui-interaction-and-component-standards.md](ui-interaction-and-component-standards.md) | UI interaction, navigation, and component standards |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
Promote dev → prod files: `npm run devtoprod:images` (after `net use \\192.168.10.122\Gallery`). Refresh dev from prod: `npm run prodto:dev:images`. See [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,6 +64,8 @@ Promote dev → prod files: `npm run devtoprod:images` (after `net use \\192.168
|
||||
| `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; 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` |
|
||||
| `audit-painting-images.js` | `npm run dev:audit-painting-images` | Detect thumb/full aspect-ratio mismatches |
|
||||
@@ -181,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
|
||||
|
||||
@@ -261,9 +278,40 @@ 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 the `details` kind that selects its architecture. Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts` (colour maps in sRGB, normal maps in linear/`NoColorSpace`).
|
||||
|
||||
Wing layout (up to 55 works per wing, side-wall-only hang, window gap placement) lives in `client/src/utils/movementHallLayout.ts`. To change a movement’s look, edit its entry in `movement-interior-styles.ts` and rebuild the client.
|
||||
### Period architecture per movement
|
||||
|
||||
Every movement is built from the architecture of **its own period** — no two movements share an interior. `client/src/components/MovementHallDetails.tsx` dispatches on `style.details` (`MovementDetailKind`, 26 values) to one component:
|
||||
|
||||
| Module | Movements |
|
||||
|--------|-----------|
|
||||
| `MovementHallDetails.tsx` (reference implementations) | `gothic` — bay-spaced compound piers, vault springers, transverse ribs · `byzantine` — porphyry colonnettes, basket capitals, revetment dado, hanging polycandela |
|
||||
| `hall-details/classical.tsx` | `roman` (Tuscan half-columns, dentil entablature, Pompeian socle) · `quattrocento` (pietra serena pilasters, blind arcade, tondi) · `cinquecento` (paired fluted Corinthian pilasters, Pantheon coffers) |
|
||||
| `hall-details/courtly.tsx` | `flemish` (linenfold wainscot, beam-and-corbel ceiling) · `mannerist` (herm pilasters, banded rustication, broken cornice) · `baroque` (solomonic corner columns, modillions, velvet field panels) · `rococo` (boiserie, rocaille cartouches, painted cove) · `neoclassical` (fluted Ionic order, meander frieze) |
|
||||
| `hall-details/nineteenth.tsx` | `gothic-revival` · `bourgeois` · `north-light` · `paris-atelier` · `symbolist` · `art-nouveau` |
|
||||
| `hall-details/modernism.tsx` | `fauvist` · `expressionist` · `cubist-atelier` · `futurist` · `suprematist` · `constructivist` · `dada` · `surrealist` · `loft` · `white-cube` |
|
||||
|
||||
`hall-details/primitives.tsx` holds the wall-flush building blocks (`WallBand`, `CorniceRing`, `FlutedShaft`, capitals, `CofferedCeiling`, `HangingFixture`, `WallSconce`); `hall-details/geometry.ts` holds the shared hooks and the **geometry budget** every module has to obey, because frames hang 0.23 m proud of the wall face with a 0.7 m clear margin at each wall end:
|
||||
|
||||
- relief below the frame line stays within **0.11 m** proud; friezes and cornices above 2.6 m within **0.17 m**
|
||||
- freestanding masses only inside the **0.34 m corner pocket**; nothing under 2.6 m within 1.45 m of a wall centre (doorways)
|
||||
- the end wall carries the hall title between **3.4 m and 3.9 m** — keep it clear
|
||||
- side-wall windows are placed at runtime between 2.4 m and 3.9 m, so panels, arches and roundels up there are filtered through **`useWallClearance`** (the hall passes its computed windows into `MovementHallDetails`)
|
||||
- at most **two added lights per hall**, so hall materials stay inside the WebGL light limits the shared track lighting already budgets for
|
||||
|
||||
Gothic surfaces include **`gothic-ashlar`** (full-storey blind arcade mapped once vertically via `metersPerRepeat === WALL_HEIGHT`), **`gothic-vault`** (quadripartite rib bay), and **`gothic-stone-floor`**. Door-flanking wall panels use the hall wall texture via `GalleryWall` `kind`/`tint` so they match the textured walls.
|
||||
|
||||
### Wall brightness
|
||||
|
||||
Halls are lit by a shared rig with a high brightness floor, so a pale wall tint clips to flat white and loses its texture. Two settings control this together, and they **multiply**:
|
||||
|
||||
- **tints** — gallery walls are authored around **0.50–0.56** relative luminance (0.66 for the Suprematist and Pop white cubes, 0.15–0.27 for the deliberately dark halls). Nothing sits near white.
|
||||
- **`lightScale`** — Gothic **0.26**, Byzantine **0.30**, and every formerly pale interior **0.30**; the already-dark halls keep **1**. Window fills are not scaled, so daylight shafts stay bright.
|
||||
|
||||
When a hall reads too bright, prefer `lightScale`: it keeps the material colour believable (Byzantine's warm stone is a genuine `#a89880`, simply lit dimly) and avoids greying tints toward neutral. Remember that dimming a hall dims its paintings too — the lighting is shared, with no per-painting spots.
|
||||
|
||||
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)
|
||||
|
||||
@@ -280,9 +328,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 |
|
||||
|--------|--------|
|
||||
@@ -349,17 +410,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).
|
||||
|
||||
@@ -419,13 +480,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
|
||||
|
||||
@@ -481,7 +544,7 @@ When **Debug mode** is on (home header) or from the **Checkup** page:
|
||||
2. **More** — `GET …/debug-image-search/more` or `…/debug-portrait-search/more` returns up to 20 ranked candidates (`searchPaintingImagesMany` / `searchArtistPortraitMany`). The modal shows each thumbnail with **resolution** when the search API provides dimensions; otherwise the client probes via `GET /api/debug/image-proxy`.
|
||||
3. **Fix** — `POST …/fix-image` or `…/fix-portrait` downloads the chosen URL via `downloadImageForFix` → `replacePaintingImageFromUrl` / `replaceArtistPortraitFromUrl` in `server/image-service.js`. The server **always regenerates thumbnails from the saved full image** (`writePaintingThumb` / `writePortraitThumb` via `sharp` — not the search-result thumb URL), updates `thumbnail_path` / `portrait_thumb_path`, and sets `checkup_fixed` + `checkup_checked`.
|
||||
4. **Clear** — `POST …/clear-image` or `…/clear-portrait` deletes local file(s), nulls DB paths, sets both flags. Cleared slots stay empty in the UI (no placeholder; `checkup_fixed` prevents on-demand refetch for paintings).
|
||||
5. **Upload** — `POST …/upload-image` or `…/upload-portrait` accepts a base64-encoded file in JSON (Express body limit **20 MB**; decoded image max **15 MB**), validates with `sharp`, writes to the standard filename under `data/images/`, and regenerates the matching thumbnail the same way as **Fix it**.
|
||||
5. **Upload** — `POST …/upload-image` or `…/upload-portrait` accepts a base64-encoded file in JSON (Express body limit **20 MB**; decoded image max **15 MB**), validates with `sharp`, writes to the standard filename under `data/images/`, and regenerates the matching thumbnail the same way as **Fix it**. The file picker uses a native `<label>` + hidden `<input>` (`DebugUploadButton.tsx`) so the `change` event is reliable on Windows/Chromium.
|
||||
6. **Remove entry** (painting detail only) — `DELETE /api/paintings/:id` via `deletePainting()` in `server/image-service.js`: deletes image files, removes the DB row (cascade on influence/annotation tables), refetches artist/movement gallery data, remounts the 3D hall, and navigates to the next or previous catalog work with no confirmation dialog.
|
||||
|
||||
### Debug panel (painting detail and artist bio)
|
||||
@@ -494,10 +557,10 @@ With debug mode on, `PaintingDetail.tsx` and `ArtistBio.tsx` show a bottom-left
|
||||
| **Fix it** | `POST …/fix-image` / `…/fix-portrait` | Saves top search result to disk, **regenerates thumb from full image**, sets both flags, refreshes detail + gallery / timeline |
|
||||
| **More** | `GET …/debug-*-search/more` then fix endpoint | Modal with 20 clickable results (resolution label under each thumb); pick one to replace (thumb regenerated from downloaded full) |
|
||||
| **Clear** | `POST …/clear-image` / `…/clear-portrait` | Removes file(s), empty frame in UI |
|
||||
| **Upload** | `POST …/upload-image` / `…/upload-portrait` | Local file picker → save full image + **auto-generated thumb** |
|
||||
| **Upload** | `POST …/upload-image` / `…/upload-portrait` | Local file picker (`DebugUploadButton`) → save full image + **auto-generated thumb**; centered **Loading…** overlay; clears preview and blocks search/fix while uploading |
|
||||
| **Remove entry** | `DELETE /api/paintings/:id` | **Paintings only** — permanent delete + gallery refresh + catalog navigation |
|
||||
|
||||
The client passes `searchUrl`, `source`, and `thumbUrl` from search results to improve download reliability. After a fix, clear, upload, or remove, `HomePage` updates the gallery session and appends a revision query on texture URLs so replaced files reload even when the path is unchanged.
|
||||
The client passes `searchUrl`, `source`, and `thumbUrl` from search results to improve download reliability. After a fix, clear, upload, or remove, `HomePage` updates the gallery session. Image URLs include `?v=<mtime_ms>` from API **`image_cache_key`** / **`thumbnail_cache_key`** (file `mtime` on disk) so replaced files reload after a full page refresh even when the relative path is unchanged. In-session counters still bump immediately after a mutation.
|
||||
|
||||
Checkup **Search visible** queues search for filtered rows only (3 concurrent); it does not search the full catalog on load.
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
Step-by-step guide for promoting the **development** version of Gallery to **production**. Covers code, database schema, database data, thumbnail generation, and image files.
|
||||
|
||||
> **Default is dev.** This runbook is for a **scheduled release** (~weekly, or when you explicitly decide to ship). Day-to-day work stays on dev — see [environments.md](environments.md#development-first-workflow-default). If the one-time prod install is not done yet, follow [environments.md → One-time setup](environments.md#one-time-setup-full-walkthrough) and [infra/docker/DEPLOY-truenas.md](../infra/docker/DEPLOY-truenas.md) first.
|
||||
> **Default is dev.** This runbook is for a **scheduled release** (~weekly, or when you explicitly decide to ship). Day-to-day work stays on dev — see [environments.md](environments.md#development-first-workflow-default).
|
||||
>
|
||||
> **Mid-week merge:** If both dev and prod have catalog or image edits and you need **last-write-wins** sync instead of a full prod overwrite, use [harmonize-dev-prod.md](harmonize-dev-prod.md) (`npm run harmonize`). Keep this runbook for releases where prod should exactly match dev.
|
||||
>
|
||||
> If the one-time prod install is not done yet, follow [environments.md → One-time setup](environments.md#one-time-setup-full-walkthrough) and [infra/docker/DEPLOY-truenas.md](../infra/docker/DEPLOY-truenas.md) first.
|
||||
|
||||
| | Dev (source) | Prod (target) |
|
||||
|---|---|---|
|
||||
@@ -12,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**.
|
||||
|
||||
---
|
||||
|
||||
@@ -30,13 +34,15 @@ For a typical weekly release, use the orchestrator instead of running each step
|
||||
|
||||
2. Edit [`infra/deploy/devtoprod.config.json`](../infra/deploy/devtoprod.config.json) (gitignored — safe for SMB password):
|
||||
- `profile`: `full` | `code` | `data` (base step set; individual `steps` overrides win)
|
||||
- `steps` — per-step overrides; see table below. On **`full`** profile, `migrateProdSchema` is **on by default** (runs `dev:migrate` on `gallery_prod` before restore).
|
||||
- `autoConfirm`: `true` skips restore/image-sync prompts (`CONFIRM_PROD=1`, `-SkipConfirm`)
|
||||
- `autoStartDevStack`: `true` starts `dev:server` (or `dev:web`) automatically when `validateBuild` finds API down
|
||||
- `git.message`, `git.branch` — used when `gitCommitPush` is enabled
|
||||
- `schemaChanged`: `true` enables prod schema migration on `full` profile
|
||||
- `smb.user` / `smb.password` — optional; maps `\\host\share` before image sync
|
||||
- `backupFile` — optional fixed path; otherwise uses the newest `gallery_dev_data_*.txt` after backup
|
||||
|
||||
3. Prerequisites still apply: Docker Desktop running, `docker login gitea.mysuperlab.netcraze.pro`, [`infra/docker/.env.prod`](../infra/docker/.env.prod) present.
|
||||
3. Prerequisites still apply: **`npm run dev:web` running** (Vite `:5173` + API `:3451`), Docker Desktop running, `docker login gitea.mysuperlab.netcraze.pro`, [`infra/docker/.env.prod`](../infra/docker/.env.prod) present.
|
||||
- Before migration/restore steps, verify prod DB target in `.env.prod`: `DB_NAME=gallery_prod`.
|
||||
|
||||
### Run
|
||||
|
||||
@@ -56,15 +62,15 @@ npm run devtoprod:release -- -DryRun
|
||||
|
||||
| Config step | Maps to runbook |
|
||||
|-------------|-----------------|
|
||||
| `validateBuild` | Step 0 — `prod:build` + dev `/api/bounds` check |
|
||||
| `validateBuild` | Step 0 — `prod:build` + dev `/api/bounds` check (API `:3451` and/or Vite `:5173` proxy) |
|
||||
| `gitCommitPush` | Step 1 — `git add`, commit, push |
|
||||
| `thumbnails` | Step 2 — `devtoprod:thumbnails` |
|
||||
| `backupDev` | Step 3 — `dev:db:backup` |
|
||||
| `backupProd` | Step 3 (rollback) — `prod:db:backup` |
|
||||
| `migrateProdSchema` | Step 4 — `dev:migrate` on `gallery_prod` |
|
||||
| `migrateProdSchema` | Step 4 — `dev:migrate` on `gallery_prod` (enabled on `full` profile; auto-runs before `restoreProd` if you disabled it but left restore on) |
|
||||
| `restoreProd` | Step 5 — `devtoprod:db:restore` |
|
||||
| `syncImages` | Step 6 — `devtoprod:images` |
|
||||
| `dockerPublish` | Step 7 — `prod:docker:publish` |
|
||||
| `dockerPublish` | Step 7 — `prod:docker:publish` (auto tag: `YYYYMMDD-HHMMSS-<gitsha>` + push `latest`) |
|
||||
| `truenasRestartPause` | Step 8 — **manual** pause; script waits for Enter after you restart **gallery-web** |
|
||||
| `verify` | Step 9 — curl LAN + public `/api/bounds` |
|
||||
|
||||
@@ -84,13 +90,13 @@ Use the manual steps below when you need a **partial** release or want to inspec
|
||||
|
||||
## What changed → which steps to run
|
||||
|
||||
Run only the steps that match what you changed. Steps are independent except that **code** needs a rebuilt image and **schema changes** must be applied before a **data** restore.
|
||||
Run only the steps that match what you changed. Steps are independent except that **code** needs a rebuilt image and **prod schema must match the dev backup** before a data restore (Step 4 before Step 5).
|
||||
|
||||
| You changed… | Required steps |
|
||||
|--------------|----------------|
|
||||
| Application code (client/server) | 0 → 1 → 7 → 8 → 9 |
|
||||
| DB schema (new `db/migrate-*.sql`, `schema.sql`) | 0 → 1 → 3 → **4** → (5 if data too) → 8 → 9 |
|
||||
| Catalog data (movements, artists, paintings, influences, bios) | 0 → 2 → 3 → 5 → 9 |
|
||||
| Catalog data (movements, artists, paintings, influences, bios) | 0 → 2 → 3 → **4** → 5 → 9 |
|
||||
| Image files (new/replaced paintings, portraits) | 0 → 2 → 3 → 5 → 6 → 9 |
|
||||
| Thumbnails only (rebuild from existing full images) | 0 → 2 → 3 → 5 → 6 → 9 |
|
||||
| Everything (typical weekly release) | 0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 |
|
||||
@@ -238,10 +244,11 @@ The restore loads rows in two ways automatically:
|
||||
|
||||
Multi-line values (e.g. artist bios with embedded newlines) are parsed as whole statements, so long text restores correctly.
|
||||
|
||||
**Caveats — prod tables are replaced by dev's contents:**
|
||||
**Caveats — prod catalog tables are replaced by dev's contents:**
|
||||
|
||||
- `users` and `curator_audit_log` are overwritten. The **dev curator account and password become the prod login**, and prod audit history is replaced. Make sure the dev curator credentials are the ones you want in prod.
|
||||
- The `session` table is truncated, so any active prod curator sessions are logged out.
|
||||
- **`users`**, **`session`**, and **`curator_audit_log`** are **not** truncated or loaded from the backup. Prod staff accounts, passwords, active sessions, and audit history stay as they are on `gallery_prod`.
|
||||
- Catalog / content tables (`artists`, `paintings`, tours, translations, etc.) are fully replaced by the dev dump.
|
||||
- After load, serial sequences are reset to `MAX(id)` so new rows (including staff users) do not collide with restored ids.
|
||||
- The target is guarded: the restore refuses to run unless the database name ends with `_prod` and only reads `infra/docker/.env.prod`.
|
||||
|
||||
> **Optional** — to use the faster single-pass load, have the postgres superuser run this once in pgAdmin (role-global, covers dev and prod): `GRANT SET ON PARAMETER session_replication_role TO gallery;`
|
||||
@@ -358,6 +365,7 @@ Keep at least the most recent `gallery_prod_data_*.txt` from Step 3 so a data ro
|
||||
- Prod DB scripts read **only** `infra/docker/.env.prod`; dev scripts refuse `_prod` database names.
|
||||
- Prod restore/backup require typing `yes` (or `CONFIRM_PROD=1` / `autoConfirm` in release config).
|
||||
- `npm run dev:migrate` targets whatever `DB_NAME` is set — always `Remove-Item Env:\DB_NAME` after Step 4 so later commands stay on dev.
|
||||
- **`restoreProd` safety:** if `migrateProdSchema` is disabled in config but `restoreProd` is enabled, the orchestrator runs Step 4 first so prod has columns/tables present in the dev backup (e.g. `updated_at`, `entity_translations`).
|
||||
- Deploy-related npm scripts and PowerShell helpers print a final **`===== SUCCESS: … =====`** or **`===== FAILED: … =====`** banner as the last line of output.
|
||||
|
||||
## Troubleshooting
|
||||
@@ -367,6 +375,7 @@ Keep at least the most recent `gallery_prod_data_*.txt` from Step 3 so a data ro
|
||||
| `/api/bounds` returns `{"min_year":null,"max_year":null}` on prod | `gallery_prod` has schema but no data | Run Step 5 (restore); confirm rows with a count query on `art_movements` / `paintings` |
|
||||
| `robocopy ... ERROR 5 (0x00000005) Access is denied` on Step 6 | SMB share not mapped/authenticated in this Windows session | `net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER`, verify `Test-Path`, then re-run. If still denied after auth, fix TrueNAS dataset ACL (`chown -R 1001:1001`, grant the SMB user write) |
|
||||
| Restore: `permission denied to set parameter "session_replication_role"` | DB role is not superuser | Handled automatically (multi-pass insert). For the fast path, superuser runs `GRANT SET ON PARAMETER session_replication_role TO gallery;` |
|
||||
| Restore: `column "updated_at" of relation … does not exist` | Prod schema behind dev backup | Run Step 4 before Step 5; `full` profile enables `migrateProdSchema` by default; partial config with `restoreProd` alone also auto-migrates |
|
||||
| Restore: `unterminated quoted string` | Old parser split multi-line values (bios) | Fixed — statements are accumulated until quotes balance; update to latest `scripts/restore-db-data.js` |
|
||||
| `git push` → `Failed to authenticate user` (Gitea) | Git Credential Manager cached an expired token | Clear it: `"protocol=https`nhost=gitea.mysuperlab.netcraze.pro`n" \| git credential reject`, then push again to re-prompt |
|
||||
| Prod tables empty after a failed restore | Restore truncates **before** inserting; a mid-run error leaves tables empty | Dev is untouched — just re-run Step 5 |
|
||||
|
||||
@@ -7,6 +7,11 @@ Gallery uses **one PostgreSQL server** on TrueNAS (`192.168.10.122`) with **two
|
||||
| **Development** | `gallery_dev` | `https://devgallery.mysuperlab.netcraze.pro` | Dev PC `192.168.10.70:5173` |
|
||||
| **Production** | `gallery_prod` | `https://gallery.mysuperlab.netcraze.pro` | TrueNAS container `192.168.10.122:5173` |
|
||||
|
||||
Version tracking:
|
||||
- Runtime version endpoint: `/api/version` (reports `app_env`, `app_version`, `image_tag`, `git_sha`, `built_at`, `db_name`)
|
||||
- `npm run prod:docker:publish` auto-creates a release tag like `20260709-155412-f72ddcc` (build time + git commit), pushes it and `latest`
|
||||
- Version metadata is baked into the Docker image; TrueNAS only needs `gallery-web:latest` with `pull_policy: always`
|
||||
|
||||
---
|
||||
|
||||
## Development-first workflow (default)
|
||||
@@ -85,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
|
||||
```
|
||||
@@ -108,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. Admins browse curator actions on **Activity** (`/api/audit`), which always reads the DB named by `DB_NAME` for that environment (`gallery_dev` here; `gallery_prod` on prod). **`users`**, **`session`**, and **`curator_audit_log`** are never copied by harmonize or `devtoprod:db:restore` — each env keeps its own staff accounts and audit history.
|
||||
|
||||
2. Run:
|
||||
|
||||
```powershell
|
||||
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
|
||||
cd T:\Repo\Gallery
|
||||
npm run dev:migrate
|
||||
npm run dev:web
|
||||
```
|
||||
@@ -151,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
|
||||
```
|
||||
|
||||
@@ -168,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
|
||||
```
|
||||
@@ -249,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`.
|
||||
|
||||
@@ -300,6 +303,7 @@ See also [Drunkmeyou gitea-https-keenetic-npm-setup.md](../../Drunkmeyou/Documen
|
||||
| Fast local HMR (no Keenetic) | `npm run dev:server` + `npm run dev:client` |
|
||||
| Refresh dev DB from prod | `npm run prodto:dev:db` |
|
||||
| Pull prod images to dev | `npm run prodto:dev:images` |
|
||||
| Merge dev ↔ prod catalog + images (incremental) | `npm run harmonize` — see [harmonize-dev-prod.md](harmonize-dev-prod.md) |
|
||||
|
||||
---
|
||||
|
||||
@@ -307,6 +311,8 @@ See also [Drunkmeyou gitea-https-keenetic-npm-setup.md](../../Drunkmeyou/Documen
|
||||
|
||||
Run this when you are ready to ship dev to production — **not** after every small change. Typical cadence: **about once a week**.
|
||||
|
||||
For **incremental** dev ↔ prod merge (both sides edited), use [harmonize-dev-prod.md](harmonize-dev-prod.md) instead of full restore.
|
||||
|
||||
**One command (recommended):** copy [`infra/deploy/devtoprod.config.example.json`](../infra/deploy/devtoprod.config.example.json) to `infra/deploy/devtoprod.config.json`, edit it, then `npm run devtoprod:release` or `deploy-dev-to-prod.cmd`. See [deploy-dev-to-prod.md → One-command release](deploy-dev-to-prod.md#one-command-release-automated).
|
||||
|
||||
**Manual steps:** detailed runbook (per-change decision matrix, schema migration, rollback): [deploy-dev-to-prod.md](deploy-dev-to-prod.md).
|
||||
@@ -333,11 +339,15 @@ Run this when you are ready to ship dev to production — **not** after every sm
|
||||
| `npm run prodto:dev:db` | Dev PC PowerShell | Clone prod → dev |
|
||||
| `npm run dev:db:backup` | Dev PC PowerShell | Dev backup |
|
||||
| `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 + artists/paintings checkup/path sync + regenerate thumbs on both sides |
|
||||
|
||||
## Image sync
|
||||
|
||||
| Command | Where | Direction |
|
||||
|---------|-------|-----------|
|
||||
| `npm run harmonize` | Dev PC PowerShell | Bidirectional merge (mtime newer wins) — [harmonize-dev-prod.md](harmonize-dev-prod.md) |
|
||||
| `npm run devtoprod:release` | Dev PC PowerShell | Full config-driven promote (see [deploy-dev-to-prod.md](deploy-dev-to-prod.md#one-command-release-automated)) |
|
||||
| `npm run devtoprod:thumbnails` | Dev PC PowerShell | Rebuild painting + portrait thumbs on dev before promote |
|
||||
| `npm run devtoprod:images` | Dev PC PowerShell | Dev → TrueNAS volume |
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# Harmonize dev and prod (incremental merge)
|
||||
|
||||
Bidirectional **catalog data** and **image** sync between `gallery_dev` and `gallery_prod`, with **last-write-wins** by timestamp. Use this when **both** environments may have curator edits since the last release — not when prod should become an exact copy of dev.
|
||||
|
||||
For a full prod replace (weekly release), use [deploy-dev-to-prod.md](deploy-dev-to-prod.md) (`npm run devtoprod:release`).
|
||||
|
||||
For refreshing dev from prod entirely, use `npm run prodto:dev:db` (destructive to dev).
|
||||
|
||||
---
|
||||
|
||||
## Rules
|
||||
|
||||
| Layer | Direction | Conflict resolution |
|
||||
|-------|-----------|---------------------|
|
||||
| **Schema** | dev → prod only | Run `npm run harmonize:schema` (same as `dev:migrate` on `gallery_prod`) |
|
||||
| **Catalog DB** | dev ↔ prod | Newer `updated_at` wins; missing rows copied to the other side (union merge) |
|
||||
| **Images** | dev ↔ prod | Newer file mtime wins; missing files copied to the other side |
|
||||
| **Users / sessions / audit** | not synced | `users`, `session`, `curator_audit_log` stay env-local |
|
||||
|
||||
**Never auto-deletes** rows or files that exist on only one side.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Both databases on Postgres `192.168.10.122` with `updated_at` columns applied:
|
||||
|
||||
```powershell
|
||||
npm run dev:migrate
|
||||
$env:DB_NAME = "gallery_prod"; npm run dev:migrate; Remove-Item Env:\DB_NAME
|
||||
```
|
||||
|
||||
2. Backfill `updated_at` from image file mtimes (recommended once after migration):
|
||||
|
||||
```powershell
|
||||
npm run dev:backfill-updated-at
|
||||
npm run harmonize:backfill-updated-at
|
||||
```
|
||||
|
||||
3. [`infra/docker/.env.prod`](../infra/docker/.env.prod) present with `DB_NAME=gallery_prod`.
|
||||
|
||||
4. SMB share reachable for prod images:
|
||||
|
||||
```powershell
|
||||
net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
```powershell
|
||||
npm run harmonize
|
||||
```
|
||||
|
||||
Dry-run (report only, no writes):
|
||||
|
||||
```powershell
|
||||
npm run harmonize -- -DryRun
|
||||
```
|
||||
|
||||
Or:
|
||||
|
||||
```powershell
|
||||
npm run harmonize:dry-run
|
||||
```
|
||||
|
||||
### Orchestrator steps
|
||||
|
||||
| Step | npm script | Purpose |
|
||||
|------|------------|---------|
|
||||
| `backupDev` | `dev:db:backup` | Safety snapshot |
|
||||
| `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, 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).
|
||||
|
||||
---
|
||||
|
||||
## Individual commands
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `npm run harmonize:schema` | Migrate prod schema from dev migration files |
|
||||
| `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, 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 |
|
||||
|
||||
---
|
||||
|
||||
## Catalog tables synced
|
||||
|
||||
Processed in FK order:
|
||||
|
||||
`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`).
|
||||
|
||||
---
|
||||
|
||||
## Conflict handling
|
||||
|
||||
Harmonize reports conflicts in the JSON report and skips those rows:
|
||||
|
||||
| Conflict | Cause | Resolution |
|
||||
|----------|-------|------------|
|
||||
| `id_collision` | Same `id` but different natural key (e.g. artist name) | Manual fix in pgAdmin; environments diverged too far |
|
||||
| `equal_updated_at` | Same timestamp, different row content | Re-run with `--prefer=dev` or `--prefer=prod`, or edit one side and re-run |
|
||||
|
||||
**Tip:** Harmonize regularly from a shared baseline (e.g. after each weekly release) to avoid ID/natural-key collisions from independent inserts.
|
||||
|
||||
---
|
||||
|
||||
## When to use what
|
||||
|
||||
| Situation | Tool |
|
||||
|-----------|------|
|
||||
| Weekly release — prod should match dev exactly | `npm run devtoprod:release` |
|
||||
| Mid-week prod curator fix + dev also changed | `npm run harmonize` |
|
||||
| Dev workspace stale — full prod copy | `npm run prodto:dev:db` |
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
## Example flows
|
||||
|
||||
### Prod curator uploaded a painting; dev also edited metadata
|
||||
|
||||
```powershell
|
||||
npm run harmonize
|
||||
```
|
||||
|
||||
DB rows merge by `updated_at`; image files merge by mtime. Both sides receive the latest version of each entity.
|
||||
|
||||
### Schema change + mixed edits
|
||||
|
||||
1. Finish and test on dev: `npm run dev:migrate`
|
||||
2. Set `schemaChanged: true` in `harmonize.config.json` (or enable `steps.schema`)
|
||||
3. `npm run harmonize`
|
||||
|
||||
### Preview before writing
|
||||
|
||||
```powershell
|
||||
npm run harmonize:dry-run
|
||||
# Review db/SyncReports/harmonize_*.json
|
||||
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)
|
||||
- Prod writes require `yes` or `CONFIRM_PROD=1` (orchestrator sets `CONFIRM_PROD=1` when `autoConfirm`-style run)
|
||||
- No TRUNCATE — harmonize only inserts/updates changed rows
|
||||
- Rollback: restore from `db/DataBackup/gallery_*_data_*.txt` using `dev:db:restore` or `devtoprod:db:restore`
|
||||
|
||||
See also [environments.md](environments.md) and [deploy-dev-to-prod.md](deploy-dev-to-prod.md).
|
||||
@@ -0,0 +1,118 @@
|
||||
# Russian localization (i18n)
|
||||
|
||||
Full Russian support: **UI chrome** via `react-i18next`, **catalog text** via PostgreSQL `entity_translations`, with Cyrillic display aliases where available and English fallback.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
| Layer | English | Russian |
|
||||
|-------|---------|---------|
|
||||
| UI labels, buttons | `client/src/locales/en/*.json` | `client/src/locales/ru/*.json` |
|
||||
| Names / titles (display) | canonical DB columns | `entity_translations` (`name`, `title`) |
|
||||
| Bios, notes, descriptions | canonical DB columns | `entity_translations` (`bio_full`, `body`, `notes`, …) |
|
||||
|
||||
English remains canonical in main tables. Russian rows use `status`: `draft` → `reviewed` → `published`. Public API returns only **`published`** (unless curator preview).
|
||||
|
||||
---
|
||||
|
||||
## User-facing locale switch
|
||||
|
||||
Timeline header: **EN | RU** toggle (`LocaleSwitcher`).
|
||||
|
||||
- Persists `gallery_locale` in `localStorage`
|
||||
- Sets `document.documentElement.lang`
|
||||
- Passes `?locale=ru` on catalog API requests
|
||||
- Refetches bootstrap catalog when locale changes
|
||||
|
||||
Timeline **layout** chrome and chart hints are also localised in `home.json`:
|
||||
|
||||
| Key | Where |
|
||||
|-----|--------|
|
||||
| `layoutHorizontal` / `layoutVertical` / `layoutTree` | Header layout switch |
|
||||
| `captionClassicTimeline` / `captionClassicFlow` | Classic era bar + movement streams |
|
||||
| `captionVerticalTimeline` / `captionVerticalFlow` | Vertical rail + streams |
|
||||
| `captionTreeFlow` | Tree of Art chart |
|
||||
|
||||
Shareable layout URLs (`?layout=tree` etc.) are language-independent; captions follow the active locale.
|
||||
---
|
||||
|
||||
## Setup (dev)
|
||||
|
||||
```powershell
|
||||
npm run dev:migrate # includes migrate-i18n.sql
|
||||
npm run dev:fetch-artist-bios-ru # draft bios + Cyrillic names from ru.wikipedia
|
||||
# Curator: Translations page → review → Publish
|
||||
npm run dev:import-translations -- --file path/to/translations.json --publish
|
||||
```
|
||||
|
||||
Optional manual import JSON:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "entity_type": "painting", "entity_id": 42, "field_name": "title", "value": "Джоконда", "status": "published", "source": "manual" }
|
||||
]
|
||||
```
|
||||
|
||||
CSV: `entity_type,entity_id,field_name,value,status,source`
|
||||
|
||||
---
|
||||
|
||||
## Curator translation review
|
||||
|
||||
1. Sign in as curator
|
||||
2. Header → **Translations** (or **Переводы** in RU UI)
|
||||
3. Pick entity type (artist / painting / movement)
|
||||
4. Select row → edit Russian fields side-by-side with English canonical
|
||||
5. **Publish** saves and marks rows `published`
|
||||
|
||||
Coverage stats show artists with `bio_full`, paintings with `title` alias, draft vs published counts.
|
||||
|
||||
API (curator-only): see [API.md](API.md#translations-curator).
|
||||
|
||||
---
|
||||
|
||||
## Translatable fields (v1)
|
||||
|
||||
| entity_type | fields |
|
||||
|-------------|--------|
|
||||
| `era`, `movement` | `name`, `description` |
|
||||
| `artist` | `name`, `bio_short`, `bio_full` |
|
||||
| `artist_period` | `name`, `description` |
|
||||
| `painting` | `title`, `description` (not `curator_notes` — English-only for now) |
|
||||
| `annotation` | `label`, `body` |
|
||||
| `influence_source` | `notes`, `aspects`, `quote`, `period_note` |
|
||||
|
||||
---
|
||||
|
||||
## API locale
|
||||
|
||||
Public endpoints accept `?locale=ru` or `Accept-Language: ru`. Responses include `"locale": "ru"` on catalog payloads; field names unchanged — values are already resolved.
|
||||
|
||||
Search matches canonical text **or** published Russian **name** / **title** aliases (partial index `entity_translations_search_alias_idx`; long `bio_full` text is stored but not btree-indexed).
|
||||
|
||||
---
|
||||
|
||||
## Prod rollout
|
||||
|
||||
1. `npm run dev:migrate` on dev; curator review + publish Russian rows on dev
|
||||
2. **`npm run devtoprod:release`** (`full` profile) — includes Step 4 schema migrate + Step 5 restore; or `npm run harmonize:schema` then harmonize/sync if you prefer merge over full promote
|
||||
3. After restore, verify locale toggle on https://gallery.mysuperlab.netcraze.pro
|
||||
|
||||
`entity_translations` is included in [`harmonize-db.js`](../scripts/harmonize-db.js) catalog sync.
|
||||
|
||||
### `dev:fetch-artist-bios-ru` index limit
|
||||
|
||||
Early `migrate-i18n.sql` indexed all `value` text; very long Russian bios (e.g. Michelangelo) could fail with `index row size … exceeds btree maximum`. Current migration uses a **partial** index on `name` and `title` only (`char_length(value) <= 512`). Re-run `npm run dev:migrate` (or prod Step 4) after pulling this fix, then re-run `npm run dev:fetch-artist-bios-ru` for any artists that failed.
|
||||
|
||||
---
|
||||
|
||||
## npm scripts
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `dev:migrate:i18n` | Apply `entity_translations` table only |
|
||||
| `dev:fetch-artist-bios-ru` | Fetch ru.wikipedia bios into translations (draft) |
|
||||
| `dev:import-translations` | Import JSON/CSV translation rows |
|
||||
|
||||
See also [setup.md](setup.md), [environments.md](environments.md), [harmonize-dev-prod.md](harmonize-dev-prod.md).
|
||||
@@ -0,0 +1,92 @@
|
||||
# Influence links import & curator tool
|
||||
|
||||
Curator tool to **list / add / delete** influence edges, **visualize** an artist neighborhood, and **import** CSV / JSON / XLSX files through a mapping wizard.
|
||||
|
||||
Entry: header **Influences** (curator session). Data lives in `painting_influence_sources` (legacy `painting_influences` mirrored for painting→painting).
|
||||
|
||||
---
|
||||
|
||||
## Expansion rule (artist-level files)
|
||||
|
||||
Input workbooks such as [`Inputs/artist_influences_web_sources.xlsx`](../Inputs/artist_influences_web_sources.xlsx) are **artist-centric**. On import:
|
||||
|
||||
- **Influenced by** tokens → attach as sources on **all paintings** of the subject artist
|
||||
- **Influenced** tokens that resolve to an artist → reverse link: subject artist becomes a source on **all paintings** of the influenced artist (same as PainterPalette `Influencedon`)
|
||||
|
||||
Unresolved names (artists / movements / paintings not in the DB) are **skipped** with warnings — no auto-create stubs.
|
||||
|
||||
---
|
||||
|
||||
## Wizard column roles
|
||||
|
||||
| Role | Meaning |
|
||||
|------|---------|
|
||||
| `subject_artist` | Artist the row is about (required) |
|
||||
| `subject_painting` | Optional work hint (contextual; expansion still uses all works) |
|
||||
| `influenced_by` | Who/what influenced the subject (`;` / `,` separated) |
|
||||
| `influenced` | Who the subject influenced |
|
||||
| `notes` / `reference` / `source_url` | Citation metadata (URLs scraped from reference text) |
|
||||
| `ignore` | Skip column |
|
||||
|
||||
### Presets
|
||||
|
||||
| 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):
|
||||
|
||||
- `contentHash` — raw file bytes
|
||||
- `payloadHash` — normalized mapped rows (same data under another filename still matches)
|
||||
|
||||
On parse/preview, if either hash matches a prior import, the UI warns and **blocks commit** unless the curator checks **Import anyway (force)**. Individual edges remain unique via DB `ON CONFLICT` either way.
|
||||
|
||||
---
|
||||
|
||||
## API (curator)
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/influences` | List (`artistId`, `paintingId`, `q`, `limit`, `offset`) |
|
||||
| `GET` | `/api/influences/graph?artistId=` | Nodes + edges for SVG neighborhood |
|
||||
| `POST` | `/api/influences` | Create one edge |
|
||||
| `PATCH` | `/api/influences/:id` | Update metadata / remap source |
|
||||
| `DELETE` | `/api/influences/:id` | Delete (+ legacy mirror) |
|
||||
| `POST` | `/api/influences/import/parse` | `{ filename, contentBase64, sheet? }` → columns, hashes, `alreadyImported` |
|
||||
| `POST` | `/api/influences/import/preview` | `{ rows, mapping, contentHash?, payloadHash? }` → proposals + duplicate check |
|
||||
| `POST` | `/api/influences/import/commit` | `{ proposals, fileName?, contentHash?, payloadHash?, force? }` — `409 ALREADY_IMPORTED` unless `force` |
|
||||
|
||||
Audit: `influence.create` / `update` / `delete` / `import` in `curator_audit_log`.
|
||||
|
||||
---
|
||||
|
||||
## CLI still available
|
||||
|
||||
- `npm run dev:update-influences` — curated [`scripts/art-influences-data.js`](../scripts/art-influences-data.js)
|
||||
- `npm run dev:import-painter-palette` — PainterPalette CSV
|
||||
|
||||
The wizard is the interactive path for ad-hoc spreadsheets under `Inputs/`.
|
||||
|
||||
See also [API.md](API.md), [DB_structure.md](DB_structure.md), [Plans.md](Plans.md).
|
||||
@@ -0,0 +1,107 @@
|
||||
# Tree of Art — alternative start page
|
||||
|
||||
An alternative landing layout for the timeline: the year axis runs **bottom → top**
|
||||
and the art movements are drawn as a **growing tree** instead of parallel streams.
|
||||
Reached from the **🌳 Tree of art** link at the top-left of every timeline page
|
||||
(`layoutTree`); the classic and vertical layouts stay untouched and are one click away.
|
||||
Shareable URL: `?layout=tree` (also `vertical` / omit or `classic` for the other layouts).
|
||||
|
||||
| | Classic | Vertical | **Tree** |
|
||||
|---|---|---|---|
|
||||
| Component | [`MovementBands`](../client/src/components/MovementBands.tsx) | [`VerticalMovementBands`](../client/src/components/VerticalMovementBands.tsx) | [`MovementTree`](../client/src/components/MovementTree.tsx) |
|
||||
| Time axis | left → right | bottom → top | bottom → top |
|
||||
| Layout | temporal lanes | centre-out lanes | spanning tree |
|
||||
| Recomputed on zoom | yes | yes | **no — structure is fixed** |
|
||||
| Artist portraits | yes | no | no |
|
||||
|
||||
Both bottom-up layouts share the [`VerticalTimeline`](../client/src/components/VerticalTimeline.tsx)
|
||||
axis, so eras, event marks, zoom and pan behave identically across them.
|
||||
|
||||
## Layout rules
|
||||
|
||||
The geometry splits in two: [`utils/movementTree.ts`](../client/src/utils/movementTree.ts)
|
||||
decides the **shape of the tree** (horizontal, view-independent), and `MovementTree`
|
||||
maps that shape onto the **current year window** (vertical) each frame.
|
||||
|
||||
### Structure — `buildMovementTree()`
|
||||
|
||||
1. **Time grows upward.** Oldest movements at the bottom, newest at the crown.
|
||||
Y is a plain `year → pixel` mapping; the layout engine never touches it.
|
||||
2. **One trunk, at the centre.** `MOVEMENT_LINEAGE` is a DAG, so it is reduced to a
|
||||
spanning tree: each movement keeps its **most immediate predecessor** (the parent
|
||||
with the latest start year that still precedes it) as its structural parent.
|
||||
Ranking by start year first makes cycles impossible by construction.
|
||||
3. **Extra parents become grafts.** The predecessors that lost step 2 are still drawn —
|
||||
as thin, low-opacity limbs behind the tree — so `Post-Impressionism → Cubism`
|
||||
survives even though Cubism hangs structurally off Fauvism.
|
||||
4. **Children split the parent's slot.** Each node reserves a slot as wide as its whole
|
||||
subtree (`max(own limb, Σ children)`); children are packed side by side and centred
|
||||
on the parent. A single-child chain inherits the parent's x exactly — the trunk stays
|
||||
straight until it forks, forks spread symmetrically, and later generations land
|
||||
further from the centre.
|
||||
5. **Leonardo's rule for thickness.** A limb is as thick as the limbs it carries:
|
||||
`base² = own² + Σ child.base²`. The trunk is the thickest thing on screen and every
|
||||
branch tapers as it rises and sheds children. A movement's *own* thickness comes from
|
||||
its `influence_link_count`.
|
||||
6. **Branches lean outward** across their own lifespan, by at most the slack left inside
|
||||
their slot — organic, and collision-free by construction.
|
||||
7. **Unlinked movements are saplings.** A movement with no lineage edge is its own root;
|
||||
extra roots are planted alternately right and left of the trunk, widest subtree first,
|
||||
so the main trunk keeps x = 0 (canvas centre). Roots get a small root flare.
|
||||
|
||||
Because the structure is built from the **whole catalogue**, zooming never reshuffles the
|
||||
tree — you keep your bearings, unlike the lane-packed layouts which re-pack on every view
|
||||
change.
|
||||
|
||||
### Rendering — `MovementTree`
|
||||
|
||||
8. **Spread follows zoom, not the canvas.** 90 % of the catalogue lives in the last 15 %
|
||||
of the time axis, so a tree stretched to full width with all of history in view is one
|
||||
long trunk under a flat bar. The whole-history view draws the tree at
|
||||
`FULL_VIEW_WIDTH_SHARE` (52 %) of the available width, and each zoom step fans the
|
||||
crown out (`(totalSpan / visibleSpan) ^ 0.45`, capped at `MAX_FIT_BOOST`). The chart
|
||||
grows as you walk up it. Whatever is on screen is always clamped to fit the canvas.
|
||||
9. **Readability floors, never date changes.** A 30-year movement is ~8 px tall with 2 900
|
||||
years in view. So a limb is drawn at least `MIN_LIMB_RISE_PX` long, a junction climbs at
|
||||
least `MIN_JUNCTION_RISE_PX` before it spreads sideways, and **a limb is never thicker
|
||||
than 55 % of its own length**. Positions still come from real years; only the drawn
|
||||
length and thickness have a floor, and the junction slides down the parent limb (never
|
||||
off it) to find its rise.
|
||||
10. **Ribbons, not strokes.** Limbs are filled ribbons sampled along a cubic and offset
|
||||
along the curve *normal*, so a junction stays solid even when a zoomed-out view
|
||||
squeezes it almost flat. Shading runs dark → colour → dark across each limb for a
|
||||
rounded, woody read.
|
||||
11. **Greedy label declutter.** Every visible movement asks for a name; closest to the
|
||||
trunk wins, and names that would collide with a placed one — or fall off the canvas —
|
||||
stay hidden until you zoom in on them.
|
||||
12. **Hover lights the descent line.** Hovering a movement brightens its whole path back
|
||||
to the root (grafts included) and dims the rest — the fastest way to read "where did
|
||||
this come from".
|
||||
|
||||
Clicking any limb or label opens the movement's artist picker and then its 3D movement
|
||||
gallery, exactly as the other two layouts do.
|
||||
|
||||
## Colours and captions
|
||||
|
||||
- Limb fill/shading uses [`utils/movementColor.ts`](../client/src/utils/movementColor.ts)
|
||||
(`vividMovementColor`, `shadeMovementColor`) — the same helpers as the classic and
|
||||
vertical charts. Malformed catalogue hex falls back to the input string; values longer
|
||||
than six digits keep the first six (`#rrggbbaa` → `#rrggbb`).
|
||||
- The chart hint under the canvas is `captionTreeFlow` in
|
||||
`locales/{en,ru}/home.json` (same pattern as the other layouts).
|
||||
|
||||
## Tuning
|
||||
|
||||
All constants sit at the top of the two files and are safe to tune:
|
||||
|
||||
| Constant | File | Effect |
|
||||
|---|---|---|
|
||||
| `LEAF_SLOT_PX`, `LIMB_GAP_PX` | `movementTree.ts` | how far apart branches sit |
|
||||
| `MIN_LIMB_PX`, `MAX_OWN_LIMB_PX`, `MAX_TRUNK_PX` | `movementTree.ts` | thickness range |
|
||||
| `LEAN_SLACK`, `MAX_LEAN_PX` | `movementTree.ts` | how much limbs bend outward |
|
||||
| `FULL_VIEW_WIDTH_SHARE`, `ZOOM_SPREAD_EXPONENT`, `MAX_FIT_BOOST` | `MovementTree.tsx` | crown spread vs. zoom |
|
||||
| `MIN_LIMB_RISE_PX`, `MIN_JUNCTION_RISE_PX`, `MAX_THICKNESS_OF_LENGTH` | `MovementTree.tsx` | crown legibility at full zoom-out |
|
||||
|
||||
New lineage edges only need adding to
|
||||
[`client/src/data/movement-lineage.ts`](../client/src/data/movement-lineage.ts) — the tree
|
||||
picks up parents, grafts, thickness and spacing from there automatically.
|
||||
+26
-9
@@ -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,9 +70,9 @@ 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, and all mutating debug APIs require an active curator session. Anonymous visitors browse the timeline and 3D halls without logging in.
|
||||
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.
|
||||
|
||||
See [API.md — Authentication](API.md#authentication) and [basics.md — Developer tools](basics.md#developer-tools-image-audit).
|
||||
|
||||
@@ -88,6 +88,10 @@ npm run dev:expand-catalog # famous works for artists below MIN_PAIN
|
||||
npm run dev:update-influences # painting influence graph for detail view + hall exits
|
||||
npm run dev:migrate:checkup-flags # optional: review/fixed flags for Checkup page (paintings)
|
||||
npm run dev:migrate:artist-checkup-flags # optional: same flags for artist portraits (bio debug)
|
||||
npm run dev:migrate:search # optional on very old DBs — also applied by dev:migrate / prod Step 4
|
||||
npm run dev:migrate:i18n # optional — entity_translations (also in dev:migrate)
|
||||
npm run dev:fetch-artist-bios-ru # draft Russian bios + Cyrillic names from ru.wikipedia
|
||||
npm run dev:import-translations -- --file path/to/file.json # bulk translation import
|
||||
npm run dev:migrate:painting-annotations # optional: art-history notes table
|
||||
npm run dev:update-painting-annotations # optional: load curated notes (+ --wikipedia for Wikipedia intros)
|
||||
npm run dev:fetch-images -- --limit=50 # random sample; 10s max per painting (default)
|
||||
@@ -108,14 +112,15 @@ Image fetch can take hours if you run it for the entire catalog. The first line
|
||||
| `npm run devtoprod:images` | Copy `data/images/` → TrueNAS via SMB `Gallery` share |
|
||||
| `npm run prodto:dev:images` | Copy prod images → dev repo |
|
||||
| `npm run prodto:dev:db` | Clone `gallery_prod` → `gallery_dev` |
|
||||
| `npm run dev:db:backup` / `devtoprod:db:restore` | Dev backup / promote DB to prod |
|
||||
| `npm run dev:db:backup` / `devtoprod:db:restore` | Dev backup / promote catalog DB to prod (prod restore skips staff/session/audit; syncs sequences) |
|
||||
| `npm run harmonize` | Bidirectional catalog + image merge (last-write-wins) — [harmonize-dev-prod.md](harmonize-dev-prod.md) |
|
||||
| `npm run prod:build` | Build production SPA into `client/dist` |
|
||||
| `npm run dev:start` | API + static SPA on `HOST`:`PORT` (uses root `.env`) |
|
||||
| `npm run prod:start` | Build client, then start server |
|
||||
| `npm run dev:server` | API with nodemon reload (local HMR workflow) |
|
||||
| `npm run dev:client` | Vite dev server on :5173 |
|
||||
|
||||
See [environments.md](environments.md) for dev/prod URLs, database split, Docker deploy, and sync commands. Quick reference: [FAC.md](FAC.md).
|
||||
See [environments.md](environments.md) for dev/prod URLs, database split, Docker deploy, and sync commands. For Russian UI + catalog text, see [i18n-russian.md](i18n-russian.md). Influence link import/CRUD: [influence-import.md](influence-import.md). Quick reference: [FAC.md](FAC.md).
|
||||
|
||||
**Production frontend:** build the client, then start the server:
|
||||
|
||||
@@ -177,6 +182,7 @@ Node on the dev PC at `:3520` with nginx → Vite `:5173` is superseded by TrueN
|
||||
| `npm run dev:migrate:influence-sources` | `scripts/migrate-influence-sources.js` | Create `painting_influence_sources` + backfill legacy edges |
|
||||
| `npm run dev:migrate:checkup-flags` | `scripts/migrate-checkup-flags.js` | Add `checkup_checked` / `checkup_fixed` on `paintings` |
|
||||
| `npm run dev:migrate:artist-checkup-flags` | `scripts/migrate-artist-checkup-flags.js` | Add `checkup_checked` / `checkup_fixed` on `artists` (bio debug) |
|
||||
| `npm run dev:migrate:search` | `scripts/migrate-search.js` | Same indexes as `migrate-search.sql` (also run by `dev:migrate`) |
|
||||
| `npm run dev:migrate:painting-annotations` | `scripts/migrate-painting-annotations.js` | Create `painting_annotations` table |
|
||||
| `npm run dev:migrate:artist-palette` | `scripts/migrate-artist-palette.js` | Add `palette_metadata` JSONB on `artists` |
|
||||
| `npm run dev:import-painter-palette` | `scripts/import-painter-palette.js` | Enrich artists + influence links from `Inputs/PainterPalette.csv` |
|
||||
@@ -258,18 +264,29 @@ After clone: copy `.env.example` → `.env`, install dependencies, run [one-time
|
||||
| Debug **More** / **Clear** / **Upload** / **Remove entry** returns 404 | Stale server process | Restart `npm run dev:start` or `npm run dev:server`; routes in `server/index.js` + `server/image-service.js` |
|
||||
| Debug **Remove entry** — button stuck or missing on next painting | Stale client build | `cd client && npm run build`; hard-refresh — detail view remounts per painting id |
|
||||
| Debug **Upload** returns 413 Payload Too Large | Base64 JSON exceeds body limit | Server allows 20 MB JSON / 15 MB decoded image; compress file or resize before upload |
|
||||
| Debug **Upload** — nothing happens after choosing file | Stale client or broken hidden-file `click()` | Pull latest client (`DebugUploadButton` uses `<label>` + `<input>`); hard-refresh |
|
||||
| Uploaded image reverts after reload (old picture) | Browser cached `/images/…` at same path | Pull latest server + client — API returns `image_cache_key` and URLs use `?v=`; `/images` no longer uses immutable long-term cache |
|
||||
| No art-history notes on painting detail | Annotations not migrated or loaded | `npm run dev:migrate:painting-annotations` then `npm run dev:update-painting-annotations` |
|
||||
| **Fix it** fails with `read ECONNRESET` | Remote host dropped connection | Restart server; client sends `searchUrl` / `source`; retry or use Commons URL in overrides |
|
||||
| Fixed image not shown in 3D gallery | Stale gallery session or cached texture | Rebuild client; fix updates session + `?v=` revision — use **Back to Gallery** (not browser back) |
|
||||
| Fixed image not shown in 3D gallery | Stale gallery session or cached texture | Rebuild client; fix/upload updates session + `?v=` from `image_cache_key` — use **Back to Gallery** (not browser back) |
|
||||
| **Back to Timeline** returns to gallery / previous wing | Stale client build | Pull latest client — `goToTimelineHome()` unmounts the hall and resets timeline zoom |
|
||||
| Catalog search dropdown hidden under timeline | Stale client CSS | Rebuild client — `.site-header` uses `z-index: 110` above the sticky timeline bar |
|
||||
| Catalog search returns empty / 500 | Search indexes missing | Run `npm run dev:migrate` (includes `migrate-search.sql`) or `npm run dev:migrate:search`; restart API |
|
||||
| `fetch-artist-bios-ru` fails: `index row size … exceeds btree maximum` | Old `entity_translations` index on all `value` text | `npm run dev:migrate` (partial search index on name/title only), then re-run `npm run dev:fetch-artist-bios-ru` |
|
||||
| Russian UI shows English catalog names | Translations not published | Curator → Translations → Publish; public API serves only `status = published` |
|
||||
| Influence import leaves many unresolved tokens | Names not in catalog DB | Curator → Influences → Import warnings; fix spelling or add artists first; free-text traditions stay unresolved |
|
||||
| Influence import created too many edges | Artist-level rows expand to all paintings | Expected (PainterPalette-style); delete unwanted edges in Influences list |
|
||||
| Influence import blocked: already imported | Same file bytes or mapped data imported before | Expected; use **Import anyway** only if intentional, or skip |
|
||||
| Frame still black after **Checked** | Gallery session not synced | Re-enter hall or toggle debug **Checked** from detail with gallery open behind overlay |
|
||||
| Duplicate works in gallery / timeline | Double import or variant Wikipedia titles | `npm run dev:find-duplicates`; merge or delete spare rows manually |
|
||||
| **Failed to load movement gallery** / `Cannot GET /api/movements/:id/gallery` | Stale server process missing route | Restart `npm run dev:web` or `npm run dev:server` after pulling API changes |
|
||||
| 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 |
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Guided tours
|
||||
|
||||
Curated walkthroughs of selected paintings. Visitors open **Tours** on the timeline, pick a published tour, and walk a 3D hall (`VirtualGallery` mode `tour`). Opening a frame shows stop notes on painting detail; prev/next follow tour order.
|
||||
|
||||
## Status
|
||||
|
||||
| Status | Who sees it |
|
||||
|--------|-------------|
|
||||
| `draft` | Curators only (Tour editor + `GET /api/tours/:id` when signed in) |
|
||||
| `published` | Everyone via Tours popup + public API |
|
||||
|
||||
Tour stop **body** text is English-only in v1 (stored on `tour_stops.body`). UI chrome is EN/RU via i18n.
|
||||
|
||||
## Data model
|
||||
|
||||
Migration: `db/migrate-tours.sql` (applied by `npm run dev:migrate`).
|
||||
|
||||
| Table | Role |
|
||||
|-------|------|
|
||||
| `tours` | Title, description, `status`, optional `cover_painting_id` |
|
||||
| `tour_stops` | Ordered stops: `tour_id`, `painting_id`, `sort_order`, `body` (unique per tour+painting) |
|
||||
|
||||
Included in `scripts/harmonize-db.js` catalog sync.
|
||||
|
||||
## Curator editor
|
||||
|
||||
Home header → **Tour editor** (curators). Create tours, set draft/published, search-add paintings, reorder, edit stop text, save.
|
||||
|
||||
## Visitor flow
|
||||
|
||||
1. Timeline → **Tours** → modal of published tours
|
||||
2. Select tour → `GET /api/tours/:id` → winged 3D hall (left wall first → right wall last, stop order preserved)
|
||||
3. Click frame → painting detail with tour notes + stop-ordered navigation
|
||||
4. Back / hall exit → timeline
|
||||
|
||||
Hall hang rules match artist and movement galleries — see [basics.md — Virtual gallery](basics.md#virtual-gallery-3d-halls).
|
||||
|
||||
## API
|
||||
|
||||
See [API.md](API.md#tours). Audit actions: `tour.create`, `tour.update`, `tour.delete`, `tour.stops`.
|
||||
|
||||
## Out of scope (v1)
|
||||
|
||||
- Russian tour body translations
|
||||
- Auto-generated / AI tours
|
||||
- Audio or timed slideshow
|
||||
- Editing tours from painting detail
|
||||
@@ -0,0 +1,527 @@
|
||||
# UI Interaction and Component Standards
|
||||
|
||||
**Subject:** Virtual Art Gallery — screen and interaction guidance
|
||||
**Applies to:** Public visitor UI, 3D halls, curator tools
|
||||
**Companion rule:** `.cursor/rules/ui-interaction-standards.mdc`
|
||||
|
||||
---
|
||||
|
||||
## 0. Introduction
|
||||
|
||||
The Gallery is a single React SPA (`HomePage.tsx` view union — no URL router except `?layout=`). Public visitors browse a museum-dark timeline and 3D halls; curators use the same shell for catalog tools (Checkup, Translations, Influences, Tours, Users, Activity). Work lands in the same header, palette, and back-stack, so a new screen that invents its own chrome, confirmations, or loading pattern fragments the experience.
|
||||
|
||||
This document is the shared contract for **how the UI behaves**. Architecture, APIs, and 3D hall construction live in [basics.md](basics.md), [API.md](API.md), and [data-and-images.md](data-and-images.md). Tree geometry lives in [movement-tree.md](movement-tree.md). Locale strings live in [i18n-russian.md](i18n-russian.md).
|
||||
|
||||
### 0.1 Purpose and audience
|
||||
|
||||
- **Purpose:** Define common UI interaction, navigation, and component standards for the Gallery client.
|
||||
- **Primary audience:** Developers (and agents) adding or changing `client/src` UI; anyone writing curator-tool screens.
|
||||
- **Secondary audience:** QA, product, copy/i18n.
|
||||
|
||||
### 0.2 Scope
|
||||
|
||||
- **In scope:**
|
||||
- Navigation and back-stack behaviour
|
||||
- Timeline / search / 3D / detail / curator-tool interaction patterns
|
||||
- Loading, empty, error, and confirmation behaviour
|
||||
- Permission-based UI (`can()` / RBAC)
|
||||
- Shared component and visual-language rules
|
||||
- **Out of scope:**
|
||||
- REST contracts, retries, and image pipeline internals
|
||||
- Three.js hall architecture, textures, and lighting (see [basics.md](basics.md))
|
||||
- Pixel-perfect branding kit (no separate design-system package; follow existing CSS)
|
||||
|
||||
### 0.3 Requirement levels
|
||||
|
||||
- **MUST:** Mandatory for new work and for fixes that touch the same screen.
|
||||
- **SHOULD:** Recommended; deviate only with a short note in the PR or screen section.
|
||||
- **MAY:** Optional pattern when the screen specification calls for it.
|
||||
|
||||
### 0.4 Surface map
|
||||
|
||||
| Surface | Typical components | Visitors | Curators |
|
||||
|---------|--------------------|----------|----------|
|
||||
| Timeline home | `Timeline`, `VerticalTimeline`, `MovementBands`, `VerticalMovementBands`, `MovementTree`, `CatalogSearchBar` | yes | yes |
|
||||
| 3D hall | `VirtualGallery`, wing navigator, exit overlays | yes | yes |
|
||||
| Painting / bio | `PaintingDetail`, `PaintingLightbox`, `ArtistBio`, `PaintingAnnotations` | yes | + debug panel when `can('images')` |
|
||||
| Overlays | `CuratorLoginModal`, `ArtistFilterModal`, `ToursPopup`, `DebugSearchResultsModal` | some | all |
|
||||
| Curator tools | `CheckupPage`, `TranslationsPage`, `InfluencesPage`, `ToursPage`, `UsersPage`, `AuditPage` | no | permission-gated |
|
||||
|
||||
---
|
||||
|
||||
## 1. Design Goals and Principles
|
||||
|
||||
### 1.1 Problem statement
|
||||
|
||||
- Statement: Visitors and curators share one shell. Inconsistent back labels, ad-hoc modals, English-only captions, and one-off loading/error treatment make the museum feel like several apps glued together.
|
||||
- Scope: All `client/src` screens and overlays.
|
||||
- Rationale: The product is a gallery, not an admin console with a visitor skin. Predictable chrome is part of the exhibit.
|
||||
- Verification: Compare a new screen’s header, back control, loading marker, and locale keys against this document.
|
||||
|
||||
### 1.2 Functional design principles
|
||||
|
||||
- Principle 1: One museum, two roles
|
||||
- Statement: Public browse chrome MUST stay museum-dark (navy / gold / Georgia). Curator tools MAY be denser but MUST reuse the same header back pattern, gold accent, and permission hiding — they MUST NOT look like a separate product.
|
||||
- Rationale: Curators enter from the same timeline; a visual cliff breaks trust.
|
||||
- Verification: Side-by-side with timeline header and Checkup / Tours editor.
|
||||
|
||||
- Principle 2: Predictable drill-down and return
|
||||
- Statement: Navigation MUST follow Timeline → (movement picker) → hall → painting/bio, with an explicit in-app Back that restores the intended session — not the browser history stack.
|
||||
- Rationale: `HomePage` owns view state; the browser Back button is not wired.
|
||||
- Verification: Walk the [basics.md navigation flow](basics.md#user-navigation-flow) and the Back table in §2.4.
|
||||
|
||||
- Principle 3: Clarity of actions and feedback
|
||||
- Statement: Every user-initiated load, save, delete, or failed request MUST show a loading, success, or error state the user can see without opening the console.
|
||||
- Rationale: 3D and image work is slow; silent failure looks like a broken hall.
|
||||
- Verification: Trigger catalog load, hall open, form save, and a failed API call.
|
||||
|
||||
- Principle 4: Minimize cognitive load
|
||||
- Statement: Timeline charts MUST keep zoom/pan/click hints visible. Curator tables MUST put filter/search above the grid. Destructive actions MUST be confirmed.
|
||||
- Rationale: Dense history data and catalog tables are easy to mis-click.
|
||||
- Verification: Hint captions present; filters above tables; delete paths show a confirm.
|
||||
|
||||
- Principle 5: Locale and permission are first-class
|
||||
- Statement: New visitor-facing copy MUST go through `react-i18next` (`locales/{en,ru}`). Actions the user cannot perform MUST be hidden, not disabled-without-explanation.
|
||||
- Rationale: EN/RU is a product requirement; exposing forbidden tools invites errors.
|
||||
- Verification: Toggle EN|RU; log in as a curator without the relevant `can()` and confirm the control is absent.
|
||||
|
||||
---
|
||||
|
||||
## 2. Navigation Principles
|
||||
|
||||
### 2.1 Application chrome (no left module rail)
|
||||
|
||||
The Gallery has **no persistent left navigation menu**. Primary wayfinding is the **timeline header** on home, and an explicit **Back** control on every nested view.
|
||||
|
||||
- Statement: The timeline home MUST keep layout switch, catalog search, locale switcher, and role-appropriate tools in the site header.
|
||||
- Scope: `HomePage` when `view` is `timeline` / `timeline-vertical` / `timeline-tree`.
|
||||
- Rationale: Visitors need search and layout without hunting; curators need tools without leaving the museum frame.
|
||||
- Verification: Header remains usable at 100vh; search dropdown stacks above movement bands (`z-index` on `.site-header`).
|
||||
|
||||
- Statement: Layout switch links MUST stay in the top-left (`.site-layout-switch`). The Tree of Art control SHOULD use `.site-layout-link-feature`.
|
||||
- Scope: Timeline home.
|
||||
- Rationale: Three layouts must stay discoverable; Tree is the featured alternative start page.
|
||||
- Verification: Classic / Vertical / Tree links match [basics.md](basics.md#timeline-and-movement-flow).
|
||||
|
||||
- Statement: Nested views (hall, painting, bio, curator pages) MUST NOT reintroduce a second global nav. They MUST show a single primary Back control in the page header.
|
||||
- Scope: All non-home views.
|
||||
- Rationale: Avoid competing menus; the drill-down is the nav.
|
||||
- Verification: No duplicate “home” plus “modules” rails on curator pages.
|
||||
|
||||
### 2.2 Page hierarchy
|
||||
|
||||
- Statement: Screens MUST stay within this hierarchy (max four levels):
|
||||
|
||||
1. Timeline home (classic / vertical / tree)
|
||||
2. Overlay or picker (artist filter, tours popup, login) **or** curator tool page
|
||||
3. 3D hall (artist / movement / tour)
|
||||
4. Painting detail or artist bio (optional lightbox on top of detail)
|
||||
|
||||
- Scope: All visitor and curator flows.
|
||||
- Rationale: Matches the existing drill-down; deeper stacks become unrecoverable without a router.
|
||||
- Verification: New views are added to the `View` union in `HomePage.tsx` with a defined parent and Back handler.
|
||||
|
||||
- Statement: Curator tools MUST open as siblings of the timeline (replace the home canvas), not as a fifth level under a hall.
|
||||
- Scope: Checkup, Translations, Influences, Tours editor, Users, Activity.
|
||||
- Rationale: Tools operate on the catalog, not on a hall session.
|
||||
- Verification: Opening Checkup from a hall is not required; from timeline header, Back returns to timeline home.
|
||||
|
||||
### 2.3 Location awareness (no breadcrumbs)
|
||||
|
||||
- Statement: The app MUST NOT add a breadcrumb trail unless a future router lands. Until then, the Back label MUST name the destination (`← Back to Timeline`, `← Back to Gallery`, `← Back`).
|
||||
- Scope: All nested views.
|
||||
- Rationale: There is no URL path to reflect; a fake breadcrumb would lie.
|
||||
- Verification: Labels match §2.4; they are i18n keys (`backToTimeline`, `backToGallery`, …).
|
||||
|
||||
- Statement: Timeline layout MUST be deep-linkable via `?layout=classic|vertical|tree` (omit param for classic). Other views MUST NOT pretend to be bookmarkable until a router exists.
|
||||
- Scope: Timeline home.
|
||||
- Rationale: Layout is the one shareable start-page choice; halls and tools are session state.
|
||||
- Verification: Load `?layout=tree`, switch layouts, confirm `history.replaceState` updates the query.
|
||||
|
||||
### 2.4 Back navigation
|
||||
|
||||
| Control | MUST return to |
|
||||
|---------|----------------|
|
||||
| **← Back to Timeline** (hall header, movement Exit to Timeline, painting opened from search) | Home timeline via `goToTimelineHome()` — unmount hall, clear session, reset year window to full catalog bounds |
|
||||
| **← Back to Gallery** (painting from a hall) | Same hall session (camera / wing preserved) |
|
||||
| **← Back** (artist bio) | `returnTo` view (usually the hall that opened bio) |
|
||||
| Curator page Back | Timeline home |
|
||||
|
||||
- Statement: Back MUST be an in-app control. The browser Back button MUST NOT be relied on (it is not wired to `View` state).
|
||||
- Scope: All nested views.
|
||||
- Rationale: `setView` is the router.
|
||||
- Verification: From painting detail, in-app Back restores the hall; browser Back does not need to.
|
||||
|
||||
- Statement: `goToTimelineHome()` MUST be the single implementation for “leave everything and show the timeline.” New exits MUST call it rather than duplicating reset logic.
|
||||
- Scope: Halls, search-opened paintings, curator Back.
|
||||
- Rationale: Year-range reset and session clear must stay consistent.
|
||||
- Verification: After Exit to Timeline, `viewStart`/`viewEnd` equal catalog bounds.
|
||||
|
||||
### 2.5 State preservation
|
||||
|
||||
- Statement: Timeline pan/zoom (`viewStart` / `viewEnd`) MUST persist while the user stays on a timeline layout. Switching classic ↔ vertical ↔ tree MUST keep the same year window.
|
||||
- Scope: Timeline home.
|
||||
- Rationale: Layout is a lens, not a new dataset.
|
||||
- Verification: Zoom, switch to Tree, confirm the year rail range is unchanged.
|
||||
|
||||
- Statement: Returning to timeline via `goToTimelineHome()` MUST reset the year window to full catalog bounds.
|
||||
- Scope: Hall / search / curator exits that call `goToTimelineHome()`.
|
||||
- Rationale: Documented in [basics.md](basics.md#back-navigation); visitors expect a fresh overview, not a leftover zoom.
|
||||
- Verification: Zoom in, enter a hall, Back to Timeline → full span.
|
||||
|
||||
- Statement: Hall camera and wing MUST be preserved across **Back to Gallery** from painting detail. They MUST be discarded on **Back to Timeline**.
|
||||
- Scope: Artist, movement, and tour halls.
|
||||
- Rationale: Inspecting a painting is a detour; leaving the museum is not.
|
||||
- Verification: Move in the hall, open a painting, Back to Gallery → same viewpoint.
|
||||
|
||||
- Statement: Catalog search input MAY clear when the dropdown closes. It MUST NOT change timeline zoom by itself.
|
||||
- Scope: `CatalogSearchBar`.
|
||||
- Rationale: Search is a jump, not a filter on the chart.
|
||||
- Verification: Type a query, Escape; year window unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 3. Common UI Interaction Patterns
|
||||
|
||||
### 3.1 Catalog search (visitor)
|
||||
|
||||
- Statement: Timeline search MUST live in the header, require **2** trimmed characters, debounce **300 ms**, and group results into Artists, Movements, Paintings.
|
||||
- Scope: `CatalogSearchBar.tsx`.
|
||||
- Rationale: Documented product behaviour; keeps `/api/search` load reasonable.
|
||||
- Verification: 1 character shows no fetch; 2+ after debounce shows groups.
|
||||
|
||||
- Statement: Keyboard MUST support `↑`/`↓` highlight, `Enter` to open, `Escape` to close.
|
||||
- Scope: Catalog search dropdown.
|
||||
- Rationale: Timeline is pointer-heavy; search should still be keyboardable.
|
||||
- Verification: Keyboard-only open of an artist, movement, and painting.
|
||||
|
||||
- Statement: Opening a painting from search MUST set `returnTo` timeline so Back is **← Back to Timeline**, not Gallery.
|
||||
- Scope: Search → painting detail.
|
||||
- Rationale: There is no hall session.
|
||||
- Verification: Search a title, open, Back → home timeline.
|
||||
|
||||
### 3.2 Timeline charts (zoom, pan, click)
|
||||
|
||||
- Statement: Scroll MUST zoom, drag MUST pan, click on a stream/limb/label MUST open the movement (artist filter → hall). All three layouts MUST use `zoomTimelineView` / `panTimelineView`.
|
||||
- Scope: `MovementBands`, `VerticalMovementBands`, `MovementTree`, era rails.
|
||||
- Rationale: Shared year window; one mental model.
|
||||
- Verification: Same wheel/drag behaviour on classic, vertical, and tree.
|
||||
|
||||
- Statement: Each chart MUST show a localised hint caption (`captionClassicTimeline`, `captionClassicFlow`, `captionVerticalTimeline`, `captionVerticalFlow`, `captionTreeFlow`).
|
||||
- Scope: Timeline home.
|
||||
- Rationale: First-time visitors cannot discover zoom/pan otherwise.
|
||||
- Verification: EN and RU captions change with `LocaleSwitcher`.
|
||||
|
||||
- Statement: Timeline layout shifts (lane packing, tree fit scale) SHOULD animate rather than snap.
|
||||
- Scope: Movement charts.
|
||||
- Rationale: Unexplained jumps look like bugs.
|
||||
- Verification: Zoom/pan does not teleport streams.
|
||||
|
||||
### 3.3 Filters and search (curator tables)
|
||||
|
||||
- Statement: Filter/search controls MUST sit in a toolbar **above** the table, not in a column header hack or a page footer.
|
||||
- Scope: Checkup, Translations, Influences, Tours editor, Users, Activity.
|
||||
- Rationale: Matches Checkup (`checkup-toolbar`) and keeps the grid scannable.
|
||||
- Verification: Filters remain visible while the table scrolls.
|
||||
|
||||
- Statement: Simple text filters MAY apply as the user types. Expensive operations (image search, import, refetch) MUST require an explicit button (e.g. Checkup **Search visible**).
|
||||
- Scope: Curator list screens.
|
||||
- Rationale: Checkup search is rate-limited and slow; typing must not fire it.
|
||||
- Verification: Typing in Checkup filter does not start image search.
|
||||
|
||||
- Statement: When a filter hides rows, the toolbar SHOULD show how many rows are visible (e.g. `N shown`).
|
||||
- Scope: Filtered tables.
|
||||
- Rationale: Empty-looking tables need an explanation.
|
||||
- Verification: Filter to zero rows → empty state plus count.
|
||||
|
||||
### 3.4 Date selection
|
||||
|
||||
- Statement: Year fields in the catalog and timeline MUST use numeric years (negative = BCE). They MUST NOT switch to locale-specific calendar widgets for historical BCE dates.
|
||||
- Scope: Timeline bounds, artist lifespan, painting years, curator year filters.
|
||||
- Rationale: The catalog spans −800 to the present; HTML date inputs cannot represent BCE.
|
||||
- Verification: Ancient era still filters correctly.
|
||||
|
||||
- Statement: If a future screen needs a civil date (e.g. audit log day), it SHOULD use ISO `YYYY-MM-DD` and validate start ≤ end for ranges.
|
||||
- Scope: Activity / audit and any new timestamp filters.
|
||||
- Rationale: Consistent with API timestamps; avoids DD/MM ambiguity.
|
||||
- Verification: Invalid range shows a field-level message.
|
||||
|
||||
### 3.5 Tables and lists (curator)
|
||||
|
||||
- Statement: Structured curator datasets MUST use a labeled HTML table (or existing page table classes), one logical record per row.
|
||||
- Scope: Checkup, Users, Influences worklists, Tours list, Translations worklist, Activity.
|
||||
- Rationale: Comparison and row actions need columns, not cards.
|
||||
- Verification: Column headers present; row click/action affects one record.
|
||||
|
||||
- Statement: Visitor-facing catalog MUST NOT be presented as a spreadsheet. Timeline streams, tree limbs, and 3D hangs are the list metaphor.
|
||||
- Scope: Public home and halls.
|
||||
- Rationale: The product is a gallery, not a DAM table.
|
||||
- Verification: No “all paintings” data grid on the public home.
|
||||
|
||||
- Statement: Tables MAY omit pagination while the dataset is curator-sized and client-filtered. If a list grows past comfortable scrolling, it SHOULD paginate or virtualise rather than rendering thousands of DOM rows.
|
||||
- Scope: Curator tools.
|
||||
- Rationale: Checkup is already filter-then-scroll; unbounded paint is a future foot-gun.
|
||||
- Verification: New tools with large lists have a documented paging or virtualisation plan.
|
||||
|
||||
- Statement: Row actions MUST sit in a dedicated column or overflow control, not as random icons in every cell.
|
||||
- Scope: Interactive curator tables.
|
||||
- Rationale: Scanability.
|
||||
- Verification: Action column or consistent button set per row.
|
||||
|
||||
### 3.6 Multi-row and per-record actions
|
||||
|
||||
- Statement: Bulk actions MUST use a leading checkbox column, Select All for **visible** rows only, and a confirmation that includes the affected count for destructive work.
|
||||
- Scope: Any new bulk-enabled table. (Today: ArtistFilterModal multi-select is a picker, not a bulk delete.)
|
||||
- Rationale: Same as the Logistics template; prevent silent mass edits.
|
||||
- Verification: Select All does not imply “all matching in the database” unless explicitly labelled.
|
||||
|
||||
- Statement: Artist filter before a movement hall MUST be a modal checklist with explicit proceed/cancel, not a bulk-edit of the catalog.
|
||||
- Scope: `ArtistFilterModal`.
|
||||
- Rationale: It only chooses who appears in the hall.
|
||||
- Verification: Cancel leaves the user on the timeline; proceed opens the hall.
|
||||
|
||||
- Statement: Primary row/object action SHOULD be the name/title (open painting, open user, open tour). Secondary actions SHOULD stay in the row’s action controls.
|
||||
- Scope: Curator tables and search results.
|
||||
- Rationale: Matches search-result click-to-open.
|
||||
- Verification: Clicking a Checkup title opens the painting when that handler exists.
|
||||
|
||||
- Statement: Unavailable-by-permission actions MUST be hidden. Unavailable-by-record-state SHOULD be disabled with a `title`/tooltip explaining why.
|
||||
- Scope: All tools.
|
||||
- Rationale: RBAC vs workflow are different signals.
|
||||
- Verification: Non-admin does not see Users; a disabled Fix button states why.
|
||||
|
||||
### 3.7 Forms
|
||||
|
||||
- Statement: Short auth and picker flows MUST use a modal. Multi-section catalog editors (Users create/edit, Tours editor, Influences wizard, Translations worklist) MUST be full-page (or the existing page layout), not nested modals.
|
||||
- Scope: All forms.
|
||||
- Rationale: Halls and timeline need to stay the “place”; heavy edit needs space.
|
||||
- Verification: Login is modal; Users is a page.
|
||||
|
||||
- Statement: Forms SHOULD be a single column. Related fields MAY group under a heading when there are more than five inputs.
|
||||
- Scope: Curator forms.
|
||||
- Rationale: Scanning beats dense multi-column on museum-width pages.
|
||||
- Verification: Users create form remains vertically grouped.
|
||||
|
||||
- Statement: Required fields MUST use the native `required` attribute and/or a visible marker; validation errors MUST appear next to the field or as a form-level error the submit control does not obscure.
|
||||
- Scope: Login, Users, Tours, Influences, Translations.
|
||||
- Rationale: Silent submit-disable is not enough.
|
||||
- Verification: Submit empty login → field or form error, not a blank modal.
|
||||
|
||||
- Statement: After successful save, the system MUST show an inline success message (or equivalent) and keep the user on the tool unless the spec says to return to timeline.
|
||||
- Scope: Curator mutations.
|
||||
- Rationale: Users page already uses `message` / `error` banners.
|
||||
- Verification: Save permissions → success text; failed save → error text.
|
||||
|
||||
- Statement: Unsaved-change guards SHOULD be added when a form is long enough that accidental Back would lose work (Tours editor, Translations). Login and tiny pickers MAY skip this.
|
||||
- Scope: Heavy editors.
|
||||
- Rationale: `window.confirm` on delete already exists; abandon-edit is the remaining hole.
|
||||
- Verification: Dirty Tours editor + Back prompts or discards explicitly.
|
||||
|
||||
### 3.8 Edit interaction pattern selection
|
||||
|
||||
| Pattern | Use when |
|
||||
|---------|----------|
|
||||
| **Modal** | Login, artist filter, tours list popup, debug image picker, lightbox, hall exit/wing overlays |
|
||||
| **Inline** | Checkup flags, debug **Checked** / **Fix it** on painting detail — small, reversible |
|
||||
| **Full page** | Curator tools, painting detail, artist bio, 3D hall |
|
||||
| **Drawer** | MUST NOT be introduced unless a spec adds a shared drawer component |
|
||||
|
||||
- Statement: Nested modals MUST NOT be used (no modal opened from another modal). The debug “More” picker MAY stack on painting detail because detail is a full page, not a modal.
|
||||
- Scope: All overlays.
|
||||
- Rationale: Focus traps and Back labels break.
|
||||
- Verification: Login does not open another dialog.
|
||||
|
||||
### 3.9 Modals and overlays
|
||||
|
||||
- Statement: Modals MUST use `role="dialog"` and `aria-modal="true"`, a visible close/cancel, and **Escape** to dismiss unless a submit is in flight.
|
||||
- Scope: `CuratorLoginModal`, `ArtistFilterModal`, `ToursPopup`, `DebugSearchResultsModal`, `PaintingLightbox`, hall exit overlays.
|
||||
- Rationale: Accessibility and parity with search.
|
||||
- Verification: Esc closes lightbox and debug picker; backdrop click matches existing login behaviour.
|
||||
|
||||
- Statement: Backdrop click MAY close pickers and login. It MUST NOT close a modal that is applying a destructive or long-running action.
|
||||
- Scope: Overlays.
|
||||
- Rationale: Accidental dismiss during Fix/upload is costly.
|
||||
- Verification: Click outside login closes; do not dismiss mid-upload.
|
||||
|
||||
### 3.10 Loading, empty, and error states
|
||||
|
||||
- Statement: Catalog, portrait, and hall loads MUST use `GalleryLoadingMarker` (overlay or banner), not an ad-hoc spinner per screen unless the marker cannot cover the region.
|
||||
- Scope: Home, halls, painting/bio image work.
|
||||
- Rationale: One recognisable “the museum is fetching” treatment.
|
||||
- Verification: First visit shows “Loading art history…”; hall open uses the same marker family.
|
||||
|
||||
- Statement: Loading SHOULD be scoped to the affected region. Full-viewport overlay MUST be used only when the user cannot usefully interact (first catalog load, hall WebGL init).
|
||||
- Scope: All loads.
|
||||
- Rationale: Portrait banner vs full-page overlay already follows this.
|
||||
- Verification: Timeline remains visible while “Loading portraits…” banners.
|
||||
|
||||
- Statement: Empty datasets MUST explain themselves (e.g. vertical flow: no movements in range; search: no matches; Checkup: no rows for filter).
|
||||
- Scope: Charts, search, tables.
|
||||
- Rationale: Blank gold-on-navy reads as a crash.
|
||||
- Verification: Zoom to a year with no movements; search a nonsense string.
|
||||
|
||||
- Statement: Failed loads MUST set a visible error string (`error-banner`, form error, or page error) — never `console.error` alone.
|
||||
- Scope: All data-fetching views.
|
||||
- Rationale: Visitors have no console.
|
||||
- Verification: Stop the API and confirm home shows a load failure message.
|
||||
|
||||
### 3.11 3D hall interaction
|
||||
|
||||
- Statement: Halls MUST keep **← Back to Timeline**, pointer-lock / click-to-move as already implemented, and **E** (or documented key) for exit/wing navigation. New hall UI MUST not steal those keys without updating this section.
|
||||
- Scope: `VirtualGallery`.
|
||||
- Rationale: Muscle memory across artist, movement, and tour halls.
|
||||
- Verification: Same Back label and exit overlay pattern in all three hall kinds.
|
||||
|
||||
- Statement: Clicking a framed painting MUST open painting detail with `returnTo` the current hall. Missing images MUST show the draped-canvas placeholder, not a broken `<img>`.
|
||||
- Scope: Halls.
|
||||
- Rationale: Documented in [basics.md](basics.md).
|
||||
- Verification: Work without a file still shows a frame cover.
|
||||
|
||||
---
|
||||
|
||||
## 4. Behavioral Standards (Screen UX)
|
||||
|
||||
### 4.1 Permission-based rendering (RBAC)
|
||||
|
||||
- Statement: Header tools and debug controls MUST render only when `can('<permission>')` (and login for curator). Anonymous visitors MUST see browse + Tours popup + locale, not Checkup/Users/etc.
|
||||
- Scope: `HomePage` header, painting/bio debug panel.
|
||||
- Rationale: Security UX: hide, don’t tease.
|
||||
- Verification: Logged-out home; curator without `users` cannot open Users.
|
||||
|
||||
- Statement: Route-like views that require a role MUST show the existing “Curator access required” panel with login and back-to-gallery actions — they MUST NOT render an empty privileged page.
|
||||
- Scope: Checkup, Translations, Influences, Tours editor, Users, Activity.
|
||||
- Rationale: Deep view state can still be set; the gate must hold.
|
||||
- Verification: Set view to Users while logged out → curator required copy.
|
||||
|
||||
- Statement: The same permission MUST hide the same action on every surface (header, painting debug, API). Do not leave a visible button that 403s.
|
||||
- Scope: All mutations.
|
||||
- Rationale: Predictable roles.
|
||||
- Verification: `can('images')` off → no debug panel and no Fix buttons.
|
||||
|
||||
### 4.2 Notifications and user feedback
|
||||
|
||||
- Statement: Curator mutations MUST show success or error text in the page’s existing banner/message area. The app has no global toast system; new screens MUST NOT invent a third notification widget without replacing this standard.
|
||||
- Scope: Curator pages.
|
||||
- Rationale: Users/Influences already use inline `message` / `error`.
|
||||
- Verification: One visual treatment per page, consistent placement under the header.
|
||||
|
||||
- Statement: Visitor-facing destructive actions (painting **Remove entry** in debug) MUST confirm and then show failure inline if the API rejects.
|
||||
- Scope: Debug painting tools.
|
||||
- Rationale: Catalog deletes are irreversible.
|
||||
- Verification: Cancel confirm → no delete.
|
||||
|
||||
- Statement: Copy MUST be concise and, for errors, actionable (“Is the server running?”, “Sign in as a curator…”).
|
||||
- Scope: All user-visible strings.
|
||||
- Rationale: Support load.
|
||||
- Verification: Home catalog failure string remains understandable.
|
||||
|
||||
### 4.3 Confirmation and cancellation
|
||||
|
||||
- Statement: Destructive curator actions (delete influence, delete tour, remove painting, deactivate user if offered) MUST confirm before the request. `window.confirm` with an i18n string is the current standard (`confirmDelete`); a shared modal MAY replace it later but MUST stay one pattern.
|
||||
- Scope: Influences, Tours, painting remove, Users.
|
||||
- Rationale: Accidental clicks on dense tables.
|
||||
- Verification: Delete tour → confirm; Cancel → no API call.
|
||||
|
||||
- Statement: Cancel on a confirm MUST leave filters, selection, and unsaved fields unchanged.
|
||||
- Scope: All confirms.
|
||||
- Rationale: Context preservation.
|
||||
- Verification: Filtered Checkup, cancel a destructive action → filter still applied.
|
||||
|
||||
---
|
||||
|
||||
## 5. Component Consistency Rules
|
||||
|
||||
### 5.1 Shared interaction pattern usage
|
||||
|
||||
- Statement: New UI MUST reuse existing components before creating parallels: `GalleryLoadingMarker`, `CatalogSearchBar`, `LocaleSwitcher`, `CuratorLoginModal`, `ArtistFilterModal`, `PaintingLightbox`, `DebugSearchResultsModal`, `DebugUploadButton`.
|
||||
- Scope: `client/src`.
|
||||
- Rationale: Duplicate spinners and dialogs already caused drift.
|
||||
- Verification: PR does not add a second login modal or loading overlay.
|
||||
|
||||
- Statement: Movement colours MUST go through `utils/movementColor.ts` (`vividMovementColor`, `shadeMovementColor`). Hex parsing MUST tolerate `#rgb` / `#rrggbb` and keep the first six digits of longer values.
|
||||
- Scope: Timeline charts and any movement swatch.
|
||||
- Rationale: Classic, vertical, and tree already share this util.
|
||||
- Verification: No local `parseHexColor` copies in components.
|
||||
|
||||
- Statement: Timeline zoom/pan MUST use `utils/timelineView.ts`. Tree horizontal layout MUST use `utils/movementTree.ts` (view-independent structure).
|
||||
- Scope: Timeline surfaces.
|
||||
- Rationale: Layouts share one year window.
|
||||
- Verification: No one-off wheel handlers that bypass the util.
|
||||
|
||||
### 5.2 Component and style selection
|
||||
|
||||
- Statement: There is **no Ant Design / MUI**. UI MUST be React + colocated CSS (`ComponentName.tsx` + `ComponentName.css`). Global museum tokens SHOULD reuse existing values:
|
||||
|
||||
| Token | Typical value | Use |
|
||||
|-------|----------------|-----|
|
||||
| Gold | `#c9a96e` / `rgba(201, 169, 110, …)` | Accents, borders, links |
|
||||
| Ink / navy | `#0f0f1a`, `#1a1a2e`, `#16213e` | Page background |
|
||||
| Type | Georgia, serif | Titles, captions, layout links |
|
||||
| Viewport | `100vh`, `overflow: hidden` on home | No document scroll for the museum shell |
|
||||
|
||||
- Scope: All client UI.
|
||||
- Rationale: The look *is* the design system.
|
||||
- Verification: New CSS does not introduce a bright Bootstrap theme.
|
||||
|
||||
- Statement: Prefer semantic `<button type="button">` for actions and native `<form>` for submits. Do not use `<div onClick>` for primary actions.
|
||||
- Scope: All interactive chrome.
|
||||
- Rationale: Keyboard and a11y.
|
||||
- Verification: Header tools are buttons.
|
||||
|
||||
- Statement: Colocate styles; do not add a CSS-in-JS runtime. Shared layout classes live in `HomePage.css` / page CSS, not inline theme objects.
|
||||
- Scope: Client.
|
||||
- Rationale: Matches the repo.
|
||||
- Verification: New component ships a `.css` file or uses an existing one.
|
||||
|
||||
### 5.3 i18n
|
||||
|
||||
- Statement: New user-visible chrome MUST add keys to `client/src/locales/en/*.json` and `ru/*.json` (namespace that matches the screen: `home`, `common`, `debug`, `users`, …). Hardcoded English is allowed only for curator-only screens that are not yet migrated, and those SHOULD be migrated when the screen is touched.
|
||||
- Scope: All new copy.
|
||||
- Rationale: [i18n-russian.md](i18n-russian.md).
|
||||
- Verification: Locale toggle changes the new string.
|
||||
|
||||
- Statement: Catalog entities (names, titles, bios) MUST use API locale resolution, not a second client-side dictionary.
|
||||
- Scope: Timeline labels, search, halls, detail.
|
||||
- Rationale: `entity_translations` is canonical for RU catalog text.
|
||||
- Verification: RU locale shows published translations.
|
||||
|
||||
### 5.4 Exception process
|
||||
|
||||
- Statement: Deviations (new overlay type, toast system, left nav, router, design library) MUST be documented in this file or in the feature’s `Documentation/*.md` with rationale before they spread to a second screen.
|
||||
- Scope: Client-wide patterns.
|
||||
- Rationale: One exception is an experiment; two without a write-up is fragmentation.
|
||||
- Verification: PR description links the exception note.
|
||||
|
||||
- Statement: Known current exceptions (do not cargo-cult; fix when touching the file):
|
||||
- Browser history is not a router.
|
||||
- Only `?layout=` is deep-linked.
|
||||
- Some curator pages (e.g. Checkup) still have English chrome.
|
||||
- Some modals may still omit Escape (login); new modals MUST include it.
|
||||
- Destructive confirms use `window.confirm` rather than a shared dialog component.
|
||||
|
||||
### 5.5 Versioning of behaviour
|
||||
|
||||
- Statement: Behaviour changes to shared patterns (Back, search debounce, layout query, loading marker) MUST update this document in the same change.
|
||||
- Scope: Shared components and `HomePage` navigation.
|
||||
- Rationale: Agents and humans use this as the spec.
|
||||
- Verification: Doc diff accompanies the code diff.
|
||||
|
||||
---
|
||||
|
||||
## 6. Screen-Level Checklist
|
||||
|
||||
Use this before submitting a new view, overlay, or curator tool. Unchecked items need a note.
|
||||
|
||||
- [ ] Surface type is identified (timeline / hall / detail / overlay / curator page).
|
||||
- [ ] Parent view and Back label/destination are defined (`returnTo` or `goToTimelineHome`).
|
||||
- [ ] Permissions: controls hidden unless `can(…)` / signed in as required.
|
||||
- [ ] Loading uses `GalleryLoadingMarker` or a justified scoped indicator.
|
||||
- [ ] Empty and error states are visible copy, not a blank canvas.
|
||||
- [ ] Destructive actions confirm; cancel leaves state unchanged.
|
||||
- [ ] Visitor chrome strings are in `locales/{en,ru}`; catalog text uses API locale.
|
||||
- [ ] No new CSS framework; gold/navy/Georgia; colocated CSS.
|
||||
- [ ] Search/filter (if any) sits above the list; expensive work is explicit-button.
|
||||
- [ ] Modals: `role="dialog"`, close control, Escape.
|
||||
- [ ] Timeline work uses `timelineView` / `movementColor` / `movementTree` as applicable.
|
||||
- [ ] Hall work preserves camera on Back to Gallery and resets on Back to Timeline.
|
||||
- [ ] This document updated if a shared pattern changed.
|
||||
@@ -0,0 +1,125 @@
|
||||
Here are 100 standout books by art historians and specialists covering the artists in your lists and their movements, ordered roughly by period. (Your list includes Apelles, Zeuxis, Parrhasius, the icon painters, and everyone through Pop Art, so I've covered all those areas; per your earlier instruction I've left out Eastern and African art scholarship.)
|
||||
|
||||
**Classical antiquity & icons (Apelles, Zeuxis, Parrhasius; Rublev, Theophanes)**
|
||||
1. J. J. Pollitt — *Art and Experience in Classical Greece* (1972)
|
||||
2. J. J. Pollitt — *The Art of Ancient Greece: Sources and Documents* (1990)
|
||||
3. Ernst Gombrich — *Art and Illusion* (1960; central discussion of Greek mimesis and the Zeuxis/Parrhasius tradition)
|
||||
4. Viktor Lazarev — *The Russian Icon: From Its Origins to the Sixteenth Century* (English ed. 1997)
|
||||
5. Robin Cormack — *Icons* (2007)
|
||||
|
||||
**Trecento & Early Renaissance (Cimabue, Giotto, Duccio, Simone Martini, Masaccio, Fra Angelico, Castagno, Ghirlandaio, Botticelli)**
|
||||
6. Giorgio Vasari — *Lives of the Artists* (1550/1568; the founding text)
|
||||
7. Bernard Berenson — *The Italian Painters of the Renaissance* (1930 collected ed.)
|
||||
8. John White — *Art and Architecture in Italy 1250–1400* (1966)
|
||||
9. Millard Meiss — *Painting in Florence and Siena after the Black Death* (1951)
|
||||
10. Bruce Cole — *Giotto and Florentine Painting 1280–1375* (1976)
|
||||
11. Francesca Flores d'Arcais — *Giotto* (1995)
|
||||
12. John White — *Duccio: Tuscan Art and the Medieval Workshop* (1979)
|
||||
13. Andrew Martindale — *Simone Martini* (1988)
|
||||
14. Michael Baxandall — *Painting and Experience in Fifteenth-Century Italy* (1972)
|
||||
15. Paul Joannides — *Masaccio and Masolino: A Complete Catalogue* (1993)
|
||||
16. John Pope-Hennessy — *Fra Angelico* (1952, rev. 1974)
|
||||
17. Diane Cole Ahl — *Fra Angelico* (2008)
|
||||
18. Herbert Horne — *Alessandro Filipepi, Called Sandro Botticelli* (1908; still the classic monograph)
|
||||
19. Ronald Lightbown — *Sandro Botticelli: Life and Work* (1978)
|
||||
20. Jean Cadogan — *Domenico Ghirlandaio: Artist and Artisan* (2000)
|
||||
|
||||
**High Renaissance (Leonardo, Michelangelo, Raphael, Giorgione, Titian)**
|
||||
21. Heinrich Wölfflin — *Classic Art* (1899)
|
||||
22. Sydney Freedberg — *Painting of the High Renaissance in Rome and Florence* (1961)
|
||||
23. Kenneth Clark — *Leonardo da Vinci* (1939)
|
||||
24. Martin Kemp — *Leonardo da Vinci: The Marvellous Works of Nature and Man* (1981)
|
||||
25. Charles de Tolnay — *Michelangelo* (5 vols., 1943–1960)
|
||||
26. Howard Hibbard — *Michelangelo* (1974)
|
||||
27. Roger Jones & Nicholas Penny — *Raphael* (1983)
|
||||
28. John Pope-Hennessy — *Raphael* (1970)
|
||||
29. Salvatore Settis — *Giorgione's Tempest: Interpreting the Hidden Subject* (English ed. 1990)
|
||||
30. Johannes Wilde — *Venetian Art from Bellini to Titian* (1974)
|
||||
31. Harold Wethey — *The Paintings of Titian* (3 vols., 1969–1975)
|
||||
32. Charles Hope — *Titian* (1980)
|
||||
33. David Rosand — *Painting in Cinquecento Venice* (1982)
|
||||
|
||||
**Northern Renaissance (van Eyck, Bosch, Dürer)**
|
||||
34. Erwin Panofsky — *Early Netherlandish Painting* (1953)
|
||||
35. Otto Pächt — *Van Eyck and the Founders of Early Netherlandish Painting* (English ed. 1994)
|
||||
36. Craig Harbison — *Jan van Eyck: The Play of Realism* (1991)
|
||||
37. Charles de Tolnay — *Hieronymus Bosch* (English ed. 1966)
|
||||
38. Walter Gibson — *Hieronymus Bosch* (1973)
|
||||
39. Erwin Panofsky — *The Life and Art of Albrecht Dürer* (1943)
|
||||
40. Joseph Koerner — *The Moment of Self-Portraiture in German Renaissance Art* (1993)
|
||||
|
||||
**Mannerism (Pontormo, Bronzino, Parmigianino, El Greco)**
|
||||
41. John Shearman — *Mannerism* (1967)
|
||||
42. Sydney Freedberg — *Painting in Italy 1500–1600* (1971)
|
||||
43. Cecil Gould — *Parmigianino* (1994)
|
||||
44. Maurice Brock — *Bronzino* (2002)
|
||||
45. Elizabeth Cropper — *Pontormo: Portrait of a Halberdier* (1997)
|
||||
46. Harold Wethey — *El Greco and His School* (1962)
|
||||
47. Fernando Marías — *El Greco: Life and Work — A New History* (2013)
|
||||
|
||||
**Baroque (Caravaggio, Gentileschi, Rubens, Rembrandt, Velázquez, Poussin, Claude)**
|
||||
48. Rudolf Wittkower — *Art and Architecture in Italy 1600–1750* (1958)
|
||||
49. Walter Friedlaender — *Caravaggio Studies* (1955)
|
||||
50. Howard Hibbard — *Caravaggio* (1983)
|
||||
51. Helen Langdon — *Caravaggio: A Life* (1998)
|
||||
52. Mary Garrard — *Artemisia Gentileschi: The Image of the Female Hero in Italian Baroque Art* (1989)
|
||||
53. Michael Jaffé — *Rubens and Italy* (1977)
|
||||
54. Kristin Lohse Belkin — *Rubens* (Phaidon, 1998)
|
||||
55. Jakob Rosenberg — *Rembrandt: Life and Work* (1948)
|
||||
56. Svetlana Alpers — *Rembrandt's Enterprise: The Studio and the Market* (1988)
|
||||
57. Ernst van de Wetering — *Rembrandt: The Painter at Work* (1997)
|
||||
58. Simon Schama — *Rembrandt's Eyes* (1999)
|
||||
59. Jonathan Brown — *Velázquez: Painter and Courtier* (1986)
|
||||
60. Anthony Blunt — *Nicolas Poussin* (1967)
|
||||
61. Marcel Röthlisberger — *Claude Lorrain: The Paintings* (1961)
|
||||
|
||||
**Eighteenth century (Watteau, Boucher, Fragonard, Kauffman)**
|
||||
62. Donald Posner — *Antoine Watteau* (1984)
|
||||
63. Michael Levey — *Rococo to Revolution* (1966)
|
||||
64. Alastair Laing et al. — *François Boucher 1703–1770* (Met exhibition catalogue, 1986)
|
||||
65. Pierre Rosenberg — *Fragonard* (Met exhibition catalogue, 1988)
|
||||
66. Angela Rosenthal — *Angelica Kauffman: Art and Sensibility* (2006)
|
||||
|
||||
**Neoclassicism & Romanticism (David, Ingres, Goya, Géricault, Delacroix, Turner, Constable, Friedrich)**
|
||||
67. Robert Rosenblum — *Transformations in Late Eighteenth Century Art* (1967)
|
||||
68. Anita Brookner — *Jacques-Louis David* (1980)
|
||||
69. Robert Rosenblum — *Jean-Auguste-Dominique Ingres* (1967)
|
||||
70. Fred Licht — *Goya: The Origins of the Modern Temper in Art* (1979)
|
||||
71. Janis Tomlinson — *Goya: A Portrait of the Artist* (2020)
|
||||
72. Lorenz Eitner — *Géricault: His Life and Work* (1983)
|
||||
73. Barthélémy Jobert — *Delacroix* (1998)
|
||||
74. Andrew Wilton — *J. M. W. Turner: His Art and Life* (1979)
|
||||
75. John Gage — *Colour in Turner: Poetry and Truth* (1969)
|
||||
76. Michael Rosenthal — *Constable: The Painter and His Landscape* (1983)
|
||||
77. Joseph Koerner — *Caspar David Friedrich and the Subject of Landscape* (1990)
|
||||
|
||||
**Realism (Courbet, Millet, Daumier, Corot)**
|
||||
78. Linda Nochlin — *Realism* (1971)
|
||||
79. T. J. Clark — *Image of the People: Gustave Courbet and the 1848 Revolution* (1973)
|
||||
80. T. J. Clark — *The Absolute Bourgeois: Artists and Politics in France 1848–1851* (1973; Millet and Daumier)
|
||||
81. Peter Galassi — *Corot in Italy* (1991)
|
||||
|
||||
**Impressionism (Manet, Monet, Renoir, Degas, Pissarro)**
|
||||
82. John Rewald — *The History of Impressionism* (1946; still the standard narrative)
|
||||
83. Robert Herbert — *Impressionism: Art, Leisure, and Parisian Society* (1988)
|
||||
84. T. J. Clark — *The Painting of Modern Life: Paris in the Art of Manet and His Followers* (1985)
|
||||
85. Michael Fried — *Manet's Modernism* (1996)
|
||||
86. Paul Hayes Tucker — *Claude Monet: Life and Art* (1995)
|
||||
87. Barbara Ehrlich White — *Renoir: His Life, Art, and Letters* (1984)
|
||||
88. Jean Sutherland Boggs et al. — *Degas* (1988 exhibition catalogue; the standard reference)
|
||||
89. Richard Brettell — *Pissarro and Pontoise* (1990)
|
||||
|
||||
**Post-Impressionism & Symbolism (Cézanne, van Gogh, Gauguin, Seurat, Toulouse-Lautrec, Munch, Redon, Puvis, Böcklin, Klimt, Beardsley, Mucha)**
|
||||
90. John Rewald — *Post-Impressionism: From Van Gogh to Gauguin* (1956)
|
||||
91. Meyer Schapiro — *Paul Cézanne* (1952)
|
||||
92. Roger Fry — *Cézanne: A Study of His Development* (1927)
|
||||
93. Jan Hulsker — *The New Complete Van Gogh* (1996)
|
||||
94. Debora Silverman — *Van Gogh and Gauguin: The Search for Sacred Art* (2000)
|
||||
95. Richard Brettell et al. — *The Art of Paul Gauguin* (1988 exhibition catalogue)
|
||||
96. Robert Herbert — *Seurat and the Making of "La Grande Jatte"* (2004)
|
||||
97. Julia Frey — *Toulouse-Lautrec: A Life* (1994)
|
||||
98. Reinhold Heller — *Munch: His Life and Work* (1984)
|
||||
99. Douglas Druick et al. — *Odilon Redon: Prince of Dreams* (1994)
|
||||
100. Carl Schorske — *Fin-de-Siècle Vienna: Politics and Culture* (1980; the classic frame for Klimt)
|
||||
|
||||
I stopped at a strict 100, which meant the twentieth-century avant-gardes got squeezed out. If you'd like, I can give you a second list of ~40 covering the rest of your artists — the essentials there would be things like Alfred Barr's *Matisse*, John Richardson's *A Life of Picasso*, John Golding's *Cubism*, Camilla Gray's *The Russian Experiment in Art* (Malevich, Tatlin, Rodchenko, Popova), Werner Spies on Ernst, Dawn Ades on Dalí, Jacques Dupin on Miró, Irving Sandler's *The Triumph of American Painting* (Pollock, Rothko, de Kooning), and Lucy Lippard's *Pop Art* (Warhol, Lichtenstein, Hamilton). Just say the word.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,415 @@
|
||||
artist,painting,link,size
|
||||
Albrecht Dürer,Abduction of a Woman,https://www.wga.hu/art/d/durer/2/11/1/10abduct.jpg,283 x 423 mm
|
||||
Albrecht Dürer,Adam,https://www.wga.hu/art/d/durer/1/06/2adam.jpg,209 x 81 cm
|
||||
Albrecht Dürer,Apostles Philip and James the Elder,https://www.wga.hu/art/d/durer/1/08/6apostl.jpg,46 x 37 cm
|
||||
Albrecht Dürer,Christ as the Man of Sorrows,https://www.wga.hu/art/d/durer/1/01/05sorrow.jpg,30 x 19 cm
|
||||
Albrecht Dürer,Courtyard of Innsbruck Castle With Clouds,https://www.wga.hu/art/d/durer/2/16/1/04courty.jpg,335 x 267 mm
|
||||
Albrecht Dürer,Courtyard of Innsbruck Castle Without Clouds,https://www.wga.hu/art/d/durer/2/16/1/03courty.jpg,335 x 267 mm
|
||||
Albrecht Dürer,Eve,https://www.wga.hu/art/d/durer/1/06/3eve.jpg,209 x 81 cm
|
||||
Albrecht Dürer,Fiesole Altarpiece,https://www.wga.hu/art/d/durer/1/06/7heller.jpg,190 x 260 cm
|
||||
Albrecht Dürer,Portrait of Barbara Holper,https://www.wga.hu/art/d/durer/1/01/01mother.jpg,47 x 38 cm
|
||||
Albrecht Dürer,Portrait of an unknown praying man,https://www.wga.hu/art/d/durer/2/11/5/11portra.jpg,365 x 258 mm
|
||||
Albrecht Dürer,Salvator Mundi with the Child Jesus,https://www.wga.hu/art/d/durer/1/05/02salvat.jpg,57 x 46 cm
|
||||
Albrecht Dürer,The Circumcision of Christ,https://www.wga.hu/art/d/durer/2/12/5virgin/10_life.jpg,
|
||||
Albrecht Dürer,The Death of Orpheus,https://www.wga.hu/art/d/durer/2/11/1/05orpheu.jpg,289 x 225 mm
|
||||
Albrecht Dürer,The Passion of Christ,https://www.wga.hu/art/d/durer/2/12/7smallp/2/14_small.jpg,
|
||||
Albrecht Dürer,Three Musicians,https://www.wga.hu/art/d/durer/1/04/1jabach2.jpg,94 x 51 cm
|
||||
Albrecht Dürer,Wire drawing mill near Nyremberg,https://www.wga.hu/art/d/durer/2/16/1/02mill.jpg,286 x 426 mm
|
||||
Alphonse Mucha,Gismonda,https://www.wga.hu/art/m/mucha/poster1.jpg,213 x 75 cm
|
||||
Andrea del Castagno,Last Supper,https://www.wga.hu/art/a/andrea/castagno/1_1440s/08lasts1.jpg,453 x 975 cm
|
||||
Andrei Rublev,Archangel Michael,https://www.wga.hu/art/r/rublyov/deesis2.jpg,158 x 106 cm
|
||||
Andrei Rublev,Holy Trinity,https://www.wga.hu/art/r/rublyov/trinity.jpg,142 x 114 cm
|
||||
Andrei Rublev,Hospitality of Abraham,https://www.wga.hu/art/r/rublyov/trinity.jpg,142 x 114 cm
|
||||
Andrei Rublev,Icon with the Eastern Orthodox Trinity,https://www.wga.hu/art/r/rublyov/trinity.jpg,142 x 114 cm
|
||||
Andrei Rublev,Icon with the Old Testament Trinity The Hospitality of Abraham,https://www.wga.hu/art/r/rublyov/trinity.jpg,142 x 114 cm
|
||||
Andrei Rublev,Trinity,https://www.wga.hu/art/r/rublyov/trinity.jpg,142 x 114 cm
|
||||
Angelica Kauffman,Self-Portrait of the Artist Hesitating Between the Arts of Music and Painting,https://www.wga.hu/art/k/kauffman/self_por.jpg,128 x 94 cm
|
||||
Angelica Kauffman,Venus Induces Helen to Fall in Love with Paris,https://www.wga.hu/art/k/kauffman/venushel.jpg,102 x 128 cm
|
||||
Arnold Böcklin,Isle of the Dead,https://www.wga.hu/art/b/bocklin/isle1.jpg,111 x 155 cm
|
||||
Artemisia Gentileschi,Judith Slaying Holofernes,https://www.wga.hu/art/g/gentiles/artemisi/judit.jpg,159 x 126 cm
|
||||
Artemisia Gentileschi,Judith and Her Maidservant,https://www.wga.hu/art/g/gentiles/artemisi/judith_m.jpg,114 x 93.5 cm
|
||||
Artemisia Gentileschi,Self-Portrait as the Allegory of Painting,https://www.wga.hu/art/g/gentiles/artemisi/selfport.jpg,"96,5 x 73,7 cm"
|
||||
Aubrey Beardsley,The Dancer s Reward,https://www.wga.hu/art/b/beardsle/beards04.jpg,342 x 272 mm
|
||||
Aubrey Beardsley,The Yellow Book,https://www.wga.hu/art/b/beardsle/beards07.jpg,
|
||||
Bronzino,Holy Family with St Anne and the Infant St John,https://www.wga.hu/art/b/bronzino/3/holyfam.jpg,127 x 102 cm
|
||||
Bronzino,Miracle of the Spring,https://www.wga.hu/art/b/bronzino/3a/05eleono.jpg,
|
||||
Bronzino,Portrait of Cosimo I de Medici in Armour,https://www.wga.hu/art/b/bronzino/1/cosimo1a.jpg,118 x 99 cm
|
||||
Bronzino,Portrait of Eleonora di Toledo with her son Giovanni,https://www.wga.hu/art/b/bronzino/1/eleonorb.jpg,59 x 46 cm
|
||||
Bronzino,Venus Cupid Folly and Time,https://www.wga.hu/art/b/bronzino/4/venus_cu.jpg,147 x 117 cm
|
||||
Camille Pissarro,Boulevard Montmartre,https://www.wga.hu/art/p/pissarro/camille/2/paris03.jpg,53 x 65 cm
|
||||
Camille Pissarro,Jalais Hill Pontoise,https://www.wga.hu/art/p/pissarro/camille/1/ponto01.jpg,89 x 116 cm
|
||||
Camille Pissarro,Lordship Lane Station,https://www.wga.hu/art/p/pissarro/camille/4/variou2.jpg,45 x 73 cm
|
||||
Camille Pissarro,Red Roofs,https://www.wga.hu/art/p/pissarro/camille/3/winter15.jpg,54 x 65 cm
|
||||
Caravaggio,Calling of Saint Matthew,https://www.wga.hu/art/c/caravagg/04/23conta.jpg,322 x 340 cm
|
||||
Caravaggio,David with the Head of Goliath,https://www.wga.hu/art/c/caravagg/08/52david.jpg,91 x 116 cm
|
||||
Caravaggio,Madonna di Crevole,https://www.wga.hu/art/c/caravagg/07/42loreto.jpg,260 x 150 cm
|
||||
Caravaggio,Mary Magdalene,https://www.wga.hu/art/c/caravagg/02/12magda.jpg,123 x 99 cm
|
||||
Caravaggio,Saint John the Baptist,https://www.wga.hu/art/c/caravagg/10/62behead.jpg,361 x 520 cm
|
||||
Caravaggio,Saint Matthew,https://www.wga.hu/art/c/caravagg/04/23conta.jpg,322 x 340 cm
|
||||
Caravaggio,Saint Thomas,https://www.wga.hu/art/c/caravagg/06/34thomas.jpg,107 x 146 cm
|
||||
Caravaggio,The Calling of Saint Matthew,https://www.wga.hu/art/c/caravagg/04/23conta.jpg,322 x 340 cm
|
||||
Caravaggio,The Crucifixion of Saint Peter,https://www.wga.hu/art/c/caravagg/05/28ceras.jpg,230 x 175 cm
|
||||
Caravaggio,The Crucifixion of Saint Peter and the Decapitation of Saint John the Baptist,https://www.wga.hu/art/c/caravagg/05/28ceras.jpg,230 x 175 cm
|
||||
Caspar David Friedrich,Wanderer above the Sea of Fog,https://www.wga.hu/art/f/friedric/2/209fried.jpg,95 x 75 cm
|
||||
Cimabue,Madonna Enthroned with the Child St Francis and Four Angels,https://www.wga.hu/art/c/cimabue/madonna/madonn_1.jpg,320 x 340 cm
|
||||
Cimabue,Madonna and Child Enthroned with Angels and Prophets Santa Trinit Maest,https://www.wga.hu/art/c/cimabue/madonna/madonna.jpg,384 x 223 cm
|
||||
Cimabue,Santa Trinita Maestà,https://www.wga.hu/art/c/cimabue/madonna/madonna.jpg,384 x 223 cm
|
||||
Claude Lorrain,Embarkation of the Queen of Sheba,https://www.wga.hu/art/c/claude/2/10sheba.jpg,148 x 194 cm
|
||||
Claude Monet,"Impression, Sunrise",https://www.wga.hu/art/m/monet/03/0impress.jpg,48 x 63 cm
|
||||
Claude Monet,Rouen Cathedral,https://www.wga.hu/art/m/monet/07/5cathed3.jpg,100 x 65 cm
|
||||
Claude Monet,The Japanese Bridge,https://www.wga.hu/art/m/monet/08/3bridge5.jpg,89 x 116 cm
|
||||
Claude Monet,The Lunch,https://www.wga.hu/art/m/monet/02/1vario02.jpg,230 x 150 cm
|
||||
Claude Monet,Water Lilies,https://www.wga.hu/art/m/monet/08/7liliy01.jpg,90 x 100 cm
|
||||
Diego Velázquez,Christ in the House of Martha and Mary,https://www.wga.hu/art/v/velazque/01/0108vela.jpg,"60 x 103,5 cm"
|
||||
Diego Velázquez,Kitchen Scene,https://www.wga.hu/art/v/velazque/01/0106vela.jpg,55 x 118 cm
|
||||
Diego Velázquez,Kitchen Scene with the Supper in Emmaus,https://www.wga.hu/art/v/velazque/01/0106vela.jpg,55 x 118 cm
|
||||
Diego Velázquez,Las Meninas,https://www.wga.hu/art/v/velazque/08/0801vela.jpg,318 x 276 cm
|
||||
Diego Velázquez,Philip IV of Spain 1605 1665,https://www.wga.hu/art/v/velazque/02/0204vela.jpg,210 x 102 cm
|
||||
Diego Velázquez,Portrait du Conde-Duque de Olivares,https://www.wga.hu/art/v/velazque/05/0508vela.jpg,314 x 240 cm
|
||||
Diego Velázquez,Portrait of Philip IV of Spain,https://www.wga.hu/art/v/velazque/10/1002vela.jpg,"47 x 37,5 cm"
|
||||
Diego Velázquez,Queen Mariana of Spain 1634 1696,https://www.wga.hu/art/v/velazque/10/1003vela.jpg,231 x 131 cm
|
||||
Diego Velázquez,The Buffoon Pablo de Valladolid,https://www.wga.hu/art/v/velazque/06/0604vela.jpg,214 x 125 cm
|
||||
Diego Velázquez,The Dwarf Francisco Lezcano,https://www.wga.hu/art/v/velazque/07/0702vela.jpg,107 x 83 cm
|
||||
Domenico Ghirlandaio,Birth of the Virgin,https://www.wga.hu/art/g/ghirland/domenico/6tornab/61tornab/2birth.jpg,
|
||||
Domenico Ghirlandaio,Confirmation of the Rule,https://www.wga.hu/art/g/ghirland/domenico/5sassett/frescoes/5confir.jpg,
|
||||
Domenico Ghirlandaio,Expulsion of Joachim from the Temple,https://www.wga.hu/art/g/ghirland/domenico/6tornab/61tornab/1expuls.jpg,
|
||||
Domenico Ghirlandaio,Madonna of Mercy and Lamentation,https://www.wga.hu/art/g/ghirland/domenico/1early/2ogniss.jpg,
|
||||
Domenico Ghirlandaio,Marriage of Mary,https://www.wga.hu/art/g/ghirland/domenico/6tornab/61tornab/4marria.jpg,
|
||||
Domenico Ghirlandaio,Portrait of Giovanna Tornabuoni,https://www.wga.hu/art/g/ghirland/domenico/7panel/07tornab.jpg,76 x 50 cm
|
||||
Domenico Ghirlandaio,Portrait of Lucrezia Tornabuoni,https://www.wga.hu/art/g/ghirland/domenico/7panel/07tornab.jpg,76 x 50 cm
|
||||
Domenico Ghirlandaio,Saint John the Evangelist on the Island of Patmos,https://www.wga.hu/art/g/ghirland/domenico/7panel/02patmos.jpg,
|
||||
Duccio,Christ Accused by the Pharisees,https://www.wga.hu/art/d/duccio/maesta/verso_1/verso12.jpg,49 x 57 cm
|
||||
Duccio,Christ and the Samaritan Woman,https://www.wga.hu/art/d/duccio/maesta/predel_v/pre_v_6.jpg,44 x 46 cm
|
||||
Duccio,Disputation with the Doctors,https://www.wga.hu/art/d/duccio/maesta/predel_f/pre_f_7.jpg,43 x 43 cm
|
||||
Duccio,Entry into Jerusalem,https://www.wga.hu/art/d/duccio/maesta/verso_1/verso01.jpg,100 x 57 cm
|
||||
Duccio,Gualino Madonna,https://www.wga.hu/art/d/duccio/various/22gualin.jpg,157 x 86 cm
|
||||
Duccio,Jesus Appears on Lake Tiberias,https://www.wga.hu/art/d/duccio/maesta/crown_v/cro_v_3.jpg,37 x 48 cm
|
||||
Duccio,Madonna with Child and six Angels,https://www.wga.hu/art/d/duccio/various/6perugi.jpg,97 x 63 cm
|
||||
Duccio,Maest - Backside,https://www.wga.hu/art/d/duccio/maesta/0main/maest_01.jpg,214 x 412 cm
|
||||
Duccio,Maestà,https://www.wga.hu/art/d/duccio/maesta/0main/maest_01.jpg,214 x 412 cm
|
||||
Duccio,Pact of Judas top Christ Taking Leave of the Apostles bottom,https://www.wga.hu/art/d/duccio/maesta/verso_1/verso04.jpg,50 x 53 cm
|
||||
Duccio,Rucellai Madonna,https://www.wga.hu/art/d/duccio/ruccelai/3ruccela.jpg,450 x 290 cm
|
||||
Duccio,The Calling of the Apostles Peter and Andrew,https://www.wga.hu/art/d/duccio/maesta/predel_v/pre_v_4.jpg,44 x 46 cm
|
||||
Duccio,The Healing of the Man Born Blind,https://www.wga.hu/art/d/duccio/maesta/predel_v/pre_v_7.jpg,45 x 47 cm
|
||||
Duccio,The Madonna of the Franciscans,https://www.wga.hu/art/d/duccio/various/51franci.jpg,"23,5 x 16 cm"
|
||||
Duccio,The Nativity with the Prophets Isaiah and Ezekiel,https://www.wga.hu/art/d/duccio/maesta/predel_f/pre_f_1d.jpg,48 x 87 cm
|
||||
Duccio,The Temptation of Christ on the Mountain,https://www.wga.hu/art/d/duccio/maesta/predel_v/pre_v_3.jpg,43 x 46 cm
|
||||
Duccio,The Wedding at Cana,https://www.wga.hu/art/d/duccio/maesta/predel_v/pre_v_5.jpg,44 x 47 cm
|
||||
Duccio,The three Marys at the Tomb,https://www.wga.hu/art/d/duccio/maesta/verso_2/verso23.jpg,51 x 54 cm
|
||||
Duccio,Three Marys at the Tomb,https://www.wga.hu/art/d/duccio/maesta/verso_2/verso23.jpg,51 x 54 cm
|
||||
Duccio,Three Marys at the Tomb top Descent into Hell bottom,https://www.wga.hu/art/d/duccio/maesta/verso_2/verso23.jpg,51 x 54 cm
|
||||
Edgar Degas,Blue Dancer,https://www.wga.hu/art/d/degas/5/1890s_04.jpg,67 x 67 cm
|
||||
Edgar Degas,Blue Dancers,https://www.wga.hu/art/d/degas/5/1890s_04.jpg,67 x 67 cm
|
||||
Edgar Degas,The Ballet Class,https://www.wga.hu/art/d/degas/4/1880s_03.jpg,82 x 77 cm
|
||||
Edvard Munch,Anxiety,https://www.wga.hu/art/m/munch/scream5.jpg,94 x 74 cm
|
||||
Edvard Munch,Madonna,https://www.wga.hu/art/m/munch/madonna1.jpg,91 x 71 cm
|
||||
Edvard Munch,The Dance of Life,https://www.wga.hu/art/m/munch/stages2.jpg,125 x 191 cm
|
||||
Edvard Munch,The Scream,https://www.wga.hu/art/m/munch/scream1.jpg,91 x 74 cm
|
||||
Edvard Munch,The Sick Child,https://www.wga.hu/art/m/munch/sick1.jpg,120 x 119 cm
|
||||
El Greco,Disrobement of Christ,https://www.wga.hu/art/g/greco_el/04/0401grec.jpg,
|
||||
El Greco,Dormition of the Virgin,https://www.wga.hu/art/g/greco_el/01/0101grec.jpg,"61,4 x 45 cm"
|
||||
El Greco,Ecstasy of St Francis,https://www.wga.hu/art/g/greco_el/06/0610grec.jpg,102 x 75 cm
|
||||
El Greco,Miracle of Saint Peter,https://www.wga.hu/art/g/greco_el/20/2005grec.jpg,209 x 106 cm
|
||||
El Greco,Saint Francis reveives the stigmata,https://www.wga.hu/art/g/greco_el/08/0804grec.jpg,105 x 80 cm
|
||||
El Greco,Saint Joseph,https://www.wga.hu/art/g/greco_el/06/0613grec.jpg,68 x 56 cm
|
||||
El Greco,Saint Peter,https://www.wga.hu/art/g/greco_el/20/2005grec.jpg,209 x 106 cm
|
||||
El Greco,Saint Peter Weeping,https://www.wga.hu/art/g/greco_el/20/2005grec.jpg,209 x 106 cm
|
||||
El Greco,Studies for Martyrdom of Saint Peter the Martyr,https://www.wga.hu/art/g/greco_el/20/2005grec.jpg,209 x 106 cm
|
||||
El Greco,The Penitent Saint Jerome,https://www.wga.hu/art/g/greco_el/21/2104grec.jpg,166 x 110 cm
|
||||
El Greco,The Stigmata of Saint Francis,https://www.wga.hu/art/g/greco_el/08/0804grec.jpg,105 x 80 cm
|
||||
El Greco,View of Toledo,https://www.wga.hu/art/g/greco_el/11/1104grec.jpg,121 x 109 cm
|
||||
El Greco,the repose of the Virgin Mary,https://www.wga.hu/art/g/greco_el/10/1010grec.jpg,52 x 36 cm
|
||||
Eugène Delacroix,Arab Horses Fighting in a Stable,https://www.wga.hu/art/d/delacroi/5/512delac.jpg,"64,6 x 81 cm"
|
||||
Eugène Delacroix,Liberty Leading the People,https://www.wga.hu/art/d/delacroi/2/208delac.jpg,260 x 325 cm
|
||||
Eugène Delacroix,The Death of Sardanapalus,https://www.wga.hu/art/d/delacroi/2/204delac.jpg,392 x 496 cm
|
||||
Eugène Delacroix,The Massacre at Chios,https://www.wga.hu/art/d/delacroi/1/107delac.jpg,419 x 354 cm
|
||||
Eugène Delacroix,Women of Algiers in their Apartment,https://www.wga.hu/art/d/delacroi/3/307delac.jpg,180 x 229 cm
|
||||
Fra Angelico,Apparition at Arles,https://www.wga.hu/art/a/angelico/12/02compa4.jpg,26 x 31 cm
|
||||
Fra Angelico,Birth of the Virgin,https://www.wga.hu/art/a/angelico/04/2edge1.jpg,23 x 14 cm
|
||||
Fra Angelico,Compagnia di San Francesco altarpiece,https://www.wga.hu/art/a/angelico/12/01compag.jpg,189 x 81 cm
|
||||
Fra Angelico,Deposition from the Cross,https://www.wga.hu/art/a/angelico/08/1trinita.jpg,176 x 185 cm
|
||||
Fra Angelico,Saint Francis and a Bishop Saint,https://www.wga.hu/art/a/angelico/00/11fieso7.jpg,
|
||||
Fra Angelico,Sant Ambrogio Altarpiece,https://www.wga.hu/art/a/angelico/07/altar_sm.jpg,220 x 227 cm
|
||||
Fra Angelico,The Annunciation,https://www.wga.hu/art/a/angelico/09/cells/03_annun.jpg,176 x 148 cm
|
||||
Fra Angelico,The Funeral of a Bishop Saint,https://www.wga.hu/art/a/angelico/00/11fieso7.jpg,
|
||||
Fra Angelico,The Meeting of Saint Dominic and Saint Francis of Assisi,https://www.wga.hu/art/a/angelico/13/03prede4.jpg,27 x 26 cm
|
||||
Francisco Goya,Dream of St Gregory,https://www.wga.hu/art/g/goya/4/403goya.jpg,188 x 113 cm
|
||||
Francisco Goya,Portrait of Andr Derain,https://www.wga.hu/art/g/goya/4/407goya.jpg,"95 x 65,7 cm"
|
||||
Francisco Goya,Portrait of the Duke of Wellington,https://www.wga.hu/art/g/goya/7/707goya.jpg,64 x 52 cm
|
||||
Francisco Goya,The Clothed Maja,https://www.wga.hu/art/g/goya/5/502goya.jpg,97 x 190 cm
|
||||
Francisco Goya,The Naked Maja,https://www.wga.hu/art/g/goya/5/501goya.jpg,97 x 190 cm
|
||||
Francisco Goya,The Third of May 1808,https://www.wga.hu/art/g/goya/7/714goya.jpg,266 x 345 cm
|
||||
Francisco Goya,Witches Sabbath,https://www.wga.hu/art/g/goya/2/218goya.jpg,43 x 30 cm
|
||||
François Boucher,Madame de Pompadour,https://www.wga.hu/art/b/boucher/2/pompado2.jpg,"72,5 x 57 cm"
|
||||
François Boucher,The Blonde Odalisque,https://www.wga.hu/art/b/boucher/2/o_murph.jpg,59 x 73 cm
|
||||
François Boucher,The Toilet of Venus,https://www.wga.hu/art/b/boucher/2/venus_to.jpg,108 x 85 cm
|
||||
Georges Seurat,Bathers at Asni res,https://www.wga.hu/art/s/seurat/figures/bathers1.jpg,201 x 300 cm
|
||||
Georges Seurat,Models,https://www.wga.hu/art/s/seurat/figures/models.jpg,39 x 49 cm
|
||||
Georges Seurat,Sunday Afternoon on the Island of La Grande Jatte,https://www.wga.hu/art/s/seurat/figures/jatte.jpg,208 x 308 cm
|
||||
Georges Seurat,The Circus,https://www.wga.hu/art/s/seurat/figures/zcircus1.jpg,180 x 148 cm
|
||||
Georges Seurat,The Lighthouse at Honfleur,https://www.wga.hu/art/s/seurat/landscap/honfleu3.jpg,67 x 82 cm
|
||||
Georges Seurat,Young Woman Powdering Herself,https://www.wga.hu/art/s/seurat/figures/powderin.jpg,96 x 80 cm
|
||||
Giorgione,A Woman Spinning and an Old Woman,https://www.wga.hu/art/g/giorgion/portrait/03woman.jpg,68 x 59 cm
|
||||
Giorgione,Old Woman Cooking Eggs,https://www.wga.hu/art/g/giorgion/portrait/03woman.jpg,68 x 59 cm
|
||||
Giorgione,Sleeping Venus,https://www.wga.hu/art/g/giorgion/various/venus.jpg,108 x 175 cm
|
||||
Giorgione,The Pastoral Concert,https://www.wga.hu/art/g/giorgion/various/concert.jpg,110 x 138 cm
|
||||
Giotto,Apparition to Fra Agostino and to Bishop Guido of Arezzo,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc21.jpg,270 x 230 cm
|
||||
Giotto,Badia Polyptych,https://www.wga.hu/art/g/giotto/z_panel/2panel/5badia.jpg,91x334 cm
|
||||
Giotto,Canonization of St Francis,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc24.jpg,270 x 230 cm
|
||||
Giotto,Carnesecchi Triptych,https://www.wga.hu/art/g/giotto/z_panel/4stefane/10stefan.jpg,220 x 245 cm
|
||||
Giotto,Death and Ascension of St Francis,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc20.jpg,270 x 230 cm
|
||||
Giotto,Death of the Knight of Celano,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc16.jpg,270 x 230 cm
|
||||
Giotto,Descent into Hell,https://www.wga.hu/art/g/giotto/z_panel/3polypty/6limbo.jpg,45 x 44 cm
|
||||
Giotto,Dream of Innocent III,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc06.jpg,270 x 230 cm
|
||||
Giotto,Dream of the Palace,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc03.jpg,270 x 230 cm
|
||||
Giotto,Exorcism of the Demons at Arezzo,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc10.jpg,270 x 230 cm
|
||||
Giotto,Homage of a Simple Man,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc01.jpg,270 x 230 cm
|
||||
Giotto,Institution of the Crib at Greccio,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc13.jpg,270 x 230 cm
|
||||
Giotto,Joachim s Dream,https://www.wga.hu/art/g/giotto/padova/1joachim/joachi5.jpg,200 x 185 cm
|
||||
Giotto,Joachim s Sacrificial Offering,https://www.wga.hu/art/g/giotto/padova/1joachim/joachi4.jpg,200 x 185 cm
|
||||
Giotto,Miracle of the Crucifix,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc04.jpg,270 x 230 cm
|
||||
Giotto,Renunciation of Wordly Goods,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc05.jpg,270 x 230 cm
|
||||
Giotto,Saint Francis,https://www.wga.hu/art/g/giotto/assisi/upper/05saints.jpg,
|
||||
Giotto,Scenes from the New Testament Lamentation,https://www.wga.hu/art/g/giotto/assisi/upper/new_test/05lamen.jpg,300 x 300 cm
|
||||
Giotto,Sermon to the Birds,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc15.jpg,270 x 200 cm
|
||||
Giotto,St Francis Giving his Mantle to a Poor Man,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc02.jpg,270 x 230 cm
|
||||
Giotto,St Francis Mourned by St Clare,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc23.jpg,270 x 230 cm
|
||||
Giotto,St Francis Preaching before Honorius III,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc17.jpg,270 x 230 cm
|
||||
Giotto,St Francis before the Sultan,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc11.jpg,270 x 230 cm
|
||||
Giotto,Verification of the Stigmata,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc22.jpg,270 x 230 cm
|
||||
Giotto,Vision of the Flaming Chariot,https://www.wga.hu/art/g/giotto/assisi/upper/legend/franc08.jpg,270 x 230 cm
|
||||
Giotto,the seven virtues,https://www.wga.hu/art/g/giotto/padova/7vicevir/virtu_1.jpg,120 x 60 cm
|
||||
Gustav Klimt,Portrait of Adele Bloch-Bauer I,https://www.wga.hu/art/k/klimt/2/33portrait.jpg,140 x 140 cm
|
||||
Gustav Klimt,Stenberg Brothers Exhibition Poster,https://www.wga.hu/art/k/klimt/2/21poster.jpg,970 x 700 mm
|
||||
Gustav Klimt,The Kiss,https://www.wga.hu/art/k/klimt/2/24kiss.jpg,180 x 180 cm
|
||||
Gustav Klimt,The Tree of Life,https://www.wga.hu/art/k/klimt/2/42stoclet.jpg,200 x 102 cm
|
||||
Gustave Courbet,A Burial at Ornans,https://www.wga.hu/art/c/courbet/1/courb104.jpg,315 x 668 cm
|
||||
Gustave Courbet,Lot and his daughters,https://www.wga.hu/art/c/courbet/1/courb101.jpg,89 x 116 cm
|
||||
Gustave Courbet,Man with a Pipe,https://www.wga.hu/art/c/courbet/2/courb223.jpg,56 x 47 cm
|
||||
Gustave Courbet,Sleep,https://www.wga.hu/art/c/courbet/3/courb303.jpg,135 x 200 cm
|
||||
Gustave Courbet,The Origin of the World,https://www.wga.hu/art/c/courbet/3/courb304.jpg,46 x 55 cm
|
||||
Henri de Toulouse-Lautrec,At the Moulin Rouge,https://www.wga.hu/art/t/toulouse/3/2mouli04.jpg,80 x 60 cm
|
||||
Henri de Toulouse-Lautrec,In Bed The Kiss,https://www.wga.hu/art/t/toulouse/3/3bed2.jpg,53 x 34 cm
|
||||
Henri de Toulouse-Lautrec,Jane Avril,https://www.wga.hu/art/t/toulouse/6/litho25.jpg,56 x 34 cm
|
||||
Henri de Toulouse-Lautrec,Moulin Rouge La Goulue,https://www.wga.hu/art/t/toulouse/6/litho01.jpg,191 x 117 cm
|
||||
Henri de Toulouse-Lautrec,Portrait of Vincent van Gogh,https://www.wga.hu/art/t/toulouse/2/1misc01.jpg,54 x 45 cm
|
||||
Henri de Toulouse-Lautrec,Yvette Guilbert,https://www.wga.hu/art/t/toulouse/4/cabare17.jpg,56 x 48 cm
|
||||
Hieronymus Bosch,Christ Child with a Walking Frame,https://www.wga.hu/art/b/bosch/1early/13reverse.jpg,
|
||||
Hieronymus Bosch,Crucifixion with a Donor,https://www.wga.hu/art/b/bosch/1early/08crucif.jpg,"74,7 x 61 cm"
|
||||
Hieronymus Bosch,Death and the Miser,https://www.wga.hu/art/b/bosch/5panels/10deathm.jpg,93 x 31 cm
|
||||
Hieronymus Bosch,Garden of Earthly Delights,https://www.wga.hu/art/b/bosch/3garden/1garden.jpg,220 x 195 cm
|
||||
Hieronymus Bosch,Head of a Halberdier,https://www.wga.hu/art/b/bosch/1early/02fragme.jpg,28 x 20 cm
|
||||
Hieronymus Bosch,The Garden of Earthly Delights,https://www.wga.hu/art/b/bosch/3garden/1garden.jpg,220 x 195 cm
|
||||
Hieronymus Bosch,Two Male Heads,https://www.wga.hu/art/b/bosch/1early/03heads.jpg,"14,5 x 12 cm"
|
||||
Hieronymus Bosch,Two monsters,https://www.wga.hu/art/b/bosch/93graph/28graphi.jpg,164 x 116 mm
|
||||
Hieronymus Bosch,Visions of the Hereafter Fall of the Damned into Hell,https://www.wga.hu/art/b/bosch/6venice/2hell1.jpg,"86,5 x 39,5 cm"
|
||||
Honoré Daumier,Rue Transnonain,https://www.wga.hu/art/d/daumier/11transn.jpg,290 x 445 mm
|
||||
Honoré Daumier,The Uprising,https://www.wga.hu/art/d/daumier/08uprisi.jpg,88 x 113 cm
|
||||
Honoré Daumier,Third-Class Carriage,https://www.wga.hu/art/d/daumier/093rdcla.jpg,65 x 90 cm
|
||||
J. M. W. Turner,Dido Building Carthage,https://www.wga.hu/art/t/turner/1/107turnd.jpg,156 x 230 cm
|
||||
J. M. W. Turner,Rain Steam and Speed,https://www.wga.hu/art/t/turner/2/205turne.jpg,91 x 122 cm
|
||||
J. M. W. Turner,Snow Storm Steam-Boat off a Harbour s Mouth,https://www.wga.hu/art/t/turner/2/2040turn.jpg,91 x 122 cm
|
||||
J. M. W. Turner,The Fighting Temeraire,https://www.wga.hu/art/t/turner/2/201turne.jpg,91 x 122 cm
|
||||
J. M. W. Turner,Ulysses Deriding Polyphemus,https://www.wga.hu/art/t/turner/2/209turne.jpg,132 x 203 cm
|
||||
Jacques-Louis David,Leonidas at Thermopylae,https://www.wga.hu/art/d/david_j/4/414david.jpg,395 x 531 cm
|
||||
Jacques-Louis David,Oath of the Horatii,https://www.wga.hu/art/d/david_j/2/200david.jpg,330 x 425 cm
|
||||
Jacques-Louis David,Portrait of the Artist Holding a Thistle,https://www.wga.hu/art/d/david_j/3/304david.jpg,81 x 64 cm
|
||||
Jacques-Louis David,Portrait of the artist s father,https://www.wga.hu/art/d/david_j/3/304david.jpg,81 x 64 cm
|
||||
Jacques-Louis David,The Death of Marat,https://www.wga.hu/art/d/david_j/3/301david.jpg,162 x 128 cm
|
||||
Jacques-Louis David,The Intervention of the Sabine Women,https://www.wga.hu/art/d/david_j/3/311david.jpg,385 x 522 cm
|
||||
Jacques-Louis David,The Oath of the Horatii,https://www.wga.hu/art/d/david_j/2/200david.jpg,330 x 425 cm
|
||||
Jan van Eyck,A Young Man in a scarlet turban,https://www.wga.hu/art/e/eyck_van/jan/01page/13turban.jpg,"25,5 x 19 cm"
|
||||
Jan van Eyck,Adoration of the Lamb from the Ghent Altarpiece,https://www.wga.hu/art/e/eyck_van/jan/09ghent/1open3/l3adora.jpg,"137,7 x 242,3 cm"
|
||||
Jan van Eyck,Arnolfini Portrait,https://www.wga.hu/art/e/eyck_van/jan/02page/20arnolf.jpg,29 x 20 cm
|
||||
Jan van Eyck,Ghent Altarpiece,https://www.wga.hu/art/e/eyck_van/jan/09ghent/1open.jpg,350 x 461 cm
|
||||
Jan van Eyck,Madonna Enthroned,https://www.wga.hu/art/e/eyck_van/jan/02page/23suckli.jpg,"65,5 x 49,5 cm"
|
||||
Jan van Eyck,Madonna at the Fountain,https://www.wga.hu/art/e/eyck_van/jan/02page/29founta.jpg,19 x 12 cm
|
||||
Jan van Eyck,Madonna with Canon Joris van der Paele,https://www.wga.hu/art/e/eyck_van/jan/21paele/21paele.jpg,122 x 157 cm
|
||||
Jan van Eyck,Man in a Red Turban,https://www.wga.hu/art/e/eyck_van/jan/01page/13turban.jpg,"25,5 x 19 cm"
|
||||
Jan van Eyck,Portrait of Baudouin de Lannoy,https://www.wga.hu/art/e/eyck_van/jan/02page/19lannoy.jpg,26 x 20 cm
|
||||
Jan van Eyck,Portrait of Cardinal Niccol Albergati,https://www.wga.hu/art/e/eyck_van/jan/01page/111alber.jpg,212 x 180 mm
|
||||
Jan van Eyck,Portrait of Giovanni di Nicolao Arnolfini,https://www.wga.hu/art/e/eyck_van/jan/02page/20arnolf.jpg,29 x 20 cm
|
||||
Jan van Eyck,Portrait of Jan de Leeuw,https://www.wga.hu/art/e/eyck_van/jan/02page/22leeuw.jpg,"24,5 x 19 cm"
|
||||
Jan van Eyck,Portrait of Margareta van Eyck,https://www.wga.hu/art/e/eyck_van/jan/02page/28margar.jpg,"32,6 x 25,8 cm"
|
||||
Jan van Eyck,Portrait of a Man with Carnation,https://www.wga.hu/art/e/eyck_van/jan/02page/17carnat.jpg,40 x 31 cm
|
||||
Jan van Eyck,Prophet Zacharias Angel of The Annunciation,https://www.wga.hu/art/e/eyck_van/jan/09ghent/2closed1/u1annun.jpg,"164,8 x 71,7 cm"
|
||||
Jan van Eyck,Singing Angels,https://www.wga.hu/art/e/eyck_van/jan/09ghent/1open2/u2singi.jpg,"164,5 x 71,5 cm"
|
||||
Jan van Eyck,Study for Cardinal Niccol Albergati,https://www.wga.hu/art/e/eyck_van/jan/01page/11alberg.jpg,"34,1 x 27,3 cm"
|
||||
Jan van Eyck,The Ghent Altarpiece The Donor,https://www.wga.hu/art/e/eyck_van/jan/09ghent/2closed2/l1donor.jpg,"149,1 x 54,1 cm"
|
||||
Jan van Eyck,The Ghent Altarpiece wings closed,https://www.wga.hu/art/e/eyck_van/jan/09ghent/1open.jpg,350 x 461 cm
|
||||
Jan van Eyck,The Just Judges,https://www.wga.hu/art/e/eyck_van/jan/09ghent/1open3/l1judge.jpg,145 x 51 cm
|
||||
Jan van Eyck,The Madonna in the Church,https://www.wga.hu/art/e/eyck_van/jan/01page/04church.jpg,32 x 14 cm
|
||||
Jean-Antoine Watteau,F tes Venitiennes,https://www.wga.hu/art/w/watteau/antoine/2/15fetesv.jpg,56 x 46 cm
|
||||
Jean-Antoine Watteau,Gilles,https://www.wga.hu/art/w/watteau/antoine/2/17gilles.jpg,"184,5 x 149,5 cm"
|
||||
Jean-Antoine Watteau,Mezzetin,https://www.wga.hu/art/w/watteau/antoine/2/141mezze.jpg,"55,2 x 43,2 cm"
|
||||
Jean-Antoine Watteau,Pilgrimage to Cythera,https://www.wga.hu/art/w/watteau/antoine/1/08cythe2.jpg,129 x 194 cm
|
||||
Jean-Antoine Watteau,The Dance,https://www.wga.hu/art/w/watteau/antoine/2/19dance.jpg,97 x 116 cm
|
||||
Jean-Auguste-Dominique Ingres,Grande Odalisque,https://www.wga.hu/art/i/ingres/05ingres.jpg,91 x 162 cm
|
||||
Jean-Auguste-Dominique Ingres,Jupiter and Thetis,https://www.wga.hu/art/i/ingres/03ingrex.jpg,320 x 260 cm
|
||||
Jean-Auguste-Dominique Ingres,Napoleon on His Imperial Throne,https://www.wga.hu/art/i/ingres/010ingre.jpg,259 x 162 cm
|
||||
Jean-Auguste-Dominique Ingres,Oedipus and the Sphinx,https://www.wga.hu/art/i/ingres/071ingre.jpg,189 x 144 cm
|
||||
Jean-Auguste-Dominique Ingres,The Turkish Bath,https://www.wga.hu/art/i/ingres/17ingres.jpg,
|
||||
Jean-Baptiste-Camille Corot,Bust of a Young Woman,https://www.wga.hu/art/c/corot/corot07.jpg,53 x 40 cm
|
||||
Jean-Baptiste-Camille Corot,The Artist s Studio,https://www.wga.hu/art/c/corot/corot163.jpg,56 x 46 cm
|
||||
Jean-François Millet,Haystacks Autumn,https://www.wga.hu/art/m/millet/06autumn.jpg,85 x 110 cm
|
||||
Jean-François Millet,The Angelus,https://www.wga.hu/art/m/millet/04angel.jpg,56 x 66 cm
|
||||
Jean-François Millet,The Gleaners,https://www.wga.hu/art/m/millet/03gleaner.jpg,"85,5 x 111 cm"
|
||||
Jean-Honoré Fragonard,A Young Girl Reading,https://www.wga.hu/art/f/fragonar/father/2/04readin.jpg,81 x 65 cm
|
||||
Jean-Honoré Fragonard,The Progress of Love,https://www.wga.hu/art/f/fragonar/father/2/011pursui.jpg,318 x 216 cm
|
||||
Jean-Honoré Fragonard,The Stolen Kiss,https://www.wga.hu/art/f/fragonar/father/2/11stolen.jpg,45 x 55 cm
|
||||
Jean-Honoré Fragonard,The Swing,https://www.wga.hu/art/f/fragonar/father/1/021seesa.jpg,120 x 95 cm
|
||||
John Constable,The Hay Wain,https://www.wga.hu/art/c/constabl/haywain.jpg,130 x 185 cm
|
||||
John Constable,The Lock,https://www.wga.hu/art/c/constabl/lock.jpg,142 x 120 cm
|
||||
Leonardo da Vinci,Allegory with a Wolf and an Eagle,https://www.wga.hu/art/l/leonardo/09study/12allego.jpg,170 x 280 mm
|
||||
Leonardo da Vinci,Anatomical Deawing of the Heart and its Blood Vessels,https://www.wga.hu/art/l/leonardo/10anatom/5heart.jpg,
|
||||
Leonardo da Vinci,Anatomical Drawings of the Heart and its Blood Vessels,https://www.wga.hu/art/l/leonardo/10anatom/5heart.jpg,
|
||||
Leonardo da Vinci,Anatomical Studies of a Bear s Foot,https://www.wga.hu/art/l/leonardo/10anatom/4larynx.jpg,
|
||||
Leonardo da Vinci,Anatomical Studies of the Blood Supply to the Liver,https://www.wga.hu/art/l/leonardo/10anatom/4larynx.jpg,
|
||||
Leonardo da Vinci,Anatomical Studies of the Bones of the Foot and Study of the Shoulder,https://www.wga.hu/art/l/leonardo/10anatom/3should1.jpg,289 x 199 mm
|
||||
Leonardo da Vinci,Anatomical Studies of the Developing Foetus,https://www.wga.hu/art/l/leonardo/10anatom/4larynx.jpg,
|
||||
Leonardo da Vinci,Anatomical Studies of the Facial Nerves,https://www.wga.hu/art/l/leonardo/10anatom/4larynx.jpg,
|
||||
Leonardo da Vinci,Anatomical Studies of the Foot,https://www.wga.hu/art/l/leonardo/10anatom/4larynx.jpg,
|
||||
Leonardo da Vinci,Anatomical Studies of the Hand,https://www.wga.hu/art/l/leonardo/10anatom/4larynx.jpg,
|
||||
Leonardo da Vinci,Anatomical Studies of the Human Skeleton,https://www.wga.hu/art/l/leonardo/10anatom/4larynx.jpg,
|
||||
Leonardo da Vinci,Madonna Lactans,https://www.wga.hu/art/l/leonardo/03/3litta.jpg,42 x 33 cm
|
||||
Leonardo da Vinci,Mona Lisa,https://www.wga.hu/art/l/leonardo/04/0monalis.jpg,77 x 53 cm
|
||||
Leonardo da Vinci,The Last Supper,https://www.wga.hu/art/l/leonardo/03/4lastsu1.jpg,460 x 880 cm
|
||||
Masaccio,Baptism of the Neophytes,https://www.wga.hu/art/m/masaccio/brancacc/st_peter/baptism.jpg,255 x 162 cm
|
||||
Masaccio,Madonna and Child Enthroned and Twelve Angels,https://www.wga.hu/art/m/masaccio/pisa/pisa_cen.jpg,136 x 73 cm
|
||||
Masaccio,Madonna and Child with Angels,https://www.wga.hu/art/m/masaccio/pisa/pisa_cen.jpg,136 x 73 cm
|
||||
Masaccio,Madonna and Child with Five Angels,https://www.wga.hu/art/m/masaccio/pisa/pisa_cen.jpg,136 x 73 cm
|
||||
Masaccio,Raising of the Son of Teophilus and St Peter Enthroned,https://www.wga.hu/art/m/masaccio/brancacc/st_peter/theo_pet.jpg,230 x 598 cm
|
||||
Masaccio,Saints Jerome and John the Baptist,https://www.wga.hu/art/m/masaccio/z_panels/jerome.jpg,125 x 59 cm
|
||||
Masaccio,San Giovenale Triptych,https://www.wga.hu/art/m/masaccio/z_panels/giovena1.jpg,110 x 65 cm
|
||||
Masaccio,St Peter Healing the Sick with His Shadow,https://www.wga.hu/art/m/masaccio/brancacc/st_peter/shadow.jpg,230 x 162 cm
|
||||
Masaccio,The Distribution of Alms and Death of Ananias,https://www.wga.hu/art/m/masaccio/brancacc/st_peter/distrib.jpg,230 x 162 cm
|
||||
Masaccio,The agony of Christ in the garden of Gethsemane St Jerome as penitent,https://www.wga.hu/art/m/masaccio/z_panels/garden.jpg,62 x 44 cm
|
||||
Michelangelo,Cumaean Sibyl,https://www.wga.hu/art/m/michelan/3sistina/4sibyls/05_6si3.jpg,375 x 380 cm
|
||||
Michelangelo,Delphic Sibyl,https://www.wga.hu/art/m/michelan/3sistina/4sibyls/01_7si1.jpg,350 x 380 cm
|
||||
Michelangelo,Doni Tondo,https://www.wga.hu/art/m/michelan/2paintin/1/1donitop.jpg,
|
||||
Michelangelo,Erythraean Sibyl,https://www.wga.hu/art/m/michelan/3sistina/4sibyls/03_1si2.jpg,360 x 380 cm
|
||||
Michelangelo,Erythraean Sibyl and arched window with a view,https://www.wga.hu/art/m/michelan/3sistina/4sibyls/03_1si2.jpg,360 x 380 cm
|
||||
Michelangelo,Jacob and Joseph,https://www.wga.hu/art/m/michelan/3sistina/6lunette/02/lu02jac.jpg,215 x 430 cm
|
||||
Michelangelo,Judith and Holofernes,https://www.wga.hu/art/m/michelan/3sistina/5spandre/00_1pe1.jpg,570 x 970 cm
|
||||
Michelangelo,Studies of a Horse with Two Nude Riders and a Male Torso,https://www.wga.hu/art/m/michelan/4drawing/02/25cascin.jpg,222 x 198 mm
|
||||
Michelangelo,The Ancestors of Christ Zerubbabel Abiud and Eliakim,https://www.wga.hu/art/m/michelan/3sistina/6lunette/05/lu05zer.jpg,215 x 430 cm
|
||||
Michelangelo,The Creation,https://www.wga.hu/art/m/michelan/3sistina/1genesis/5eve/05_2ce5f.jpg,
|
||||
Michelangelo,The Creation of Adam,https://www.wga.hu/art/m/michelan/3sistina/1genesis/6adam/06_3ce6.jpg,280 x 570 cm
|
||||
Michelangelo,The Deluge,https://www.wga.hu/art/m/michelan/3sistina/1genesis/2flood/02_3ce2.jpg,280 x 570 cm
|
||||
Michelangelo,The Drunkenness of Noah,https://www.wga.hu/art/m/michelan/3sistina/1genesis/1drunken/01_2ce1f.jpg,
|
||||
Michelangelo,The Sacrifice of Noah,https://www.wga.hu/art/m/michelan/3sistina/1genesis/3sacrifi/03_2ce3f.jpg,
|
||||
Michelangelo,Three Standing Men in Wide Cloaks Turned to the Left,https://www.wga.hu/art/m/michelan/4drawing/01/03early.jpg,292 x 200 mm
|
||||
Nicolas Poussin,Rape of the Sabine Women,https://www.wga.hu/art/p/poussin/2a/05sabin1.jpg,"154,6 x 209,9 cm"
|
||||
Odilon Redon,Closed Eyes,https://www.wga.hu/art/r/redon/closedey.jpg,44 x 36 cm
|
||||
Parmigianino,Pallas Athena,https://www.wga.hu/art/p/parmigia/1/pallas_a.jpg,"63,8 x 45,1 cm"
|
||||
Parmigianino,Self-Portrait in a Convex Mirror,https://www.wga.hu/art/p/parmigia/1/convex.jpg,
|
||||
Parmigianino,Virgin and Child with an Angel,https://www.wga.hu/art/p/parmigia/1/virgin_a.jpg,26 x 19 cm
|
||||
Parmigianino,Vision of Saint Jerome,https://www.wga.hu/art/p/parmigia/1/v_jerome.jpg,343 x 149 cm
|
||||
Paul Cézanne,Apples and Oranges,https://www.wga.hu/art/c/cezanne/4/4still6.jpg,74 x 93 cm
|
||||
Paul Cézanne,Houses at l'Estaque,https://www.wga.hu/art/c/cezanne/2/1lands09.jpg,42 x 59 cm
|
||||
Paul Cézanne,Mont Sainte-Victoire,https://www.wga.hu/art/c/cezanne/3/1lands09.jpg,67 x 92 cm
|
||||
Paul Cézanne,The Card Players,https://www.wga.hu/art/c/cezanne/4/2figure1.jpg,48 x 57 cm
|
||||
Paul Gauguin,Nevermore,https://www.wga.hu/art/g/gauguin/06/tahiti63.jpg,60 x 116 cm
|
||||
Paul Gauguin,The Yellow Christ,https://www.wga.hu/art/g/gauguin/02/6pould05.jpg,92 x 73 cm
|
||||
Paul Gauguin,Two Tahitian Women,https://www.wga.hu/art/g/gauguin/06/tahiti72.jpg,94 x 72 cm
|
||||
Paul Gauguin,Vision After the Sermon,https://www.wga.hu/art/g/gauguin/02/3ponta04.jpg,73 x 92 cm
|
||||
Paul Gauguin,Where Do We Come From,https://www.wga.hu/art/g/gauguin/06/tahiti66.jpg,141 x 346 cm
|
||||
Paul Gauguin,Woman with a Hat,https://www.wga.hu/art/g/gauguin/09/graphi16.jpg,150 x 108 mm
|
||||
Paul Gauguin,Young Girls by the Sea,https://www.wga.hu/art/g/gauguin/04/tahiti23.jpg,68 x 92 cm
|
||||
Peter Paul Rubens,Assumption of Mary,https://www.wga.hu/art/r/rubens/7graphic/04sketch.jpg,88 x 59 cm
|
||||
Peter Paul Rubens,Cupid Carving his Bow,https://www.wga.hu/art/r/rubens/21mythol/14mythol.jpg,142 x 108 cm
|
||||
Peter Paul Rubens,Deer and Monkeys on the reverse of Judith with the Head of Holofernes,https://www.wga.hu/art/r/rubens/15biblic/08judith.jpg,120 x 111 cm
|
||||
Peter Paul Rubens,Esther before Ahasuerus,https://www.wga.hu/art/r/rubens/7graphic/04sketc.jpg,33 x 32 cm
|
||||
Peter Paul Rubens,Feast of Herod,https://www.wga.hu/art/r/rubens/15biblic/10feast.jpg,208 x 264 cm
|
||||
Peter Paul Rubens,Judith and the Head of Holofernes,https://www.wga.hu/art/r/rubens/15biblic/08judith.jpg,120 x 111 cm
|
||||
Peter Paul Rubens,Judith with the Head of Holofernes,https://www.wga.hu/art/r/rubens/15biblic/08judith.jpg,120 x 111 cm
|
||||
Peter Paul Rubens,Music-making Angels,https://www.wga.hu/art/r/rubens/7graphic/11sketcx.jpg,65 x 83 cm
|
||||
Peter Paul Rubens,St Francis of Assisi Receiving the Stigmata,https://www.wga.hu/art/r/rubens/14religi/78religi.jpg,264 x 192 cm
|
||||
Peter Paul Rubens,The Education of the Virgin,https://www.wga.hu/art/r/rubens/13religi/58religi.jpg,193 x 140 cm
|
||||
Peter Paul Rubens,The Honeysuckle Bower,https://www.wga.hu/art/r/rubens/41portra/08artist.jpg,"178 x 136,5 cm"
|
||||
Peter Paul Rubens,The Martyrdom of St Dionysius,https://www.wga.hu/art/r/rubens/14religi/73religi.jpg,455 x 347 cm
|
||||
Peter Paul Rubens,The Virgin Flanked by Music Making Angels,https://www.wga.hu/art/r/rubens/7graphic/11sketcx.jpg,65 x 83 cm
|
||||
Peter Paul Rubens,Virgin and Child Enthroned with Four Saints,https://www.wga.hu/art/r/rubens/7graphic/11sketch.jpg,80 x 56 cm
|
||||
Pierre Puvis de Chavannes,The Poor Fisherman,https://www.wga.hu/art/p/puvis/fisherma.jpg,156 x 193 cm
|
||||
Pierre-Auguste Renoir,Dance at Le Moulin de la Galette,https://www.wga.hu/art/r/renoir/2/2renoi13.jpg,131 x 175 cm
|
||||
Pierre-Auguste Renoir,Diana,https://www.wga.hu/art/r/renoir/1/1renoi06.jpg,196 x 130 cm
|
||||
Pierre-Auguste Renoir,Girl with a Watering Can,https://www.wga.hu/art/r/renoir/2/2renoi19.jpg,100 x 73 cm
|
||||
Pierre-Auguste Renoir,Houses at Chatou,https://www.wga.hu/art/r/renoir/2/2renoi31.jpg,81 x 100 cm
|
||||
Pierre-Auguste Renoir,Luncheon of the Boating Party,https://www.wga.hu/art/r/renoir/3/3renoi11.jpg,130 x 173 cm
|
||||
Pierre-Auguste Renoir,Maternity,https://www.wga.hu/art/r/renoir/3/3renoi26.jpg,82 x 65 cm
|
||||
Pierre-Auguste Renoir,The Farmers Lunch,https://www.wga.hu/art/r/renoir/2/2renoi12.jpg,54 x 65 cm
|
||||
Pierre-Auguste Renoir,Two Sisters On the Terrace,https://www.wga.hu/art/r/renoir/3/3renoi06.jpg,100 x 80 cm
|
||||
Pontormo,Joseph in Egypt,https://www.wga.hu/art/p/pontormo/1/03josep3.jpg,96 x 109 cm
|
||||
Pontormo,Supper at Emmaus,https://www.wga.hu/art/p/pontormo/3/04emmaus.jpg,230 x 173 cm
|
||||
Raphael,Angel with a Tambourine,https://www.wga.hu/art/r/raphael/7drawing/1/34study.jpg,189 x 126 mm
|
||||
Raphael,Bache Madonna,https://www.wga.hu/art/r/raphael/5roma/1/06alba.jpg,
|
||||
Raphael,Baldassare Castiglione,https://www.wga.hu/art/r/raphael/5roma/3/01castig.jpg,82 x 67 cm
|
||||
Raphael,Bindo Altoviti,https://www.wga.hu/art/r/raphael/5roma/2/01altovi.jpg,60 x 44 cm
|
||||
Raphael,Cherubs of the Sistine Madonna,https://www.wga.hu/art/r/raphael/5roma/2/03sisti.jpg,270 x 201 cm
|
||||
Raphael,Coronation of Saint Nicholas from Tolentino,https://www.wga.hu/art/r/raphael/7drawing/1/01study.jpg,409 x 265 mm
|
||||
Raphael,Half-length portrait of a young woman in profile,https://www.wga.hu/art/r/raphael/7drawing/3/09drawim.jpg,254 x 160 mm
|
||||
Raphael,Interior of the Pantheon,https://www.wga.hu/art/r/raphael/7drawing/3/08drawin.jpg,277 x 407 mm
|
||||
Raphael,Justice,https://www.wga.hu/art/r/raphael/4stanze/1segnatu/5/1tondo4.jpg,
|
||||
Raphael,La Madonna della sedia,https://www.wga.hu/art/r/raphael/5roma/2/08tenda.jpg,"65,8 x 51,2 cm"
|
||||
Raphael,Lucca Madonna,https://www.wga.hu/art/r/raphael/5roma/1/06alba.jpg,
|
||||
Raphael,Madonna and Child Madonna della Seggiola,https://www.wga.hu/art/r/raphael/5roma/2/07sedia1.jpg,
|
||||
Raphael,Madonna del Granduca,https://www.wga.hu/art/r/raphael/2firenze/1/22grand.jpg,84 x 56 cm
|
||||
Raphael,Madonna della Loggia,https://www.wga.hu/art/r/raphael/5roma/2/07sedia1.jpg,
|
||||
Raphael,Madonna della sedia,https://www.wga.hu/art/r/raphael/5roma/2/08tenda.jpg,"65,8 x 51,2 cm"
|
||||
Raphael,Madonna della seggiola,https://www.wga.hu/art/r/raphael/5roma/2/07sedia1.jpg,
|
||||
Raphael,Prophet Isaiah,https://www.wga.hu/art/r/raphael/5roma/1/08isaiah.jpg,250 x 155 cm
|
||||
Raphael,Sistine Madonna,https://www.wga.hu/art/r/raphael/5roma/2/03sisti.jpg,270 x 201 cm
|
||||
Raphael,St Michael and the Dragon by Cimabue,https://www.wga.hu/art/r/raphael/2firenze/1/25drago2.jpg,31 x 27 cm
|
||||
Raphael,Study of a Kneeling Nude Girl for The Entombment,https://www.wga.hu/art/r/raphael/7drawing/1/11study.jpg,230 x 319 mm
|
||||
Raphael,The School of Athens,https://www.wga.hu/art/r/raphael/4stanze/1segnatu/1/athens.jpg,
|
||||
Raphael,The Sistine Madonna,https://www.wga.hu/art/r/raphael/5roma/2/03sisti.jpg,270 x 201 cm
|
||||
Raphael,The Virgin and Child with the Young St John Tondo,https://www.wga.hu/art/r/raphael/7drawing/3/35drawin.jpg,193 x 228 mm
|
||||
Raphael,Vision of the Trones,https://www.wga.hu/art/r/raphael/4stanze/4constan/3vision.jpg,
|
||||
Rembrandt,Christ and the Woman Taken in Adultery,https://www.wga.hu/art/r/rembrand/13biblic/32newtes.jpg,84 x 65 cm
|
||||
Rembrandt,Lucretia,https://www.wga.hu/art/r/rembrand/17histor/09histor.jpg,"105 x 92,5 cm"
|
||||
Rembrandt,The Anatomy Lesson of Dr Nicolaes Tulp,https://www.wga.hu/art/r/rembrand/26group/01group.jpg,170 x 217 cm
|
||||
Rembrandt,The Night Watch,https://www.wga.hu/art/r/rembrand/26group/05group.jpg,363 x 437 cm
|
||||
Rembrandt,The Red Trees,https://www.wga.hu/art/r/rembrand/51etchin/4/212.jpg,213 x 279 mm
|
||||
Sandro Botticelli,Birth of Christ,https://www.wga.hu/art/b/botticel/21/4birth.jpg,200 x 300 cm
|
||||
Sandro Botticelli,Fortitude,https://www.wga.hu/art/b/botticel/1early/09fortit.jpg,167 x 87 cm
|
||||
Sandro Botticelli,Madonna and Child with an Angel,https://www.wga.hu/art/b/botticel/1early/01madonn.jpg,87 x 60 cm
|
||||
Sandro Botticelli,Madonna in Glory with Seraphim,https://www.wga.hu/art/b/botticel/1early/06glory.jpg,120 x 65 cm
|
||||
Sandro Botticelli,Madonna of the Rose,https://www.wga.hu/art/b/botticel/21/51madonn.jpg,40 x 28 cm
|
||||
Sandro Botticelli,Madonna of the Rose Garden,https://www.wga.hu/art/b/botticel/1early/07rosega.jpg,124 x 65 cm
|
||||
Sandro Botticelli,Portrait of Dante,https://www.wga.hu/art/b/botticel/7portrai/14dante.jpg,"54,7 x 47,5 cm"
|
||||
Sandro Botticelli,Portrait of a Man with a Medal of Cosimo the Elder,https://www.wga.hu/art/b/botticel/7portrai/04medal.jpg,"57,5 x 44 cm"
|
||||
Sandro Botticelli,Portrait of a Young Man possibly Giuliano de Medici,https://www.wga.hu/art/b/botticel/7portrai/06medici.jpg,76 x 53 cm
|
||||
Sandro Botticelli,Predella of the Annunciation,https://www.wga.hu/art/b/botticel/22/70cestel.jpg,150 x 156 cm
|
||||
Sandro Botticelli,Purgatory,https://www.wga.hu/art/b/botticel/93dante/20purgat.jpg,320 x 470 mm
|
||||
Sandro Botticelli,St John on Patmos,https://www.wga.hu/art/b/botticel/8smarco/21predel.jpg,21 x 269 cm
|
||||
Sandro Botticelli,The Birth of Venus,https://www.wga.hu/art/b/botticel/5allegor/29birth.jpg,173 x 279 cm
|
||||
Sandro Botticelli,The Discovery of the Body of Holofernes,https://www.wga.hu/art/b/botticel/1early/13holofe.jpg,31 x 25 cm
|
||||
Sandro Botticelli,The Return of Judith to Bethulia,https://www.wga.hu/art/b/botticel/1early/12judith.jpg,31 x 24 cm
|
||||
Sandro Botticelli,Three Scenes from the Story of Esther,https://www.wga.hu/art/b/botticel/1early/14esther.jpg,48 x 132 cm
|
||||
Simone Martini,Annunciation,https://www.wga.hu/art/s/simone/6annunci/a_angel.jpg,31 x 22cm
|
||||
Simone Martini,Annunciation to Mary,https://www.wga.hu/art/s/simone/6annunci/ann_2st.jpg,184 x 210 cm
|
||||
Simone Martini,Annunciation to Saint Anne,https://www.wga.hu/art/s/simone/6annunci/ann_2st.jpg,184 x 210 cm
|
||||
Simone Martini,Blessing Christ,https://www.wga.hu/art/s/simone/4altars/1louis/5naples.jpg,76 x 46 cm
|
||||
Simone Martini,Saint Andrew,https://www.wga.hu/art/s/simone/4altars/6other/4andrew.jpg,57 x 38 cm
|
||||
Simone Martini,Saint Ansanus,https://www.wga.hu/art/s/simone/4altars/6other/1ansanus.jpg,58 x 38 cm
|
||||
Théodore Géricault,The Raft of the Medusa,https://www.wga.hu/art/g/gericaul/1/105geric.jpg,491 x 716 cm
|
||||
Titian,Christ the Redeemer,https://www.wga.hu/art/t/tiziano/03_1530s/4redeeme.jpg,77 x 57 cm
|
||||
Titian,Orpheus and Eurydice,https://www.wga.hu/art/t/tiziano/08/03orpheu.jpg,39 x 53 cm
|
||||
Titian,Portrait of Isabella of Portugal,https://www.wga.hu/art/t/tiziano/10/1/13isabel.jpg,117 x 93 cm
|
||||
Titian,The Birth of Adonis,https://www.wga.hu/art/t/tiziano/08/01birth.jpg,35 x 162 cm
|
||||
Titian,The Christ Child as the Redeemer,https://www.wga.hu/art/t/tiziano/03_1530s/4redeeme.jpg,77 x 57 cm
|
||||
Titian,The Legend of Polydoros,https://www.wga.hu/art/t/tiziano/08/02polydo.jpg,35 x 162 cm
|
||||
Titian,The Venus of Urbino,https://www.wga.hu/art/t/tiziano/08/08urbin.jpg,119 x 165 cm
|
||||
Titian,Venus Anadyomene,https://www.wga.hu/art/t/tiziano/08/07anadyo.jpg,76 x 57 cm
|
||||
Titian,Venus of Urbino,https://www.wga.hu/art/t/tiziano/08/08urbin.jpg,119 x 165 cm
|
||||
Vincent van Gogh,Bal du moulin de la Galette,https://www.wga.hu/art/g/gogh_van/06/paris05.jpg,46 x 38 cm
|
||||
Vincent van Gogh,Haystacks,https://www.wga.hu/art/g/gogh_van/18/2arles08.jpg,241 x 319 mm
|
||||
Vincent van Gogh,Saint Paul,https://www.wga.hu/art/g/gogh_van/11/1asylu01.jpg,95 x 76 cm
|
||||
Vincent van Gogh,Self-Portrait with Bandaged Ear,https://www.wga.hu/art/g/gogh_van/16/selfpo31.jpg,60 x 49 cm
|
||||
Vincent van Gogh,Sunflowers,https://www.wga.hu/art/g/gogh_van/09/arles63.jpg,92 x 71 cm
|
||||
Vincent van Gogh,Two Children Are Threatened by a Nightingale,https://www.wga.hu/art/g/gogh_van/15/auvers25.jpg,52 x 46 cm
|
||||
Vincent van Gogh,Wheatfield with Crows,https://www.wga.hu/art/g/gogh_van/15/auvers33.jpg,"50,5 x 103 cm"
|
||||
Édouard Manet,A Bar at the Folies-Berg re,https://www.wga.hu/art/m/manet/5/5late09.jpg,96 x 130 cm
|
||||
Édouard Manet,Luncheon on the Grass,https://www.wga.hu/art/m/manet/1/5dejeun1.jpg,208 x 265 cm
|
||||
Édouard Manet,Olympia,https://www.wga.hu/art/m/manet/1/4olympi1.jpg,131 x 190 cm
|
||||
Édouard Manet,The Balcony,https://www.wga.hu/art/m/manet/2/2manet18.jpg,170 x 125 cm
|
||||
|
Binary file not shown.
@@ -1,12 +1,14 @@
|
||||
# Art Gallery
|
||||
|
||||
Interactive virtual art gallery: zoomable historical timeline with era click-to-zoom, major event markers (vertical guides into the movement flow), branching art-movement streams (click a movement name to enter its **3D movement gallery** — photorealistic period interiors with painted walls, stone, and wood textures; chronological wings with up to ~55 works each, side-wall windows, wing navigator), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with art-history annotations, prev/next catalog browsing and fullscreen lightbox, **curator-gated** debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**; painting detail also **Remove entry**), optional **Show more** auto-opens the search picker, Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies. Anonymous visitors browse freely; curators sign in via **Curator login** in the header.
|
||||
Interactive virtual art gallery: zoomable historical timeline (classic left→right, vertical, or **Tree of Art** lineage chart — shareable via `?layout=`) with era click-to-zoom, major event markers (vertical guides into the movement flow), branching art-movement streams (click a movement name to enter its **3D movement gallery** — photorealistic period interiors with painted walls, stone, and wood textures; chronological wings with up to ~55 works each, side-wall windows, wing navigator), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with art-history annotations, prev/next catalog browsing and fullscreen lightbox, **curator-gated** debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**; painting detail also **Remove entry**), optional **Show more** auto-opens the search picker, Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies. Anonymous visitors browse freely; curators sign in via **Curator login** in the header.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| [Documentation/basics.md](Documentation/basics.md) | Architecture, layout, user flow |
|
||||
| [Documentation/ui-interaction-and-component-standards.md](Documentation/ui-interaction-and-component-standards.md) | UI interaction, navigation, and component standards |
|
||||
| [Documentation/movement-tree.md](Documentation/movement-tree.md) | **Tree of Art** start page — tree layout rules |
|
||||
| [Documentation/FAC.md](Documentation/FAC.md) | **Command cheat sheet** — start/stop, import, deploy |
|
||||
| [Documentation/environments.md](Documentation/environments.md) | Dev/prod URLs, DB split, deploy, sync |
|
||||
| [Documentation/setup.md](Documentation/setup.md) | Install, env, npm scripts |
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>client</title>
|
||||
<title>Virtual Art Gallery</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+76
-1
@@ -10,8 +10,10 @@
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.6.1",
|
||||
"i18next": "^26.3.6",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.9",
|
||||
"react-router-dom": "^7.18.0",
|
||||
"three": "^0.184.0"
|
||||
},
|
||||
@@ -2077,6 +2079,43 @@
|
||||
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.6",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
||||
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com/i18next"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@@ -2815,6 +2854,33 @@
|
||||
"react": "^19.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.9",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.9.tgz",
|
||||
"integrity": "sha512-buLzOSqHtXxjf+qgSrLWNTXVZ1jSwO6kUv3uJqSP1roGBPgNnbhFm7OmdVwWcgf2gIbUyP0J333uPyx+Btsi3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz",
|
||||
@@ -3159,7 +3225,7 @@
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -3337,6 +3403,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webgl-constants": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz",
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.6.1",
|
||||
"i18next": "^26.3.6",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.9",
|
||||
"react-router-dom": "^7.18.0",
|
||||
"three": "^0.184.0"
|
||||
},
|
||||
|
||||
+784
-40
@@ -5,19 +5,147 @@ import type {
|
||||
Artist,
|
||||
ArtistDetail,
|
||||
MovementGalleryDetail,
|
||||
ArtistSummary,
|
||||
Painting,
|
||||
PaintingDetail,
|
||||
ArtistNavigation,
|
||||
CatalogSearchResponse,
|
||||
TourSummary,
|
||||
TourGalleryDetail,
|
||||
} from '../types';
|
||||
import { readStoredLocale, type AppLocale } from '../utils/localeStorage';
|
||||
|
||||
const API = '/api';
|
||||
|
||||
let apiLocale: AppLocale = readStoredLocale();
|
||||
|
||||
export function setApiLocale(locale: AppLocale) {
|
||||
apiLocale = locale;
|
||||
}
|
||||
|
||||
export function getApiLocale(): AppLocale {
|
||||
return apiLocale;
|
||||
}
|
||||
|
||||
function localeParams(base?: URLSearchParams): URLSearchParams {
|
||||
const params = base ?? new URLSearchParams();
|
||||
if (apiLocale !== 'en') params.set('locale', apiLocale);
|
||||
return params;
|
||||
}
|
||||
|
||||
function localizedPath(path: string, params?: URLSearchParams): string {
|
||||
const qs = localeParams(params).toString();
|
||||
return qs ? `${path}?${qs}` : path;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface AuditLogEntry {
|
||||
id: number;
|
||||
created_at: string;
|
||||
user_id: number;
|
||||
username: string;
|
||||
user_role: 'admin' | 'curator';
|
||||
action: string;
|
||||
resource_type: string;
|
||||
resource_id: number | null;
|
||||
resource_label: string | null;
|
||||
details: Record<string, unknown> | null;
|
||||
ip_address: string | null;
|
||||
}
|
||||
|
||||
export interface AuditLogList {
|
||||
database: string | null;
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
entries: AuditLogEntry[];
|
||||
}
|
||||
|
||||
export interface AuditSummary {
|
||||
database: string | null;
|
||||
total: number;
|
||||
last_24h: number;
|
||||
last_7d: number;
|
||||
oldest: string | null;
|
||||
newest: string | null;
|
||||
by_user: Array<{ user_id: number; username: string; role: string; count: number }>;
|
||||
by_action: Array<{ action: string; count: number }>;
|
||||
by_resource_type: Array<{ resource_type: string; count: number }>;
|
||||
}
|
||||
|
||||
export interface AuditMeta {
|
||||
database: string | null;
|
||||
users: Array<{ id: number; username: string; role: string }>;
|
||||
actions: string[];
|
||||
resource_types: string[];
|
||||
}
|
||||
|
||||
export type AuditQuery = {
|
||||
user_id?: number;
|
||||
username?: string;
|
||||
action?: string;
|
||||
resource_type?: string;
|
||||
resource_id?: number;
|
||||
from?: string;
|
||||
to?: string;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
function auditQueryString(params?: AuditQuery): string {
|
||||
if (!params) return '';
|
||||
const qs = new URLSearchParams();
|
||||
if (params.user_id != null) qs.set('user_id', String(params.user_id));
|
||||
if (params.username) qs.set('username', params.username);
|
||||
if (params.action) qs.set('action', params.action);
|
||||
if (params.resource_type) qs.set('resource_type', params.resource_type);
|
||||
if (params.resource_id != null) qs.set('resource_id', String(params.resource_id));
|
||||
if (params.from) qs.set('from', params.from);
|
||||
if (params.to) qs.set('to', params.to);
|
||||
if (params.q) qs.set('q', params.q);
|
||||
if (params.limit != null) qs.set('limit', String(params.limit));
|
||||
if (params.offset != null) qs.set('offset', String(params.offset));
|
||||
const s = qs.toString();
|
||||
return s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
@@ -38,6 +166,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}`);
|
||||
}
|
||||
@@ -52,65 +188,128 @@ export async function logoutCurator(): Promise<void> {
|
||||
if (!res.ok) throw new Error(`Logout failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export function imageUrl(path: string | null | undefined): string {
|
||||
if (!path) return '/placeholder-art.svg';
|
||||
return `/images/${path}`;
|
||||
}
|
||||
|
||||
export function portraitUrl(path: string | null | undefined, revision?: number): string {
|
||||
const base = imageUrl(path);
|
||||
export function imageUrl(path: string | null | undefined, revision?: number | null): string {
|
||||
const base = !path ? '/placeholder-art.svg' : `/images/${path}`;
|
||||
if (!revision || base.startsWith('/placeholder')) return base;
|
||||
return `${base}${base.includes('?') ? '&' : '?'}v=${revision}`;
|
||||
}
|
||||
|
||||
/** Small portrait for timeline / movement flow (~256px). Falls back to full portrait. */
|
||||
export function portraitUrl(
|
||||
path: string | null | undefined,
|
||||
revision?: number | null,
|
||||
options?: { portrait_cache_key?: number | null; portrait_thumb_cache_key?: number | null }
|
||||
): string {
|
||||
const cacheRevision =
|
||||
revision ?? options?.portrait_cache_key ?? options?.portrait_thumb_cache_key ?? undefined;
|
||||
return imageUrl(path, cacheRevision);
|
||||
}
|
||||
|
||||
export function portraitThumbUrl(
|
||||
artist: {
|
||||
portrait_thumb_path?: string | null;
|
||||
portrait_path?: string | null;
|
||||
portrait_cache_key?: number | null;
|
||||
portrait_thumb_cache_key?: number | null;
|
||||
},
|
||||
revision?: number
|
||||
revision?: number | null
|
||||
): string {
|
||||
const path = artist.portrait_thumb_path || artist.portrait_path;
|
||||
return portraitUrl(path, revision);
|
||||
const cacheRevision =
|
||||
revision ?? artist.portrait_thumb_cache_key ?? artist.portrait_cache_key ?? undefined;
|
||||
return portraitUrl(path, cacheRevision);
|
||||
}
|
||||
|
||||
export function paintingImageRevision(
|
||||
painting: {
|
||||
image_cache_key?: number | null;
|
||||
thumbnail_cache_key?: number | null;
|
||||
},
|
||||
sessionRevision?: number | null
|
||||
): number | undefined {
|
||||
const apiRevision = painting.image_cache_key ?? painting.thumbnail_cache_key;
|
||||
if (apiRevision != null) return apiRevision;
|
||||
if (sessionRevision != null) return sessionRevision;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Image for 3D gallery — prefer thumbnail for faster texture loads */
|
||||
export function galleryImageUrl(painting: {
|
||||
thumbnail_path?: string | null;
|
||||
image_path?: string | null;
|
||||
}): string | null {
|
||||
if (painting.thumbnail_path) return `/images/${painting.thumbnail_path}`;
|
||||
if (painting.image_path) return `/images/${painting.image_path}`;
|
||||
export function galleryImageUrl(
|
||||
painting: {
|
||||
thumbnail_path?: string | null;
|
||||
image_path?: string | null;
|
||||
image_cache_key?: number | null;
|
||||
thumbnail_cache_key?: number | null;
|
||||
},
|
||||
sessionRevision?: number | null
|
||||
): string | null {
|
||||
const revision = paintingImageRevision(painting, sessionRevision);
|
||||
if (painting.thumbnail_path) return imageUrl(painting.thumbnail_path, revision);
|
||||
if (painting.image_path) return imageUrl(painting.image_path, revision);
|
||||
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;
|
||||
thumbnail_path?: string | null;
|
||||
image_path?: string | null;
|
||||
image_cache_key?: number | null;
|
||||
thumbnail_cache_key?: number | null;
|
||||
},
|
||||
revision?: number
|
||||
sessionRevision?: number | null
|
||||
): string | null {
|
||||
const base = galleryImageUrl(painting);
|
||||
if (!base || !revision) return base;
|
||||
return `${base}${base.includes('?') ? '&' : '?'}v=${revision}`;
|
||||
return galleryImageUrl(painting, sessionRevision);
|
||||
}
|
||||
|
||||
export function paintingImageUrl(painting: {
|
||||
id: number;
|
||||
image_path?: string | null;
|
||||
thumbnail_path?: string | null;
|
||||
checkup_fixed?: boolean;
|
||||
}): string | null {
|
||||
if (painting.image_path) return `/images/${painting.image_path}`;
|
||||
if (painting.thumbnail_path) return `/images/${painting.thumbnail_path}`;
|
||||
export function paintingImageUrl(
|
||||
painting: {
|
||||
id: number;
|
||||
image_path?: string | null;
|
||||
thumbnail_path?: string | null;
|
||||
checkup_fixed?: boolean;
|
||||
image_cache_key?: number | null;
|
||||
thumbnail_cache_key?: number | null;
|
||||
},
|
||||
sessionRevision?: number | null
|
||||
): string | null {
|
||||
const revision = paintingImageRevision(painting, sessionRevision);
|
||||
if (painting.image_path) return imageUrl(painting.image_path, revision);
|
||||
if (painting.thumbnail_path) return imageUrl(painting.thumbnail_path, revision);
|
||||
if (painting.checkup_fixed) return null;
|
||||
return `/api/paintings/${painting.id}/image?size=full`;
|
||||
}
|
||||
|
||||
async function fileToBase64Payload(file: File): Promise<{ imageData: string; mimeType: string }> {
|
||||
validateDebugUploadFile(file);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
@@ -122,7 +321,7 @@ async function fileToBase64Payload(file: File): Promise<{ imageData: string; mim
|
||||
const comma = result.indexOf(',');
|
||||
resolve({
|
||||
imageData: comma >= 0 ? result.slice(comma + 1) : result,
|
||||
mimeType: file.type || 'image/jpeg',
|
||||
mimeType: file.type || mimeTypeFromFilename(file.name) || 'image/jpeg',
|
||||
});
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Could not read file'));
|
||||
@@ -130,6 +329,56 @@ async function fileToBase64Payload(file: File): Promise<{ imageData: string; mim
|
||||
});
|
||||
}
|
||||
|
||||
async function fileToBase64Raw(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result;
|
||||
if (typeof result !== 'string') {
|
||||
reject(new Error('Could not read file'));
|
||||
return;
|
||||
}
|
||||
const comma = result.indexOf(',');
|
||||
resolve(comma >= 0 ? result.slice(comma + 1) : result);
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Could not read file'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function mimeTypeFromFilename(filename: string): string | null {
|
||||
const ext = filename.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1];
|
||||
switch (ext) {
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
return 'image/jpeg';
|
||||
case 'png':
|
||||
return 'image/png';
|
||||
case 'webp':
|
||||
return 'image/webp';
|
||||
case 'gif':
|
||||
return 'image/gif';
|
||||
case 'avif':
|
||||
return 'image/avif';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateDebugUploadFile(file: File): void {
|
||||
const maxBytes = 15 * 1024 * 1024;
|
||||
if (file.size <= 0) {
|
||||
throw new Error('Selected file is empty.');
|
||||
}
|
||||
if (file.size > maxBytes) {
|
||||
throw new Error('Image too large (max 15 MB).');
|
||||
}
|
||||
const nameOk = /\.(jpe?g|png|webp|gif|avif)$/i.test(file.name);
|
||||
if (!file.type.startsWith('image/') && !nameOk) {
|
||||
throw new Error('Please choose an image file (JPEG, PNG, WebP, GIF).');
|
||||
}
|
||||
}
|
||||
|
||||
async function postJsonImageAction<T>(url: string, payload: { imageData: string; mimeType: string }): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
...fetchCredentials,
|
||||
@@ -153,15 +402,29 @@ 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;
|
||||
image_cache_key?: number | null;
|
||||
thumbnail_cache_key?: number | null;
|
||||
fixed?: boolean;
|
||||
checked?: boolean;
|
||||
}
|
||||
|
||||
export interface FixArtistPortraitResult {
|
||||
portraitPath: string | null;
|
||||
portraitThumbPath?: string | null;
|
||||
portrait_cache_key?: number | null;
|
||||
portrait_thumb_cache_key?: number | null;
|
||||
fixed?: boolean;
|
||||
checked?: boolean;
|
||||
}
|
||||
@@ -213,38 +476,103 @@ export interface PaintingCheckupData {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listUsers: () => fetchJson<{ users: StaffUser[]; permissions: StaffPermission[] }>(`${API}/users`),
|
||||
|
||||
listAuditLog: (params?: AuditQuery) =>
|
||||
fetchJson<AuditLogList>(`${API}/audit${auditQueryString(params)}`),
|
||||
|
||||
getAuditSummary: (params?: AuditQuery) =>
|
||||
fetchJson<AuditSummary>(`${API}/audit/summary${auditQueryString(params)}`),
|
||||
|
||||
getAuditMeta: () => fetchJson<AuditMeta>(`${API}/audit/meta`),
|
||||
|
||||
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) => {
|
||||
const params = new URLSearchParams();
|
||||
if (start != null) params.set('start', String(start));
|
||||
if (end != null) params.set('end', String(end));
|
||||
const qs = params.toString();
|
||||
return fetchJson<CatalogBootstrap>(`${API}/catalog/bootstrap${qs ? `?${qs}` : ''}`);
|
||||
return fetchJson<CatalogBootstrap>(localizedPath(`${API}/catalog/bootstrap`, params));
|
||||
},
|
||||
|
||||
getTimeline: (start: number, end: number) =>
|
||||
fetchJson<TimelineData>(`${API}/timeline?start=${start}&end=${end}`),
|
||||
getTimeline: (start: number, end: number) => {
|
||||
const params = new URLSearchParams({ start: String(start), end: String(end) });
|
||||
return fetchJson<TimelineData>(localizedPath(`${API}/timeline`, params));
|
||||
},
|
||||
|
||||
search: (q: string, options?: { limit?: number; types?: string }) => {
|
||||
const params = new URLSearchParams({ q });
|
||||
if (options?.limit != null) params.set('limit', String(options.limit));
|
||||
if (options?.types) params.set('types', options.types);
|
||||
return fetchJson<CatalogSearchResponse>(localizedPath(`${API}/search`, params));
|
||||
},
|
||||
|
||||
getArtists: (start?: number, end?: number, movementId?: number) => {
|
||||
const params = new URLSearchParams();
|
||||
if (start != null) params.set('start', String(start));
|
||||
if (end != null) params.set('end', String(end));
|
||||
if (movementId != null) params.set('movement_id', String(movementId));
|
||||
return fetchJson<Artist[]>(`${API}/artists?${params}`);
|
||||
return fetchJson<Artist[]>(localizedPath(`${API}/artists`, params));
|
||||
},
|
||||
|
||||
/** Lightweight artist rows for the timeline (no biography text). */
|
||||
getTimelineArtists: () => fetchJson<Artist[]>(`${API}/artists?timeline=1`),
|
||||
getTimelineArtists: () => fetchJson<Artist[]>(localizedPath(`${API}/artists`, new URLSearchParams({ timeline: '1' }))),
|
||||
|
||||
getArtist: (id: number) => fetchJson<ArtistDetail>(`${API}/artists/${id}`),
|
||||
getArtist: (id: number) => fetchJson<ArtistDetail>(localizedPath(`${API}/artists/${id}`)),
|
||||
|
||||
getMovementGallery: (id: number) => fetchJson<MovementGalleryDetail>(`${API}/movements/${id}/gallery`),
|
||||
getMovementGallery: (id: number) => fetchJson<MovementGalleryDetail>(localizedPath(`${API}/movements/${id}/gallery`)),
|
||||
|
||||
getMovementArtistsSummary: (id: number) =>
|
||||
fetchJson<ArtistSummary[]>(localizedPath(`${API}/movements/${id}/artists-summary`)),
|
||||
|
||||
getArtistNavigation: (id: number) =>
|
||||
fetchJson<ArtistNavigation>(`${API}/artists/${id}/navigation`),
|
||||
fetchJson<ArtistNavigation>(localizedPath(`${API}/artists/${id}/navigation`)),
|
||||
|
||||
getPainting: (id: number) => fetchJson<PaintingDetail>(`${API}/paintings/${id}`),
|
||||
getPainting: (id: number) => fetchJson<PaintingDetail>(localizedPath(`${API}/paintings/${id}`)),
|
||||
|
||||
getPaintingDebugImageSearch: (id: number) =>
|
||||
fetchJson<DebugImageSearchResult>(`${API}/paintings/${id}/debug-image-search`),
|
||||
@@ -312,6 +640,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`),
|
||||
|
||||
@@ -367,9 +709,411 @@ export const api = {
|
||||
return res.json() as Promise<{ checked: boolean; fixed: boolean }>;
|
||||
}),
|
||||
|
||||
getTranslationCoverage: (locale = 'ru') =>
|
||||
fetchJson<{ locale: string; coverage: Record<string, number> }>(
|
||||
`${API}/translations/coverage?locale=${encodeURIComponent(locale)}`
|
||||
),
|
||||
|
||||
getTranslationWorklist: (entityType: string, locale = 'ru') =>
|
||||
fetchJson<{ items: TranslationWorklistItem[] }>(
|
||||
`${API}/translations/worklist/${encodeURIComponent(entityType)}?locale=${encodeURIComponent(locale)}`
|
||||
),
|
||||
|
||||
getEntityTranslation: (entityType: string, id: number) =>
|
||||
fetchJson<TranslationDetail>(`${API}/translations/${encodeURIComponent(entityType)}/${id}`),
|
||||
|
||||
saveEntityTranslation: (
|
||||
entityType: string,
|
||||
id: number,
|
||||
payload: { locale: string; fields: Record<string, string>; status?: string }
|
||||
) =>
|
||||
fetch(`${API}/translations/${encodeURIComponent(entityType)}/${id}`, {
|
||||
...fetchCredentials,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Save failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}),
|
||||
|
||||
publishEntityTranslation: (entityType: string, id: number, locale = 'ru') =>
|
||||
fetch(`${API}/translations/${encodeURIComponent(entityType)}/${id}/publish`, {
|
||||
...fetchCredentials,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ locale }),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Publish failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}),
|
||||
|
||||
listInfluences: (params: {
|
||||
artistId?: number;
|
||||
paintingId?: number;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.artistId) qs.set('artistId', String(params.artistId));
|
||||
if (params.paintingId) qs.set('paintingId', String(params.paintingId));
|
||||
if (params.q) qs.set('q', params.q);
|
||||
if (params.limit) qs.set('limit', String(params.limit));
|
||||
if (params.offset) qs.set('offset', String(params.offset));
|
||||
const q = qs.toString();
|
||||
return fetchJson<{ items: InfluenceEdgeItem[]; total: number; limit: number; offset: number }>(
|
||||
`${API}/influences${q ? `?${q}` : ''}`,
|
||||
);
|
||||
},
|
||||
|
||||
getInfluenceGraph: (params: { artistId?: number; paintingId?: number }) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.artistId) qs.set('artistId', String(params.artistId));
|
||||
if (params.paintingId) qs.set('paintingId', String(params.paintingId));
|
||||
return fetchJson<InfluenceGraph>(`${API}/influences/graph?${qs.toString()}`);
|
||||
},
|
||||
|
||||
createInfluence: (payload: {
|
||||
paintingId: number;
|
||||
sourceType: 'painting' | 'artist' | 'movement';
|
||||
sourcePaintingId?: number;
|
||||
sourceArtistId?: number;
|
||||
sourceMovementId?: number;
|
||||
notes?: string;
|
||||
source?: string;
|
||||
sourceUrl?: string;
|
||||
}) =>
|
||||
fetch(`${API}/influences`, {
|
||||
...fetchCredentials,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Create failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<{ id: number }>;
|
||||
}),
|
||||
|
||||
updateInfluence: (
|
||||
id: number,
|
||||
payload: Partial<{
|
||||
notes: string;
|
||||
source: string;
|
||||
sourceUrl: string;
|
||||
confidence: string;
|
||||
sourceType: 'painting' | 'artist' | 'movement';
|
||||
sourcePaintingId: number;
|
||||
sourceArtistId: number;
|
||||
sourceMovementId: number;
|
||||
}>,
|
||||
) =>
|
||||
fetch(`${API}/influences/${id}`, {
|
||||
...fetchCredentials,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).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<{ id: number }>;
|
||||
}),
|
||||
|
||||
deleteInfluence: (id: number) =>
|
||||
fetch(`${API}/influences/${id}`, {
|
||||
...fetchCredentials,
|
||||
method: 'DELETE',
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Delete failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<{ ok: boolean }>;
|
||||
}),
|
||||
|
||||
parseInfluenceImport: async (file: File, sheet?: string) => {
|
||||
const contentBase64 = await fileToBase64Raw(file);
|
||||
return fetch(`${API}/influences/import/parse`, {
|
||||
...fetchCredentials,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
filename: file.name,
|
||||
sheet,
|
||||
contentBase64,
|
||||
}),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Parse failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<InfluenceImportParseResult>;
|
||||
});
|
||||
},
|
||||
|
||||
previewInfluenceImport: (payload: {
|
||||
rows: Record<string, string>[];
|
||||
mapping: Record<string, string>;
|
||||
sourceLabel?: string;
|
||||
contentHash?: string;
|
||||
payloadHash?: string;
|
||||
}) =>
|
||||
fetch(`${API}/influences/import/preview`, {
|
||||
...fetchCredentials,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Preview failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<InfluenceImportPreview>;
|
||||
}),
|
||||
|
||||
commitInfluenceImport: (payload: {
|
||||
proposals: InfluenceImportProposal[];
|
||||
fileName?: string;
|
||||
contentHash?: string;
|
||||
payloadHash?: string;
|
||||
force?: boolean;
|
||||
}) =>
|
||||
fetch(`${API}/influences/import/commit`, {
|
||||
...fetchCredentials,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
const err = new Error(body.error || `Commit failed: ${res.status}`) as Error & {
|
||||
code?: string;
|
||||
priorImport?: InfluencePriorImport;
|
||||
};
|
||||
err.code = body.code;
|
||||
err.priorImport = body.priorImport;
|
||||
throw err;
|
||||
}
|
||||
return res.json() as Promise<{ inserted: number; skipped: number; attempted: number }>;
|
||||
}),
|
||||
|
||||
listPublishedTours: () =>
|
||||
fetchJson<{ tours: TourSummary[] }>(`${API}/tours`),
|
||||
|
||||
listAdminTours: () =>
|
||||
fetchJson<{ tours: TourSummary[] }>(`${API}/tours/admin`),
|
||||
|
||||
getTour: (id: number) =>
|
||||
fetchJson<TourGalleryDetail & { locale?: string }>(`${API}/tours/${id}`),
|
||||
|
||||
createTour: (payload: { title: string; description?: string; status?: 'draft' | 'published' }) =>
|
||||
fetch(`${API}/tours`, {
|
||||
...fetchCredentials,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Create failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<{ tour: TourSummary }>;
|
||||
}),
|
||||
|
||||
updateTour: (
|
||||
id: number,
|
||||
payload: Partial<{
|
||||
title: string;
|
||||
description: string;
|
||||
status: 'draft' | 'published';
|
||||
coverPaintingId: number | null;
|
||||
}>,
|
||||
) =>
|
||||
fetch(`${API}/tours/${id}`, {
|
||||
...fetchCredentials,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).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<{ tour: TourSummary }>;
|
||||
}),
|
||||
|
||||
deleteTour: (id: number) =>
|
||||
fetch(`${API}/tours/${id}`, {
|
||||
...fetchCredentials,
|
||||
method: 'DELETE',
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Delete failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<{ ok: boolean }>;
|
||||
}),
|
||||
|
||||
saveTourStops: (id: number, stops: Array<{ paintingId: number; body: string }>) =>
|
||||
fetch(`${API}/tours/${id}/stops`, {
|
||||
...fetchCredentials,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ stops }),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Save stops failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<{
|
||||
ok: boolean;
|
||||
stopCount: number;
|
||||
paintings: Painting[];
|
||||
stopBodies: Record<number, string>;
|
||||
}>;
|
||||
}),
|
||||
|
||||
preloadArtistImages,
|
||||
preloadMovementImages,
|
||||
};
|
||||
|
||||
export interface TranslationWorklistItem {
|
||||
entityId: number;
|
||||
label: string;
|
||||
publishedCount: number;
|
||||
draftCount: number;
|
||||
missingFields: string[];
|
||||
}
|
||||
|
||||
export interface TranslationDetail {
|
||||
entityType: string;
|
||||
entityId: number;
|
||||
canonical: Record<string, unknown>;
|
||||
translatableFields: string[];
|
||||
translations: Array<{
|
||||
locale: string;
|
||||
field_name: string;
|
||||
value: string;
|
||||
status: string;
|
||||
source: string | null;
|
||||
updated_at: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface InfluenceEdgeItem {
|
||||
id: number;
|
||||
paintingId: number;
|
||||
paintingTitle: string;
|
||||
paintingYear: number | null;
|
||||
artistId: number;
|
||||
artistName: string;
|
||||
sourceType: 'painting' | 'artist' | 'movement';
|
||||
sourcePaintingId: number | null;
|
||||
sourceArtistId: number | null;
|
||||
sourceMovementId: number | null;
|
||||
sourceLabel: string | null;
|
||||
notes: string | null;
|
||||
source: string | null;
|
||||
sourceUrl: string | null;
|
||||
aspects: string | null;
|
||||
quote: string | null;
|
||||
confidence: string | null;
|
||||
discoveredVia: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface InfluenceGraph {
|
||||
focus: { artistId: number; paintingId: number | null; label: string };
|
||||
nodes: Array<{ id: string; type: string; label: string; focus?: boolean; artistId?: number; paintingId?: number; movementId?: number }>;
|
||||
edges: Array<{ id: number; from: string; to: string; direction: string; label: string }>;
|
||||
}
|
||||
|
||||
export interface InfluencePriorImport {
|
||||
importedAt: string;
|
||||
username: string | null;
|
||||
fileName: string | null;
|
||||
inserted: number | null;
|
||||
contentHash: string | null;
|
||||
payloadHash: string | null;
|
||||
match: 'file' | 'data' | 'unknown';
|
||||
}
|
||||
|
||||
export type { TourSummary, TourGalleryDetail };
|
||||
|
||||
export interface InfluenceImportParseResult {
|
||||
filename: string;
|
||||
format: string;
|
||||
sheets: string[] | null;
|
||||
sheet: string | null;
|
||||
columns: string[];
|
||||
rowCount: number;
|
||||
sampleRows: Record<string, string>[];
|
||||
rows?: Record<string, string>[];
|
||||
suggestedPreset: string;
|
||||
suggestedMapping: Record<string, string>;
|
||||
roles: string[];
|
||||
presets: Array<{ id: string; label: string; mapping: Record<string, string> }>;
|
||||
contentHash?: string;
|
||||
payloadHash?: string;
|
||||
alreadyImported?: boolean;
|
||||
priorImport?: InfluencePriorImport | null;
|
||||
}
|
||||
|
||||
export interface InfluenceImportProposal {
|
||||
rowIndex: number;
|
||||
direction: string;
|
||||
paintingId: number;
|
||||
paintingTitle: string;
|
||||
artistId: number;
|
||||
artistName: string;
|
||||
sourceType: string;
|
||||
sourcePaintingId: number | null;
|
||||
sourceArtistId: number | null;
|
||||
sourceMovementId: number | null;
|
||||
sourceLabel: string;
|
||||
token: string;
|
||||
notes: string | null;
|
||||
source: string | null;
|
||||
sourceUrl: string | null;
|
||||
confidence: string;
|
||||
discoveredVia: string;
|
||||
edgeKey: string;
|
||||
action: 'create' | 'skip';
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export interface InfluenceImportPreview {
|
||||
proposals: InfluenceImportProposal[];
|
||||
warnings: Array<{
|
||||
rowIndex: number;
|
||||
message: string;
|
||||
token?: string;
|
||||
direction?: string;
|
||||
candidates?: Array<{ sourceType: string; sourcePaintingId?: number; label: string }>;
|
||||
}>;
|
||||
counts: {
|
||||
rows: number;
|
||||
proposals: number;
|
||||
willCreate: number;
|
||||
willSkip: number;
|
||||
errors: number;
|
||||
};
|
||||
contentHash?: string | null;
|
||||
payloadHash?: string | null;
|
||||
alreadyImported?: boolean;
|
||||
priorImport?: InfluencePriorImport | null;
|
||||
}
|
||||
|
||||
export function debugImageProxyUrl(
|
||||
imageUrl: string,
|
||||
context?: { searchUrl?: string; source?: string }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
.artist-bio {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
|
||||
color: #e8d5b5;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { Artist } from '../types';
|
||||
import {
|
||||
api,
|
||||
@@ -9,13 +9,17 @@ import {
|
||||
type FixArtistPortraitResult,
|
||||
} from '../api/client';
|
||||
import DebugSearchResultsModal from './DebugSearchResultsModal';
|
||||
import DebugUploadButton from './DebugUploadButton';
|
||||
import GalleryLoadingMarker from './GalleryLoadingMarker';
|
||||
import '../components/PaintingDetail.css';
|
||||
import '../components/GalleryLoadingMarker.css';
|
||||
import './ArtistBio.css';
|
||||
|
||||
interface Props {
|
||||
artist: Artist & { movement_name?: string };
|
||||
debugMode?: boolean;
|
||||
debugShowMore?: boolean;
|
||||
canCheckup?: boolean;
|
||||
portraitRevision?: number;
|
||||
onBack: () => void;
|
||||
onEnterGallery: () => void;
|
||||
@@ -33,6 +37,7 @@ export default function ArtistBio({
|
||||
artist,
|
||||
debugMode = false,
|
||||
debugShowMore = false,
|
||||
canCheckup = true,
|
||||
portraitRevision = 0,
|
||||
onBack,
|
||||
onEnterGallery,
|
||||
@@ -51,11 +56,13 @@ export default function ArtistBio({
|
||||
const [applyingUrl, setApplyingUrl] = useState<string | null>(null);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploadStatus, setUploadStatus] = useState<string | null>(null);
|
||||
|
||||
const portraitCleared = !artist.portrait_path && !!artist.checkup_fixed;
|
||||
const showPortrait = !!artist.portrait_path || !artist.checkup_fixed;
|
||||
const portraitSrc = portraitUrl(artist.portrait_path, portraitRevision || undefined);
|
||||
const showPortrait = !uploading && (!!artist.portrait_path || !artist.checkup_fixed);
|
||||
const portraitSrc = uploading
|
||||
? null
|
||||
: portraitUrl(artist.portrait_path, portraitRevision || undefined, artist);
|
||||
|
||||
const lifespan =
|
||||
artist.birth_year && artist.death_year
|
||||
@@ -71,6 +78,9 @@ export default function ArtistBio({
|
||||
setMoreOpen(false);
|
||||
return;
|
||||
}
|
||||
if (uploading) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setDebugLoading(true);
|
||||
@@ -91,7 +101,7 @@ export default function ArtistBio({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [debugMode, artist.id, artist.name]);
|
||||
}, [debugMode, uploading, artist.id, artist.name]);
|
||||
|
||||
const applyPortraitUpdate = async (fixResult: FixArtistPortraitResult) => {
|
||||
if (onArtistPortraitFixed) {
|
||||
@@ -197,20 +207,27 @@ export default function ArtistBio({
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
uploadInputRef.current?.click();
|
||||
const handleUploadPress = () => {
|
||||
setDebugSearch(null);
|
||||
setDebugLoading(false);
|
||||
setMoreOpen(false);
|
||||
setMoreResults(null);
|
||||
setMoreError(null);
|
||||
setDebugError(null);
|
||||
};
|
||||
|
||||
const handleUploadFile = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file || uploading || fixing || clearing) return;
|
||||
const handleUploadFile = async (file: File) => {
|
||||
if (uploading || fixing || clearing) return;
|
||||
handleUploadPress();
|
||||
setUploading(true);
|
||||
setDebugError(null);
|
||||
setUploadStatus('Reading file…');
|
||||
try {
|
||||
setUploadStatus('Uploading…');
|
||||
const result = await api.uploadArtistPortrait(artist.id, file);
|
||||
await applyPortraitUpdate(result);
|
||||
setUploadStatus('Upload complete.');
|
||||
} catch (err) {
|
||||
setUploadStatus(null);
|
||||
setDebugError(err instanceof Error ? err.message : 'Could not upload portrait.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
@@ -219,6 +236,13 @@ export default function ArtistBio({
|
||||
|
||||
return (
|
||||
<div className="artist-bio">
|
||||
{uploading && (
|
||||
<GalleryLoadingMarker
|
||||
overlay
|
||||
className="debug-upload-page-overlay"
|
||||
message={uploadStatus ?? 'Loading…'}
|
||||
/>
|
||||
)}
|
||||
<header className="bio-header">
|
||||
<button className="back-btn" onClick={onBack}>← Back</button>
|
||||
<h1>{artist.name}</h1>
|
||||
@@ -227,9 +251,9 @@ export default function ArtistBio({
|
||||
|
||||
<div className="bio-content">
|
||||
<div
|
||||
className={`bio-portrait${artist.checkup_checked ? ' bio-portrait-checked' : ''}${portraitCleared ? ' bio-portrait-empty' : ''}`}
|
||||
className={`bio-portrait${artist.checkup_checked ? ' bio-portrait-checked' : ''}${portraitCleared && !uploading ? ' bio-portrait-empty' : ''}${uploading ? ' bio-portrait-uploading' : ''}`}
|
||||
>
|
||||
{showPortrait ? (
|
||||
{showPortrait && portraitSrc ? (
|
||||
<img
|
||||
src={portraitSrc}
|
||||
alt={artist.name}
|
||||
@@ -273,14 +297,17 @@ export default function ArtistBio({
|
||||
</div>
|
||||
|
||||
{debugMode && (
|
||||
<aside className="debug-image-panel" aria-label="Debug portrait search">
|
||||
<aside
|
||||
className={`debug-image-panel${uploading ? ' debug-image-panel-blocked' : ''}`}
|
||||
aria-label="Debug portrait search"
|
||||
>
|
||||
<h4>{debugSearch?.sourceLabel ?? 'Portrait image search'}</h4>
|
||||
<p className="debug-image-query">
|
||||
{debugSearch?.query ?? `${artist.name} portrait`}
|
||||
</p>
|
||||
{debugLoading && <p className="debug-image-status">Searching…</p>}
|
||||
{debugError && <p className="debug-image-error">{debugError}</p>}
|
||||
{!debugLoading && debugSearch?.imageUrl && (
|
||||
{debugLoading && !uploading && <p className="debug-image-status">Searching…</p>}
|
||||
{debugError && !uploading && <p className="debug-image-error">{debugError}</p>}
|
||||
{!uploading && !debugLoading && debugSearch?.imageUrl && (
|
||||
<img
|
||||
className="debug-image-preview"
|
||||
src={debugImageProxyUrl(debugSearch.imageUrl, {
|
||||
@@ -293,23 +320,25 @@ export default function ArtistBio({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
|
||||
{!uploading && !debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
|
||||
<p className="debug-image-status">No portrait image result found.</p>
|
||||
)}
|
||||
<div className="debug-action-buttons">
|
||||
<button
|
||||
type="button"
|
||||
className="debug-checked-btn"
|
||||
onClick={handleMarkChecked}
|
||||
disabled={!!artist.checkup_checked || markingChecked}
|
||||
>
|
||||
{markingChecked ? '…' : 'Checked'}
|
||||
</button>
|
||||
{canCheckup && (
|
||||
<button
|
||||
type="button"
|
||||
className="debug-checked-btn"
|
||||
onClick={handleMarkChecked}
|
||||
disabled={!!artist.checkup_checked || markingChecked || uploading}
|
||||
>
|
||||
{markingChecked ? '…' : 'Checked'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="debug-fix-btn"
|
||||
onClick={handleFixPortrait}
|
||||
disabled={fixing || debugLoading || !debugSearch?.imageUrl}
|
||||
disabled={fixing || debugLoading || uploading || !debugSearch?.imageUrl}
|
||||
>
|
||||
{fixing ? '…' : 'Fix it'}
|
||||
</button>
|
||||
@@ -317,7 +346,7 @@ export default function ArtistBio({
|
||||
type="button"
|
||||
className="debug-more-btn"
|
||||
onClick={handleOpenMore}
|
||||
disabled={debugLoading || moreLoading}
|
||||
disabled={debugLoading || moreLoading || uploading}
|
||||
>
|
||||
{moreLoading ? '…' : 'More'}
|
||||
</button>
|
||||
@@ -331,27 +360,18 @@ export default function ArtistBio({
|
||||
>
|
||||
{clearing ? '…' : 'Clear'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="debug-upload-btn"
|
||||
onClick={handleUploadClick}
|
||||
disabled={uploading || fixing || clearing}
|
||||
>
|
||||
{uploading ? '…' : 'Upload'}
|
||||
</button>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={handleUploadFile}
|
||||
<DebugUploadButton
|
||||
uploading={uploading}
|
||||
disabled={fixing || clearing}
|
||||
onUploadPress={handleUploadPress}
|
||||
onFileSelected={handleUploadFile}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<DebugSearchResultsModal
|
||||
open={moreOpen}
|
||||
open={moreOpen && !uploading}
|
||||
title="Choose portrait"
|
||||
data={moreResults}
|
||||
loading={moreLoading}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
.catalog-search {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(520px, 100%);
|
||||
margin: 16px auto 0;
|
||||
}
|
||||
|
||||
.catalog-search-label {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.catalog-search-field {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.catalog-search-input {
|
||||
width: 100%;
|
||||
padding: 10px 40px 10px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.45);
|
||||
background: rgba(15, 15, 26, 0.92);
|
||||
color: #e8d5b5;
|
||||
font-size: 15px;
|
||||
font-family: Georgia, serif;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.catalog-search-input::placeholder {
|
||||
color: rgba(232, 213, 181, 0.45);
|
||||
}
|
||||
|
||||
.catalog-search-input:focus {
|
||||
border-color: #c9a96e;
|
||||
box-shadow: 0 0 0 2px rgba(201, 169, 110, 0.18);
|
||||
}
|
||||
|
||||
.catalog-search-spinner {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: -8px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(201, 169, 110, 0.22);
|
||||
border-top-color: #c9a96e;
|
||||
animation: catalog-search-spin 0.85s linear infinite;
|
||||
}
|
||||
|
||||
.catalog-search-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 120;
|
||||
max-height: min(420px, 60vh);
|
||||
overflow: auto;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
background: rgba(12, 12, 22, 0.98);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.catalog-search-message {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
color: rgba(232, 213, 181, 0.75);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.catalog-search-error {
|
||||
color: #e8a0a0;
|
||||
}
|
||||
|
||||
.catalog-search-group + .catalog-search-group {
|
||||
border-top: 1px solid rgba(201, 169, 110, 0.12);
|
||||
}
|
||||
|
||||
.catalog-search-group-label {
|
||||
margin: 0;
|
||||
padding: 10px 14px 6px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(201, 169, 110, 0.65);
|
||||
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||
}
|
||||
|
||||
.catalog-search-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 6px 8px;
|
||||
}
|
||||
|
||||
.catalog-search-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #e8d5b5;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.catalog-search-option:hover,
|
||||
.catalog-search-option-active {
|
||||
background: rgba(201, 169, 110, 0.12);
|
||||
}
|
||||
|
||||
.catalog-search-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.catalog-search-movement-swatch {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.catalog-search-option-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.catalog-search-option-title {
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.catalog-search-option-meta {
|
||||
font-size: 12px;
|
||||
color: rgba(232, 213, 181, 0.6);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@keyframes catalog-search-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { CatalogSearchResult } from '../types';
|
||||
import { api, imageUrl, portraitThumbUrl } from '../api/client';
|
||||
import './CatalogSearchBar.css';
|
||||
|
||||
interface Props {
|
||||
onSelectArtist: (artistId: number) => void;
|
||||
onSelectMovement: (movementId: number) => void;
|
||||
onSelectPainting: (paintingId: number) => void;
|
||||
}
|
||||
|
||||
const TYPE_ORDER: CatalogSearchResult['type'][] = ['artist', 'movement', 'painting'];
|
||||
|
||||
function resultKey(item: CatalogSearchResult): string {
|
||||
return `${item.type}-${item.id}`;
|
||||
}
|
||||
|
||||
function paintingThumbSrc(item: Extract<CatalogSearchResult, { type: 'painting' }>): string {
|
||||
const path = item.thumbnail_path || item.image_path;
|
||||
return path ? imageUrl(path) : '/placeholder-art.svg';
|
||||
}
|
||||
|
||||
export default function CatalogSearchBar({
|
||||
onSelectArtist,
|
||||
onSelectMovement,
|
||||
onSelectPainting,
|
||||
}: Props) {
|
||||
const { t } = useTranslation('search');
|
||||
const typeLabels: Record<CatalogSearchResult['type'], string> = {
|
||||
artist: t('groupArtist'),
|
||||
movement: t('groupMovement'),
|
||||
painting: t('groupPainting'),
|
||||
};
|
||||
const listboxId = useId();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<CatalogSearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<CatalogSearchResult['type'], CatalogSearchResult[]>();
|
||||
for (const type of TYPE_ORDER) map.set(type, []);
|
||||
for (const item of results) {
|
||||
map.get(item.type)?.push(item);
|
||||
}
|
||||
return TYPE_ORDER.map((type) => ({ type, items: map.get(type) ?? [] })).filter((g) => g.items.length > 0);
|
||||
}, [results]);
|
||||
|
||||
const flatResults = useMemo(() => grouped.flatMap((g) => g.items), [grouped]);
|
||||
|
||||
const activate = useCallback(
|
||||
(item: CatalogSearchResult) => {
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setActiveIndex(-1);
|
||||
inputRef.current?.blur();
|
||||
if (item.type === 'artist') onSelectArtist(item.id);
|
||||
else if (item.type === 'movement') onSelectMovement(item.id);
|
||||
else onSelectPainting(item.id);
|
||||
},
|
||||
[onSelectArtist, onSelectMovement, onSelectPainting]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length < 2) {
|
||||
setResults([]);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setActiveIndex(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
api
|
||||
.search(trimmed)
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setResults(data.results);
|
||||
setOpen(true);
|
||||
setActiveIndex(data.results.length > 0 ? 0 : -1);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setResults([]);
|
||||
setError(t('searchFailed'));
|
||||
setOpen(true);
|
||||
setActiveIndex(-1);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
const onPointerDown = (e: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onPointerDown);
|
||||
return () => document.removeEventListener('mousedown', onPointerDown);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
setActiveIndex(-1);
|
||||
return;
|
||||
}
|
||||
if (!open || flatResults.length === 0) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => (i + 1) % flatResults.length);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => (i <= 0 ? flatResults.length - 1 : i - 1));
|
||||
} else if (e.key === 'Enter' && activeIndex >= 0) {
|
||||
e.preventDefault();
|
||||
activate(flatResults[activeIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
const showPanel = open && query.trim().length >= 2;
|
||||
|
||||
return (
|
||||
<div className="catalog-search" ref={rootRef}>
|
||||
<label className="catalog-search-label" htmlFor={`${listboxId}-input`}>
|
||||
{t('label')}
|
||||
</label>
|
||||
<div className="catalog-search-field">
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={`${listboxId}-input`}
|
||||
className="catalog-search-input"
|
||||
type="search"
|
||||
role="combobox"
|
||||
aria-expanded={showPanel}
|
||||
aria-controls={showPanel ? `${listboxId}-listbox` : undefined}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={
|
||||
showPanel && activeIndex >= 0 ? `${listboxId}-opt-${activeIndex}` : undefined
|
||||
}
|
||||
placeholder={t('placeholder')}
|
||||
value={query}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (query.trim().length >= 2) setOpen(true);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{loading && <span className="catalog-search-spinner" aria-hidden />}
|
||||
</div>
|
||||
|
||||
{showPanel && (
|
||||
<div
|
||||
id={`${listboxId}-listbox`}
|
||||
className="catalog-search-panel"
|
||||
role="listbox"
|
||||
aria-label={t('resultsLabel')}
|
||||
>
|
||||
{error && <p className="catalog-search-message catalog-search-error">{error}</p>}
|
||||
{!error && !loading && flatResults.length === 0 && (
|
||||
<p className="catalog-search-message">{t('noMatches')}</p>
|
||||
)}
|
||||
{grouped.map((group) => (
|
||||
<div key={group.type} className="catalog-search-group">
|
||||
<p className="catalog-search-group-label">{typeLabels[group.type]}</p>
|
||||
<ul className="catalog-search-list">
|
||||
{group.items.map((item) => {
|
||||
const flatIndex = flatResults.findIndex((r) => resultKey(r) === resultKey(item));
|
||||
const active = flatIndex === activeIndex;
|
||||
return (
|
||||
<li key={resultKey(item)}>
|
||||
<button
|
||||
type="button"
|
||||
id={`${listboxId}-opt-${flatIndex}`}
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={`catalog-search-option${active ? ' catalog-search-option-active' : ''}`}
|
||||
onMouseEnter={() => setActiveIndex(flatIndex)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => activate(item)}
|
||||
>
|
||||
{item.type === 'artist' && (
|
||||
<>
|
||||
<img
|
||||
className="catalog-search-thumb"
|
||||
src={portraitThumbUrl(item)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="catalog-search-option-text">
|
||||
<span className="catalog-search-option-title">{item.name}</span>
|
||||
<span className="catalog-search-option-meta">
|
||||
{[item.movement_name, formatYears(item.birth_year, item.death_year)]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{item.type === 'movement' && (
|
||||
<>
|
||||
<span
|
||||
className="catalog-search-movement-swatch"
|
||||
style={{ background: item.color || '#c9a96e' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="catalog-search-option-text">
|
||||
<span className="catalog-search-option-title">{item.name}</span>
|
||||
<span className="catalog-search-option-meta">
|
||||
{[item.era_name, formatYears(item.start_year, item.end_year)]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{item.type === 'painting' && (
|
||||
<>
|
||||
<img
|
||||
className="catalog-search-thumb"
|
||||
src={paintingThumbSrc(item)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="catalog-search-option-text">
|
||||
<span className="catalog-search-option-title">{item.title}</span>
|
||||
<span className="catalog-search-option-meta">
|
||||
{[item.artist_name, item.year != null ? String(item.year) : null, item.movement_name]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatYears(start: number | null | undefined, end: number | null | undefined): string | null {
|
||||
if (start == null && end == null) return null;
|
||||
if (start != null && end != null) return `${start}–${end}`;
|
||||
if (start != null) return String(start);
|
||||
return String(end);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import './CuratorLoginModal.css';
|
||||
|
||||
interface Props {
|
||||
@@ -8,6 +9,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function CuratorLoginModal({ open, onClose, onLogin }: Props) {
|
||||
const { t } = useTranslation('debug');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -32,7 +35,7 @@ export default function CuratorLoginModal({ open, onClose, onLogin }: Props) {
|
||||
await onLogin(username.trim(), password);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
setError(err instanceof Error ? err.message : t('loginFailed'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -47,13 +50,13 @@ export default function CuratorLoginModal({ open, onClose, onLogin }: Props) {
|
||||
aria-modal="true"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 id="curator-login-title">Curator login</h2>
|
||||
<h2 id="curator-login-title">{t('loginTitle')}</h2>
|
||||
<p className="curator-login-hint">
|
||||
Debug tools and catalog edits require a curator account.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label className="curator-login-field">
|
||||
<span>Username</span>
|
||||
<span>{t('username')}</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
@@ -64,7 +67,7 @@ export default function CuratorLoginModal({ open, onClose, onLogin }: Props) {
|
||||
/>
|
||||
</label>
|
||||
<label className="curator-login-field">
|
||||
<span>Password</span>
|
||||
<span>{t('password')}</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
@@ -77,10 +80,10 @@ export default function CuratorLoginModal({ open, onClose, onLogin }: Props) {
|
||||
{error && <p className="curator-login-error">{error}</p>}
|
||||
<div className="curator-login-actions">
|
||||
<button type="button" className="curator-login-cancel" onClick={onClose} disabled={submitting}>
|
||||
Cancel
|
||||
{tc('cancel')}
|
||||
</button>
|
||||
<button type="submit" className="curator-login-submit" disabled={submitting}>
|
||||
{submitting ? 'Signing in…' : 'Sign in'}
|
||||
{submitting ? t('saving') : t('signIn')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useRef, type ChangeEvent } from 'react';
|
||||
|
||||
interface Props {
|
||||
uploading: boolean;
|
||||
disabled?: boolean;
|
||||
onUploadPress?: () => void;
|
||||
onFileSelected: (file: File) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export default function DebugUploadButton({
|
||||
uploading,
|
||||
disabled = false,
|
||||
onUploadPress,
|
||||
onFileSelected,
|
||||
}: Props) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const inactive = disabled || uploading;
|
||||
|
||||
const handleChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const input = e.currentTarget;
|
||||
const file = input.files?.[0];
|
||||
if (!file || inactive) return;
|
||||
|
||||
try {
|
||||
await onFileSelected(file);
|
||||
} finally {
|
||||
input.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<label
|
||||
className={`debug-upload-btn${inactive ? ' debug-upload-btn-disabled' : ''}`}
|
||||
aria-busy={uploading}
|
||||
onClick={() => {
|
||||
if (!inactive) onUploadPress?.();
|
||||
}}
|
||||
>
|
||||
{uploading ? (
|
||||
<span className="debug-upload-btn-loading">
|
||||
<span className="debug-upload-btn-spinner" aria-hidden />
|
||||
Uploading…
|
||||
</span>
|
||||
) : (
|
||||
'Upload'
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="debug-upload-input"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif,image/avif,.jpg,.jpeg,.png,.webp,.gif,.avif"
|
||||
disabled={inactive}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -54,6 +54,22 @@
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.gallery-loading-marker-compact {
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
margin: 6px 0 8px;
|
||||
font-size: 11px;
|
||||
color: rgba(232, 213, 181, 0.85);
|
||||
}
|
||||
|
||||
.gallery-loading-marker-compact .gallery-loading-marker-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-width: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes gallery-loading-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
|
||||
@@ -6,6 +6,8 @@ interface Props {
|
||||
overlay?: boolean;
|
||||
/** Compact strip along the bottom — does not block interaction. */
|
||||
banner?: boolean;
|
||||
/** Inline row for small panels (debug upload, etc.). */
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -13,13 +15,16 @@ export default function GalleryLoadingMarker({
|
||||
message = 'Loading…',
|
||||
overlay = false,
|
||||
banner = false,
|
||||
compact = false,
|
||||
className = '',
|
||||
}: Props) {
|
||||
const modeClass = overlay
|
||||
? ' gallery-loading-marker-overlay'
|
||||
: banner
|
||||
? ' gallery-loading-marker-banner'
|
||||
: '';
|
||||
: compact
|
||||
? ' gallery-loading-marker-compact'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import type { GalleryWindowSpec, GalleryWindowStyle } from '../data/movement-interior-styles';
|
||||
|
||||
const WALL_HEIGHT = 4.2;
|
||||
@@ -69,10 +68,56 @@ function WindowFrame({
|
||||
</mesh>
|
||||
|
||||
{arch && style === 'gothic-lancet' && (
|
||||
<mesh position={[0, height / 2 + 0.15, -depth / 2 + 0.02]}>
|
||||
<coneGeometry args={[width / 2 + frameW, 0.5, 4]} />
|
||||
<meshStandardMaterial color={trimColor} roughness={0.4} metalness={0.35} />
|
||||
</mesh>
|
||||
<>
|
||||
{/* Two-centred arch head built from short chords */}
|
||||
{Array.from({ length: 14 }, (_, i) => {
|
||||
const segs = 14;
|
||||
const rise = width * 0.62;
|
||||
const pt = (t: number): [number, number] => {
|
||||
const x = (t - 0.5) * (width + frameW * 2);
|
||||
const y = Math.pow(Math.cos((t - 0.5) * Math.PI), 0.72) * rise;
|
||||
return [x, y];
|
||||
};
|
||||
const [x0, y0] = pt(i / segs);
|
||||
const [x1, y1] = pt((i + 1) / segs);
|
||||
const len = Math.hypot(x1 - x0, y1 - y0);
|
||||
return (
|
||||
<mesh
|
||||
key={i}
|
||||
position={[(x0 + x1) / 2, height / 2 + (y0 + y1) / 2, -depth / 2 + 0.02]}
|
||||
rotation={[0, 0, Math.atan2(y1 - y0, x1 - x0)]}
|
||||
>
|
||||
<boxGeometry args={[len * 1.15, frameW * 1.4, depth]} />
|
||||
<meshStandardMaterial color={trimColor} roughness={0.55} metalness={0.15} />
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
{/* Glazed spandrel filling the arch head */}
|
||||
<mesh position={[0, height / 2 + width * 0.16, 0.01]}>
|
||||
<planeGeometry args={[width * 0.72, width * 0.5]} />
|
||||
<meshStandardMaterial
|
||||
color="#9fc4ef"
|
||||
emissive="#9fc4ef"
|
||||
emissiveIntensity={0.6}
|
||||
toneMapped={false}
|
||||
transparent
|
||||
opacity={0.8}
|
||||
/>
|
||||
</mesh>
|
||||
{/* Mullions and transoms */}
|
||||
{[-width / 4, width / 4].map((ox) => (
|
||||
<mesh key={ox} position={[ox, 0, -depth / 2 + 0.03]}>
|
||||
<boxGeometry args={[0.045, height, depth]} />
|
||||
<meshStandardMaterial color={trimColor} roughness={0.55} metalness={0.15} />
|
||||
</mesh>
|
||||
))}
|
||||
{[height / 3, -height / 6].map((oy) => (
|
||||
<mesh key={oy} position={[0, oy, -depth / 2 + 0.03]}>
|
||||
<boxGeometry args={[width, 0.035, depth]} />
|
||||
<meshStandardMaterial color={trimColor} roughness={0.55} metalness={0.15} />
|
||||
</mesh>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{style === 'roman-arch' && (
|
||||
@@ -146,8 +191,6 @@ function SingleWindow({
|
||||
spec: GalleryWindowSpec;
|
||||
trimColor: string;
|
||||
}) {
|
||||
const glassColor = useMemo(() => new THREE.Color(spec.lightColor), [spec.lightColor]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
<WindowFrame style={spec.style} width={spec.width} height={spec.height} trimColor={trimColor} />
|
||||
@@ -173,22 +216,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 +259,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 +282,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>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
.locale-switcher {
|
||||
display: inline-flex;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.locale-switcher-btn {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: none;
|
||||
padding: 0.25rem 0.55rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.locale-switcher-btn-active {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.locale-switcher-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '../i18n';
|
||||
import { setApiLocale } from '../api/client';
|
||||
import { writeStoredLocale, type AppLocale } from '../utils/localeStorage';
|
||||
import './LocaleSwitcher.css';
|
||||
|
||||
interface Props {
|
||||
onLocaleChange?: (locale: AppLocale) => void;
|
||||
}
|
||||
|
||||
export default function LocaleSwitcher({ onLocaleChange }: Props) {
|
||||
const { t } = useTranslation('common');
|
||||
const current = (i18n.language === 'ru' ? 'ru' : 'en') as AppLocale;
|
||||
|
||||
const setLocale = (locale: AppLocale) => {
|
||||
if (locale === current) return;
|
||||
void i18n.changeLanguage(locale);
|
||||
writeStoredLocale(locale);
|
||||
setApiLocale(locale);
|
||||
onLocaleChange?.(locale);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="locale-switcher" role="group" aria-label={t('language')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`locale-switcher-btn${current === 'en' ? ' locale-switcher-btn-active' : ''}`}
|
||||
onClick={() => setLocale('en')}
|
||||
aria-pressed={current === 'en'}
|
||||
>
|
||||
{t('localeEn')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`locale-switcher-btn${current === 'ru' ? ' locale-switcher-btn-active' : ''}`}
|
||||
onClick={() => setLocale('ru')}
|
||||
aria-pressed={current === 'ru'}
|
||||
>
|
||||
{t('localeRu')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+1419
-361
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,38 @@
|
||||
import { useMemo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import type { MovementInteriorStyle } from '../data/movement-interior-styles';
|
||||
import type { MovementInteriorStyle, GalleryWindowSpec } from '../data/movement-interior-styles';
|
||||
import type { RoomDims } from './hall-details/geometry';
|
||||
import {
|
||||
CinquecentoDetails,
|
||||
QuattrocentoDetails,
|
||||
RomanAtriumDetails,
|
||||
} from './hall-details/classical';
|
||||
import {
|
||||
BaroqueStateDetails,
|
||||
FlemishDetails,
|
||||
ManneristDetails,
|
||||
NeoclassicalDetails,
|
||||
RococoDetails,
|
||||
} from './hall-details/courtly';
|
||||
import {
|
||||
ArtNouveauDetails,
|
||||
BourgeoisSalonDetails,
|
||||
GothicRevivalDetails,
|
||||
NorthLightSalonDetails,
|
||||
ParisAtelierDetails,
|
||||
SymbolistDetails,
|
||||
} from './hall-details/nineteenth';
|
||||
import {
|
||||
ConstructivistDetails,
|
||||
CubistAtelierDetails,
|
||||
DadaDetails,
|
||||
ExpressionistDetails,
|
||||
FauvistDetails,
|
||||
FuturistDetails,
|
||||
LoftDetails,
|
||||
SuprematistDetails,
|
||||
SurrealistDetails,
|
||||
WhiteCubeDetails,
|
||||
} from './hall-details/modernism';
|
||||
|
||||
interface Props {
|
||||
style: MovementInteriorStyle;
|
||||
@@ -8,215 +40,271 @@ interface Props {
|
||||
depth: number;
|
||||
halfW: number;
|
||||
halfD: number;
|
||||
/** Runtime window placement, so upper-wall relief can step around openings. */
|
||||
windows?: GalleryWindowSpec[];
|
||||
}
|
||||
|
||||
function PalazzoDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
|
||||
const pilasterPositions = useMemo(
|
||||
() =>
|
||||
[
|
||||
[-halfW + 0.35, -halfD + 0.35],
|
||||
[halfW - 0.35, -halfD + 0.35],
|
||||
[-halfW + 0.35, halfD - 0.35],
|
||||
[halfW - 0.35, halfD - 0.35],
|
||||
] as [number, number][],
|
||||
[halfW, halfD]
|
||||
);
|
||||
function GothicDetails({ width, depth, halfW, halfD, trim }: Omit<Props, 'style' | 'windows'> & { trim: string }) {
|
||||
// Compound piers along the side walls, spaced by bay, each rising into the
|
||||
// vault springer. Shafts stay flush against the wall (0.16 m proud) so they
|
||||
// never intrude on the ~0.7 m frame hang margin.
|
||||
const bays = useMemo(() => {
|
||||
const spacing = 4.2;
|
||||
const count = Math.max(2, Math.min(9, Math.floor(depth / spacing)));
|
||||
const step = depth / (count + 1);
|
||||
return Array.from({ length: count }, (_, i) => -halfD + step * (i + 1));
|
||||
}, [depth, halfD]);
|
||||
|
||||
const shaftHeight = 3.5;
|
||||
|
||||
return (
|
||||
<group>
|
||||
{pilasterPositions.map(([x, z], i) => (
|
||||
<group key={i} position={[x, 0, z]}>
|
||||
<mesh position={[0, 1.8, 0]} castShadow>
|
||||
<boxGeometry args={[0.22, 3.6, 0.22]} />
|
||||
<meshStandardMaterial color="#e8dcc8" roughness={0.85} metalness={0.05} />
|
||||
</mesh>
|
||||
<mesh position={[0, 3.65, 0]}>
|
||||
<boxGeometry args={[0.28, 0.14, 0.28]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.35} metalness={0.55} />
|
||||
</mesh>
|
||||
{bays.map((z, i) => (
|
||||
<group key={i}>
|
||||
{[-1, 1].map((side) => (
|
||||
<group key={side} position={[side * (halfW - 0.09), 0, z]}>
|
||||
{/* Compound pier: a heavier centre shaft flanked by colonnettes */}
|
||||
<mesh position={[0, shaftHeight / 2, 0]}>
|
||||
<cylinderGeometry args={[0.1, 0.115, shaftHeight, 12]} />
|
||||
<meshStandardMaterial color="#a89e88" roughness={0.78} metalness={0.04} />
|
||||
</mesh>
|
||||
{[-0.17, 0.17].map((dz) => (
|
||||
<mesh key={dz} position={[0, shaftHeight / 2, dz]}>
|
||||
<cylinderGeometry args={[0.052, 0.06, shaftHeight, 10]} />
|
||||
<meshStandardMaterial color="#9e9482" roughness={0.82} metalness={0.03} />
|
||||
</mesh>
|
||||
))}
|
||||
{/* Moulded base and foliate capital */}
|
||||
<mesh position={[0, 0.1, 0]}>
|
||||
<boxGeometry args={[0.28, 0.2, 0.52]} />
|
||||
<meshStandardMaterial color="#978d79" roughness={0.85} />
|
||||
</mesh>
|
||||
<mesh position={[0, shaftHeight + 0.12, 0]}>
|
||||
<boxGeometry args={[0.3, 0.24, 0.56]} />
|
||||
<meshStandardMaterial color="#b3a892" roughness={0.7} metalness={0.05} />
|
||||
</mesh>
|
||||
{/* Vault springer resting on the capital */}
|
||||
<mesh position={[0, shaftHeight + 0.36, 0]} rotation={[0, 0, Math.PI / 4]}>
|
||||
<boxGeometry args={[0.17, 0.17, 0.46]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.72} metalness={0.06} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Transverse rib arching across the nave between opposite piers */}
|
||||
<group position={[0, shaftHeight + 0.42, z]}>
|
||||
{Array.from({ length: 9 }, (_, s) => {
|
||||
const segs = 9;
|
||||
const t0 = s / segs;
|
||||
const t1 = (s + 1) / segs;
|
||||
const xAt = (t: number) => -halfW + 0.09 + t * (halfW - 0.09) * 2;
|
||||
const yAt = (t: number) => Math.sin(Math.PI * t) * 0.34;
|
||||
const x0 = xAt(t0);
|
||||
const x1 = xAt(t1);
|
||||
const y0 = yAt(t0);
|
||||
const y1 = yAt(t1);
|
||||
const len = Math.hypot(x1 - x0, y1 - y0);
|
||||
return (
|
||||
<mesh
|
||||
key={s}
|
||||
position={[(x0 + x1) / 2, (y0 + y1) / 2, 0]}
|
||||
rotation={[0, 0, Math.atan2(y1 - y0, x1 - x0)]}
|
||||
>
|
||||
<boxGeometry args={[len * 1.06, 0.13, 0.16]} />
|
||||
<meshStandardMaterial color="#b0a58f" roughness={0.74} metalness={0.05} />
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Wainscoting rail */}
|
||||
{[
|
||||
[0, -halfD + 0.09, width, 0.12] as const,
|
||||
[0, halfD - 0.09, width, 0.12] as const,
|
||||
[-halfW + 0.09, 0, 0.12, depth] as const,
|
||||
[halfW - 0.09, 0, 0.12, depth] as const,
|
||||
].map(([x, z, w, d], i) => (
|
||||
<mesh key={i} position={[x, 1.05, z]}>
|
||||
<boxGeometry args={[w, 0.08, d]} />
|
||||
<meshStandardMaterial color="#ddd0b8" roughness={0.78} metalness={0.08} />
|
||||
{/* Moulded string course running the length of both side walls */}
|
||||
{[-1, 1].map((side) => (
|
||||
<mesh key={side} position={[side * (halfW - 0.05), 2.62, 0]}>
|
||||
<boxGeometry args={[0.12, 0.1, depth - 0.2]} />
|
||||
<meshStandardMaterial color="#b3a892" roughness={0.7} metalness={0.05} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{/* Coffered ceiling */}
|
||||
{Array.from({ length: Math.min(6, Math.floor(width / 2.5)) }, (_, col) =>
|
||||
Array.from({ length: Math.min(5, Math.floor(depth / 2.8)) }, (_, row) => {
|
||||
const cx = -halfW + 1.4 + col * 2.4;
|
||||
const cz = -halfD + 1.5 + row * 2.6;
|
||||
return (
|
||||
<mesh key={`${col}-${row}`} position={[cx, 4.12, cz]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<boxGeometry args={[2.0, 2.2, 0.06]} />
|
||||
<meshStandardMaterial color="#f5efe4" roughness={0.88} />
|
||||
</mesh>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function BaroqueDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
|
||||
return (
|
||||
<group>
|
||||
{/* Gilded cornice ring */}
|
||||
{[
|
||||
[0, -halfD + 0.06, width, 0.1] as const,
|
||||
[0, halfD - 0.06, width, 0.1] as const,
|
||||
[-halfW + 0.06, 0, 0.1, depth] as const,
|
||||
[halfW - 0.06, 0, 0.1, depth] as const,
|
||||
].map(([x, z, w, d], i) => (
|
||||
<mesh key={i} position={[x, 3.95, z]}>
|
||||
<boxGeometry args={[w, 0.18, d]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.25} metalness={0.75} emissive="#3a2808" emissiveIntensity={0.08} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{/* Wall panels */}
|
||||
{[-halfW + 0.12, halfW - 0.12].map((x, i) => (
|
||||
<mesh key={i} position={[x, 2.1, 0]} rotation={[0, Math.PI / 2, 0]}>
|
||||
<boxGeometry args={[depth * 0.85, 2.8, 0.04]} />
|
||||
<meshStandardMaterial color="#3a1820" roughness={0.88} metalness={0.06} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
<pointLight position={[0, 3.6, 0]} intensity={0.9} distance={Math.max(width, depth)} color="#ffd080" />
|
||||
<mesh position={[0, 3.5, 0]}>
|
||||
<torusGeometry args={[0.55, 0.04, 8, 24]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.2} metalness={0.85} emissive="#5a4010" emissiveIntensity={0.15} />
|
||||
{/* Chancel-style cornice on the end wall */}
|
||||
<mesh position={[0, 3.96, -halfD + 0.1]}>
|
||||
<boxGeometry args={[Math.max(2.4, width - 0.4), 0.16, 0.18]} />
|
||||
<meshStandardMaterial color="#b0a58f" roughness={0.7} metalness={0.05} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function MedievalDetails({ halfW, halfD }: Pick<Props, 'halfW' | 'halfD'>) {
|
||||
const torchPositions = useMemo(
|
||||
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.2, -halfD * 0.5],
|
||||
[halfW - 0.2, -halfD * 0.5],
|
||||
[-halfW + 0.2, halfD * 0.3],
|
||||
[halfW - 0.2, halfD * 0.3],
|
||||
[-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]
|
||||
);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{torchPositions.map(([x, z], i) => (
|
||||
<group key={i} position={[x, 2.2, z]}>
|
||||
<mesh>
|
||||
<boxGeometry args={[0.08, 0.35, 0.12]} />
|
||||
<meshStandardMaterial color="#3a3028" roughness={0.9} />
|
||||
</mesh>
|
||||
<pointLight position={[0, 0.15, 0.08]} intensity={0.65} distance={5} color="#ff9830" />
|
||||
<mesh position={[0, 0.2, 0.06]}>
|
||||
<sphereGeometry args={[0.06, 8, 8]} />
|
||||
<meshStandardMaterial color="#ffb040" emissive="#ff8010" emissiveIntensity={0.8} toneMapped={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Rough stone courses */}
|
||||
{[-halfD + 0.08, halfD - 0.08].map((z, i) => (
|
||||
<mesh key={i} position={[0, 1.5, z]}>
|
||||
<boxGeometry args={[0.04, 3, 0.04]} />
|
||||
<meshStandardMaterial color="#6a6458" roughness={0.98} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
const lampPositions = useMemo(
|
||||
() =>
|
||||
[
|
||||
[0, -halfD * 0.32],
|
||||
[0, halfD * 0.18],
|
||||
] as [number, number][],
|
||||
[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][];
|
||||
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) => (
|
||||
<group key={i} position={[x, 0, z]}>
|
||||
<mesh position={[0, 1.85, 0]}>
|
||||
<cylinderGeometry args={[0.18, 0.2, 3.7, 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]} />
|
||||
<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]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.45} metalness={0.3} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassicalDetails({ halfW, trim }: Pick<Props, 'halfW'> & { trim: string }) {
|
||||
return (
|
||||
<group>
|
||||
{/* Marble revetment dado — imperial banding course at chair-rail height */}
|
||||
{[
|
||||
[-halfW + 0.5, 0],
|
||||
[halfW - 0.5, 0],
|
||||
].map(([x, z], i) => (
|
||||
[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]}>
|
||||
<mesh position={[0, 2, 0]}>
|
||||
<cylinderGeometry args={[0.16, 0.18, 4, 10]} />
|
||||
<meshStandardMaterial color="#e0d8c8" roughness={0.88} />
|
||||
{/* 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.36, 0.1, 0.36]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.25} />
|
||||
<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 SalonDetails({ trim }: { trim: string }) {
|
||||
return (
|
||||
<mesh position={[0, 4.05, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.3, 1.2, 32]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.35} metalness={0.5} side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MovementHallDetails({ style, width, depth, halfW, halfD }: Props) {
|
||||
/**
|
||||
* Period architecture for a movement hall.
|
||||
*
|
||||
* Every movement has its own interior, built from the architecture of the same
|
||||
* period as the movement itself. The Gothic nave and Byzantine basilica below
|
||||
* are the reference implementations; everything else lives in
|
||||
* `hall-details/`, sharing the geometry budget documented in
|
||||
* `hall-details/primitives.tsx`.
|
||||
*/
|
||||
export default function MovementHallDetails({ style, width, depth, halfW, halfD, windows }: Props) {
|
||||
const trim = style.tints.trim;
|
||||
const room: RoomDims = useMemo(
|
||||
() => ({ width, depth, halfW, halfD }),
|
||||
[width, depth, halfW, halfD]
|
||||
);
|
||||
const period = { room, trim, windows };
|
||||
|
||||
switch (style.details) {
|
||||
case 'palazzo':
|
||||
return <PalazzoDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
|
||||
case 'gothic':
|
||||
return <GothicDetails width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
|
||||
case 'byzantine':
|
||||
return <ByzantineDetails halfW={halfW} halfD={halfD} trim={trim} />;
|
||||
case 'roman':
|
||||
return <RomanAtriumDetails {...period} />;
|
||||
case 'quattrocento':
|
||||
return <QuattrocentoDetails {...period} />;
|
||||
case 'cinquecento':
|
||||
return <CinquecentoDetails {...period} />;
|
||||
case 'flemish':
|
||||
return <FlemishDetails {...period} />;
|
||||
case 'mannerist':
|
||||
return <ManneristDetails {...period} />;
|
||||
case 'baroque':
|
||||
return <BaroqueDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
|
||||
case 'medieval':
|
||||
return <MedievalDetails halfW={halfW} halfD={halfD} />;
|
||||
return <BaroqueStateDetails {...period} />;
|
||||
case 'rococo':
|
||||
return <RococoDetails {...period} />;
|
||||
case 'neoclassical':
|
||||
return <NeoclassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
|
||||
case 'classical':
|
||||
return <ClassicalDetails halfW={halfW} trim={trim} />;
|
||||
case 'salon':
|
||||
return <SalonDetails trim={trim} />;
|
||||
default:
|
||||
return null;
|
||||
return <NeoclassicalDetails {...period} />;
|
||||
case 'gothic-revival':
|
||||
return <GothicRevivalDetails {...period} />;
|
||||
case 'bourgeois':
|
||||
return <BourgeoisSalonDetails {...period} />;
|
||||
case 'north-light':
|
||||
return <NorthLightSalonDetails {...period} />;
|
||||
case 'paris-atelier':
|
||||
return <ParisAtelierDetails {...period} />;
|
||||
case 'symbolist':
|
||||
return <SymbolistDetails {...period} />;
|
||||
case 'art-nouveau':
|
||||
return <ArtNouveauDetails {...period} />;
|
||||
case 'fauvist':
|
||||
return <FauvistDetails {...period} />;
|
||||
case 'expressionist':
|
||||
return <ExpressionistDetails {...period} />;
|
||||
case 'cubist-atelier':
|
||||
return <CubistAtelierDetails {...period} />;
|
||||
case 'futurist':
|
||||
return <FuturistDetails {...period} />;
|
||||
case 'suprematist':
|
||||
return <SuprematistDetails {...period} />;
|
||||
case 'constructivist':
|
||||
return <ConstructivistDetails {...period} />;
|
||||
case 'dada':
|
||||
return <DadaDetails {...period} />;
|
||||
case 'surrealist':
|
||||
return <SurrealistDetails {...period} />;
|
||||
case 'loft':
|
||||
return <LoftDetails {...period} />;
|
||||
case 'white-cube':
|
||||
return <WhiteCubeDetails {...period} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
.mtree {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 12px 12px;
|
||||
}
|
||||
|
||||
.mtree-caption {
|
||||
margin: 0 0 8px;
|
||||
text-align: center;
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
color: rgba(201, 169, 110, 0.65);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mtree-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
color: rgba(201, 169, 110, 0.6);
|
||||
font-family: 'Georgia', serif;
|
||||
}
|
||||
|
||||
.mtree-canvas {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
background:
|
||||
radial-gradient(ellipse 60% 45% at 50% 100%, rgba(201, 169, 110, 0.1), transparent 72%),
|
||||
radial-gradient(ellipse 80% 60% at 50% 0%, rgba(120, 150, 190, 0.08), transparent 70%),
|
||||
rgba(0, 0, 0, 0.28);
|
||||
border: 1px solid rgba(201, 169, 110, 0.12);
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.mtree-canvas.mtree-panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.mtree-svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.mtree-limb {
|
||||
cursor: pointer;
|
||||
opacity: 0.9;
|
||||
transition: opacity 0.18s ease, filter 0.18s ease;
|
||||
}
|
||||
|
||||
.mtree-limb-lit {
|
||||
opacity: 1;
|
||||
filter: brightness(1.22) drop-shadow(0 0 6px rgba(255, 226, 170, 0.35));
|
||||
}
|
||||
|
||||
.mtree-limb-dim {
|
||||
opacity: 0.34;
|
||||
}
|
||||
|
||||
.mtree-root {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.mtree-graft {
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.mtree-graft-lit {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.mtree-label {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
transform: translate(-50%, -50%);
|
||||
margin: 0;
|
||||
padding: 2px 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: rgba(12, 12, 22, 0.74);
|
||||
color: rgba(245, 230, 200, 0.95);
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.mtree-label:hover {
|
||||
background: rgba(30, 28, 40, 0.92);
|
||||
color: #fff6e0;
|
||||
}
|
||||
|
||||
.mtree-label-dim {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.mtree-out-of-range {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
color: rgba(201, 169, 110, 0.7);
|
||||
font-family: 'Georgia', serif;
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ArtMovement } from '../types';
|
||||
import {
|
||||
branchOriginYear,
|
||||
buildMovementTree,
|
||||
limbXAtYear,
|
||||
type MovementTreeNode,
|
||||
} from '../utils/movementTree';
|
||||
import { shadeMovementColor, vividMovementColor } from '../utils/movementColor';
|
||||
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
||||
import './MovementTree.css';
|
||||
|
||||
interface Props {
|
||||
movements: ArtMovement[];
|
||||
viewStart: number;
|
||||
viewEnd: number;
|
||||
absoluteMin: number;
|
||||
absoluteMax: number;
|
||||
onViewChange: (start: number, end: number) => void;
|
||||
onMovementClick?: (movementId: number) => void;
|
||||
}
|
||||
|
||||
interface Pt {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const SIDE_PAD = 56;
|
||||
/**
|
||||
* Horizontal spread is tied to the zoom, not to the canvas.
|
||||
*
|
||||
* 90 % of the catalogue lives in the last 15 % of the time axis, so a tree
|
||||
* stretched to full width with all of history in view is one long trunk under a
|
||||
* flat bar. Instead the whole-history view draws a narrow tree, and every zoom
|
||||
* step fans the crown out — the chart grows as you walk up it.
|
||||
*/
|
||||
const FULL_VIEW_WIDTH_SHARE = 0.52;
|
||||
const ZOOM_SPREAD_EXPONENT = 0.45;
|
||||
/** How far past "everything fits" the tree may be blown up when zoomed in. */
|
||||
const MAX_FIT_BOOST = 2.4;
|
||||
/** Exponential chase rate (1/s) for the horizontal fit, so zoom reads as growth. */
|
||||
const FIT_ANIM_RATE = 9;
|
||||
const RIBBON_SAMPLES = 18;
|
||||
/** Vertical room a label needs. */
|
||||
const MIN_LABEL_HEIGHT_PX = 20;
|
||||
/**
|
||||
* Readability floors. A 30-year movement is 8 px tall when the whole of
|
||||
* history is on screen; without these the modern crown fuses into one bar.
|
||||
*/
|
||||
const MIN_LIMB_RISE_PX = 30;
|
||||
const MIN_JUNCTION_RISE_PX = 38;
|
||||
/** A limb is never drawn thicker than this share of its own length. */
|
||||
const MAX_THICKNESS_OF_LENGTH = 0.55;
|
||||
|
||||
function cubicAt(p0: Pt, c1: Pt, c2: Pt, p3: Pt, t: number): Pt {
|
||||
const u = 1 - t;
|
||||
const a = u * u * u;
|
||||
const b = 3 * u * u * t;
|
||||
const c = 3 * u * t * t;
|
||||
const d = t * t * t;
|
||||
return {
|
||||
x: a * p0.x + b * c1.x + c * c2.x + d * p3.x,
|
||||
y: a * p0.y + b * c1.y + c * c2.y + d * p3.y,
|
||||
};
|
||||
}
|
||||
|
||||
function cubicTangent(p0: Pt, c1: Pt, c2: Pt, p3: Pt, t: number): Pt {
|
||||
const u = 1 - t;
|
||||
const a = 3 * u * u;
|
||||
const b = 6 * u * t;
|
||||
const c = 3 * t * t;
|
||||
return {
|
||||
x: a * (c1.x - p0.x) + b * (c2.x - c1.x) + c * (p3.x - c2.x),
|
||||
y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p3.y - c2.y),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filled ribbon of varying width along a cubic. Offsetting along the curve
|
||||
* normal (rather than horizontally) keeps branch junctions solid even when a
|
||||
* zoomed-out view squeezes them almost flat.
|
||||
*/
|
||||
function ribbonPath(p0: Pt, c1: Pt, c2: Pt, p3: Pt, w0: number, w1: number): string {
|
||||
const left: Pt[] = [];
|
||||
const right: Pt[] = [];
|
||||
for (let i = 0; i <= RIBBON_SAMPLES; i++) {
|
||||
const t = i / RIBBON_SAMPLES;
|
||||
const p = cubicAt(p0, c1, c2, p3, t);
|
||||
const d = cubicTangent(p0, c1, c2, p3, t);
|
||||
const len = Math.hypot(d.x, d.y) || 1;
|
||||
const nx = -d.y / len;
|
||||
const ny = d.x / len;
|
||||
const half = (w0 + (w1 - w0) * t) / 2;
|
||||
left.push({ x: p.x + nx * half, y: p.y + ny * half });
|
||||
right.push({ x: p.x - nx * half, y: p.y - ny * half });
|
||||
}
|
||||
const fmt = (pt: Pt) => `${pt.x.toFixed(2)} ${pt.y.toFixed(2)}`;
|
||||
const forward = left.map((pt, i) => `${i === 0 ? 'M' : 'L'} ${fmt(pt)}`).join(' ');
|
||||
const back = right
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((pt) => `L ${fmt(pt)}`)
|
||||
.join(' ');
|
||||
return `${forward} ${back} Z`;
|
||||
}
|
||||
|
||||
interface LimbShape {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
shade: string;
|
||||
/** Trunk / limb body. */
|
||||
d: string;
|
||||
/** Junction ribbon growing out of the structural parent (may be empty). */
|
||||
junction: string;
|
||||
/** Rounded tip cap. */
|
||||
tip: Pt & { r: number };
|
||||
/** Root flare under a tree root, drawn only when the base is on screen. */
|
||||
roots: string[];
|
||||
labelX: number;
|
||||
labelY: number;
|
||||
labelVisible: boolean;
|
||||
yearRange: string;
|
||||
depth: number;
|
||||
inView: boolean;
|
||||
}
|
||||
|
||||
interface GraftShape {
|
||||
key: string;
|
||||
d: string;
|
||||
color: string;
|
||||
fromId: number;
|
||||
toId: number;
|
||||
}
|
||||
|
||||
function formatYear(year: number): string {
|
||||
return year < 0 ? `${Math.abs(year)} BCE` : `${year}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Greedy declutter: closer to the trunk wins. Every visible movement asks for a
|
||||
* name, and the ones that would collide with an already-placed name — or fall
|
||||
* off the canvas — stay anonymous until you zoom in on them.
|
||||
*/
|
||||
function hideOverlappingLabels(limbs: LimbShape[], width: number, height: number): void {
|
||||
const placed: { x0: number; y0: number; x1: number; y1: number }[] = [];
|
||||
const candidates = limbs
|
||||
.map((limb, index) => ({ limb, index }))
|
||||
.filter(({ limb }) => limb.labelVisible)
|
||||
.sort((a, b) => a.limb.depth - b.limb.depth || b.limb.labelY - a.limb.labelY);
|
||||
|
||||
for (const { limb } of candidates) {
|
||||
const halfW = (limb.name.length * 6.6 + 16) / 2;
|
||||
const halfH = MIN_LABEL_HEIGHT_PX / 2;
|
||||
const box = {
|
||||
x0: limb.labelX - halfW,
|
||||
y0: limb.labelY - halfH,
|
||||
x1: limb.labelX + halfW,
|
||||
y1: limb.labelY + halfH,
|
||||
};
|
||||
const offCanvas = box.x0 < 2 || box.x1 > width - 2 || box.y0 < 2 || box.y1 > height - 2;
|
||||
const collides = placed.some(
|
||||
(p) => box.x0 < p.x1 && box.x1 > p.x0 && box.y0 < p.y1 && box.y1 > p.y0
|
||||
);
|
||||
if (offCanvas || collides) {
|
||||
limb.labelVisible = false;
|
||||
continue;
|
||||
}
|
||||
placed.push(box);
|
||||
}
|
||||
}
|
||||
|
||||
export default function MovementTree({
|
||||
movements,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
absoluteMin,
|
||||
absoluteMax,
|
||||
onViewChange,
|
||||
onMovementClick,
|
||||
}: Props) {
|
||||
const { t } = useTranslation('home');
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const [canvas, setCanvas] = useState({ w: 900, h: 600 });
|
||||
const [panning, setPanning] = useState(false);
|
||||
const [hoveredId, setHoveredId] = useState<number | null>(null);
|
||||
const [fitScale, setFitScale] = useState(1);
|
||||
const panStart = useRef({ y: 0, viewStart: 0, viewEnd: 0 });
|
||||
const viewRef = useRef({ viewStart, viewEnd });
|
||||
const onViewChangeRef = useRef(onViewChange);
|
||||
const fitRef = useRef(1);
|
||||
const fitReadyRef = useRef(false);
|
||||
const fitTargetRef = useRef(1);
|
||||
const fitRafRef = useRef<number | null>(null);
|
||||
const fitLastTsRef = useRef(0);
|
||||
viewRef.current = { viewStart, viewEnd };
|
||||
onViewChangeRef.current = onViewChange;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = canvasRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
setCanvas({ w: Math.round(rect.width), h: Math.round(rect.height) });
|
||||
}
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Structure is catalogue-wide and view-independent: zooming must not reshape
|
||||
// the tree, only travel along it.
|
||||
const tree = useMemo(() => buildMovementTree(movements), [movements]);
|
||||
|
||||
const visibleIds = useMemo(() => {
|
||||
const ids = new Set<number>();
|
||||
for (const node of tree.nodes.values()) {
|
||||
const { start_year: s, end_year: e } = node.movement;
|
||||
if (e > viewStart && s < viewEnd) ids.add(node.movement.id);
|
||||
}
|
||||
return ids;
|
||||
}, [tree, viewStart, viewEnd]);
|
||||
|
||||
const targetScale = useMemo(() => {
|
||||
const usable = Math.max(240, canvas.w - SIDE_PAD * 2);
|
||||
const fitAll = usable / (2 * Math.max(1, tree.halfSpan));
|
||||
const visibleSpan = Math.max(1, viewEnd - viewStart);
|
||||
const totalSpan = Math.max(visibleSpan, absoluteMax - absoluteMin);
|
||||
const zoomSpread = Math.pow(totalSpan / visibleSpan, ZOOM_SPREAD_EXPONENT);
|
||||
const spread = Math.min(
|
||||
MAX_FIT_BOOST,
|
||||
Math.max(FULL_VIEW_WIDTH_SHARE, FULL_VIEW_WIDTH_SHARE * zoomSpread)
|
||||
);
|
||||
|
||||
let visibleHalfSpan = 0;
|
||||
for (const id of visibleIds) {
|
||||
const node = tree.nodes.get(id);
|
||||
if (!node) continue;
|
||||
visibleHalfSpan = Math.max(
|
||||
visibleHalfSpan,
|
||||
Math.abs(node.x) + Math.abs(node.lean) + node.baseWidth / 2
|
||||
);
|
||||
}
|
||||
// Never let what is on screen spill off the canvas.
|
||||
const overflowCap = visibleHalfSpan > 0 ? usable / (2 * visibleHalfSpan) : Infinity;
|
||||
return Math.min(fitAll * spread, overflowCap);
|
||||
}, [canvas.w, tree, visibleIds, viewStart, viewEnd, absoluteMin, absoluteMax]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
fitTargetRef.current = targetScale;
|
||||
if (!fitReadyRef.current) {
|
||||
// First measured layout — adopt it instead of animating in from nothing.
|
||||
fitReadyRef.current = true;
|
||||
fitRef.current = targetScale;
|
||||
setFitScale(targetScale);
|
||||
return;
|
||||
}
|
||||
if (fitRafRef.current != null) return;
|
||||
|
||||
fitLastTsRef.current = performance.now();
|
||||
const step = (now: number) => {
|
||||
const dt = Math.min(0.05, Math.max(0, (now - fitLastTsRef.current) / 1000));
|
||||
fitLastTsRef.current = now;
|
||||
const t = 1 - Math.exp(-FIT_ANIM_RATE * dt);
|
||||
const next = fitRef.current + (fitTargetRef.current - fitRef.current) * t;
|
||||
if (Math.abs(fitTargetRef.current - next) < 0.002) {
|
||||
fitRef.current = fitTargetRef.current;
|
||||
setFitScale(fitTargetRef.current);
|
||||
fitRafRef.current = null;
|
||||
return;
|
||||
}
|
||||
fitRef.current = next;
|
||||
setFitScale(next);
|
||||
fitRafRef.current = requestAnimationFrame(step);
|
||||
};
|
||||
fitRafRef.current = requestAnimationFrame(step);
|
||||
}, [targetScale]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (fitRafRef.current != null) cancelAnimationFrame(fitRafRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const { limbs, grafts } = useMemo(() => {
|
||||
const span = viewEnd - viewStart || 1;
|
||||
const centerX = canvas.w / 2;
|
||||
const widthScale = Math.min(1.7, Math.max(0.55, fitScale));
|
||||
const sx = (treeX: number) => centerX + treeX * fitScale;
|
||||
const sy = (year: number) => canvas.h - ((year - viewStart) / span) * canvas.h;
|
||||
|
||||
const limbList: LimbShape[] = [];
|
||||
const graftList: GraftShape[] = [];
|
||||
|
||||
/**
|
||||
* Pass 1 — screen geometry per movement.
|
||||
*
|
||||
* Two readability floors apply here, and only here: the structure and the
|
||||
* dates stay untouched. A limb is drawn at least `MIN_LIMB_RISE_PX` long,
|
||||
* and never thicker than it is long, so 2 500 years of trunk and 30 years
|
||||
* of Fauvism can share one linear axis without the modern crown fusing
|
||||
* into a solid bar.
|
||||
*/
|
||||
const drawn = new Map<
|
||||
number,
|
||||
{ base: Pt; c1: Pt; c2: Pt; tip: Pt; wBase: number; wTip: number }
|
||||
>();
|
||||
|
||||
for (const id of tree.drawOrder) {
|
||||
const node = tree.nodes.get(id)!;
|
||||
const { start_year: start, end_year: end } = node.movement;
|
||||
if (end <= start) continue;
|
||||
|
||||
const base: Pt = { x: sx(limbXAtYear(node, start)), y: sy(start) };
|
||||
const trueTipY = sy(end);
|
||||
const tip: Pt = {
|
||||
x: sx(limbXAtYear(node, end)),
|
||||
y: Math.min(trueTipY, base.y - MIN_LIMB_RISE_PX),
|
||||
};
|
||||
const lengthPx = Math.hypot(tip.x - base.x, tip.y - base.y);
|
||||
const cap = Math.max(3, lengthPx * MAX_THICKNESS_OF_LENGTH);
|
||||
const wBase = Math.min(node.baseWidth * widthScale, cap);
|
||||
const wTip = Math.min(node.tipWidth * widthScale, cap * 0.82);
|
||||
const dy = tip.y - base.y;
|
||||
drawn.set(id, {
|
||||
base,
|
||||
c1: { x: base.x, y: base.y + dy * 0.42 },
|
||||
c2: { x: tip.x, y: tip.y - dy * 0.34 },
|
||||
tip,
|
||||
wBase,
|
||||
wTip,
|
||||
});
|
||||
}
|
||||
|
||||
/** Point on a drawn limb at the screen height closest to `targetY`. */
|
||||
const pointOnLimb = (parentId: number, targetY: number) => {
|
||||
const p = drawn.get(parentId)!;
|
||||
const total = p.base.y - p.tip.y || 1;
|
||||
const t = Math.min(1, Math.max(0, (p.base.y - targetY) / total));
|
||||
return {
|
||||
pt: cubicAt(p.base, p.c1, p.c2, p.tip, t),
|
||||
width: p.wBase + (p.wTip - p.wBase) * t,
|
||||
};
|
||||
};
|
||||
|
||||
// Pass 2 — ribbons.
|
||||
for (const id of tree.drawOrder) {
|
||||
const node = tree.nodes.get(id)!;
|
||||
const shape = drawn.get(id);
|
||||
if (!shape) continue;
|
||||
const { base, c1, c2, tip, wBase, wTip } = shape;
|
||||
const { start_year: start, end_year: end } = node.movement;
|
||||
|
||||
const color = vividMovementColor(node.movement.color);
|
||||
const d = ribbonPath(base, c1, c2, tip, wBase, wTip);
|
||||
|
||||
// Junction: the limb grows out of its parent a little before its own
|
||||
// date, and always climbs far enough to read as a fork.
|
||||
let junction = '';
|
||||
const parent = node.parentId != null ? tree.nodes.get(node.parentId) : null;
|
||||
if (parent && drawn.has(parent.movement.id)) {
|
||||
const byDate = sy(branchOriginYear(parent, node));
|
||||
const origin = pointOnLimb(
|
||||
parent.movement.id,
|
||||
Math.max(byDate, base.y + MIN_JUNCTION_RISE_PX)
|
||||
);
|
||||
const from = origin.pt;
|
||||
const jdy = base.y - from.y;
|
||||
const jLength = Math.hypot(base.x - from.x, jdy);
|
||||
const jCap = Math.max(3, jLength * MAX_THICKNESS_OF_LENGTH);
|
||||
const wFrom = Math.min(origin.width * 0.92, wBase * 1.25, jCap);
|
||||
const jc1: Pt = { x: from.x, y: from.y + jdy * 0.45 };
|
||||
const jc2: Pt = { x: base.x, y: base.y - jdy * 0.45 };
|
||||
junction = ribbonPath(from, jc1, jc2, base, wFrom, Math.min(wBase, jCap));
|
||||
}
|
||||
|
||||
// Roots: only the bottom of a tree, and only when that bottom is in frame.
|
||||
const roots: string[] = [];
|
||||
if (!parent && base.y > -canvas.h && base.y < canvas.h * 2) {
|
||||
const flare = Math.max(22, wBase * 1.4);
|
||||
for (const dir of [-1, -0.35, 0.35, 1]) {
|
||||
const endPt: Pt = { x: base.x + dir * flare, y: base.y + flare * 0.72 };
|
||||
const rc1: Pt = { x: base.x + dir * flare * 0.2, y: base.y + flare * 0.34 };
|
||||
const rc2: Pt = { x: base.x + dir * flare * 0.8, y: base.y + flare * 0.5 };
|
||||
roots.push(ribbonPath(base, rc1, rc2, endPt, wBase * 0.42, 1.5));
|
||||
}
|
||||
}
|
||||
|
||||
const inView = visibleIds.has(id);
|
||||
const clampedStart = Math.max(start, viewStart);
|
||||
const clampedEnd = Math.min(end, viewEnd);
|
||||
const midYear = (clampedStart + clampedEnd) / 2;
|
||||
const labelY = Math.min(
|
||||
Math.max(sy(midYear), tip.y + MIN_LABEL_HEIGHT_PX / 2),
|
||||
base.y
|
||||
);
|
||||
|
||||
limbList.push({
|
||||
id,
|
||||
name: node.movement.name,
|
||||
color,
|
||||
shade: shadeMovementColor(color),
|
||||
d,
|
||||
junction,
|
||||
tip: { x: tip.x, y: tip.y, r: Math.max(1.5, wTip / 2) },
|
||||
roots,
|
||||
labelX: sx(limbXAtYear(node, midYear)),
|
||||
labelY,
|
||||
labelVisible: inView,
|
||||
yearRange: `${formatYear(start)} – ${formatYear(end)}`,
|
||||
depth: node.depth,
|
||||
inView,
|
||||
});
|
||||
|
||||
for (const graftId of node.graftParentIds) {
|
||||
const graftParent = tree.nodes.get(graftId);
|
||||
if (!graftParent || !drawn.has(graftId)) continue;
|
||||
const byDate = sy(branchOriginYear(graftParent, node));
|
||||
const origin = pointOnLimb(graftId, Math.max(byDate, base.y + MIN_JUNCTION_RISE_PX));
|
||||
const from = origin.pt;
|
||||
const gdy = base.y - from.y;
|
||||
const gWidth = Math.max(2.5, Math.min(wBase * 0.3, 9));
|
||||
graftList.push({
|
||||
key: `${graftId}-${id}`,
|
||||
d: ribbonPath(
|
||||
from,
|
||||
{ x: from.x, y: from.y + gdy * 0.55 },
|
||||
{ x: base.x, y: base.y - gdy * 0.3 },
|
||||
base,
|
||||
gWidth * 0.7,
|
||||
gWidth
|
||||
),
|
||||
color: vividMovementColor(graftParent.movement.color),
|
||||
fromId: graftId,
|
||||
toId: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
hideOverlappingLabels(limbList, canvas.w, canvas.h);
|
||||
return { limbs: limbList, grafts: graftList };
|
||||
}, [tree, viewStart, viewEnd, canvas.w, canvas.h, fitScale, visibleIds]);
|
||||
|
||||
/** A hovered movement lights up its whole descent line back to the root. */
|
||||
const lineageIds = useMemo(() => {
|
||||
const ids = new Set<number>();
|
||||
if (hoveredId == null) return ids;
|
||||
let cursor: number | null = hoveredId;
|
||||
let guard = 0;
|
||||
while (cursor != null && guard++ < 64) {
|
||||
ids.add(cursor);
|
||||
const node: MovementTreeNode | undefined = tree.nodes.get(cursor);
|
||||
if (!node) break;
|
||||
for (const graftId of node.graftParentIds) ids.add(graftId);
|
||||
cursor = node.parentId;
|
||||
}
|
||||
return ids;
|
||||
}, [hoveredId, tree]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = canvasRef.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = el.getBoundingClientRect();
|
||||
const { viewStart: vs, viewEnd: ve } = viewRef.current;
|
||||
// Bottom = oldest, so invert the pointer offset before reusing the shared
|
||||
// left-to-right zoom math.
|
||||
const invertedY = rect.height - (e.clientY - rect.top);
|
||||
const next = zoomTimelineView(
|
||||
invertedY,
|
||||
0,
|
||||
rect.height,
|
||||
e.deltaY,
|
||||
vs,
|
||||
ve,
|
||||
absoluteMin,
|
||||
absoluteMax
|
||||
);
|
||||
onViewChangeRef.current(next.start, next.end);
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false, capture: true });
|
||||
return () => el.removeEventListener('wheel', onWheel, { capture: true });
|
||||
}, [absoluteMin, absoluteMax]);
|
||||
|
||||
const handlePanStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
setPanning(true);
|
||||
panStart.current = { y: e.clientY, viewStart, viewEnd };
|
||||
},
|
||||
[viewStart, viewEnd]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!panning) return;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const dy = e.clientY - panStart.current.y;
|
||||
const next = panTimelineView(
|
||||
-dy,
|
||||
rect.height,
|
||||
panStart.current.viewStart,
|
||||
panStart.current.viewEnd,
|
||||
absoluteMin,
|
||||
absoluteMax
|
||||
);
|
||||
onViewChangeRef.current(next.start, next.end);
|
||||
};
|
||||
const onUp = () => setPanning(false);
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
}, [panning, absoluteMin, absoluteMax]);
|
||||
|
||||
if (movements.length === 0) {
|
||||
return (
|
||||
<div className="mtree-empty">
|
||||
<p>No art movements to grow a tree from yet.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const anyInView = limbs.some((limb) => limb.inView);
|
||||
|
||||
return (
|
||||
<div className="mtree">
|
||||
<p className="mtree-caption">
|
||||
{t('captionTreeFlow')}
|
||||
</p>
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className={`mtree-canvas${panning ? ' mtree-panning' : ''}`}
|
||||
onMouseDown={handlePanStart}
|
||||
>
|
||||
<svg
|
||||
className="mtree-svg"
|
||||
viewBox={`0 0 ${canvas.w} ${canvas.h}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
{limbs.map((limb) => (
|
||||
<linearGradient
|
||||
key={`grad-${limb.id}`}
|
||||
id={`mtree-limb-${limb.id}`}
|
||||
gradientUnits="objectBoundingBox"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="1"
|
||||
y2="0"
|
||||
>
|
||||
<stop offset="0%" stopColor={limb.shade} />
|
||||
<stop offset="45%" stopColor={limb.color} />
|
||||
<stop offset="100%" stopColor={limb.shade} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
|
||||
<g className="mtree-grafts">
|
||||
{grafts.map((graft) => (
|
||||
<path
|
||||
key={graft.key}
|
||||
d={graft.d}
|
||||
fill={graft.color}
|
||||
className={`mtree-graft${
|
||||
lineageIds.has(graft.toId) && lineageIds.has(graft.fromId)
|
||||
? ' mtree-graft-lit'
|
||||
: ''
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
|
||||
{limbs.map((limb) => {
|
||||
const lit = lineageIds.has(limb.id);
|
||||
const dim = hoveredId != null && !lit;
|
||||
return (
|
||||
<g
|
||||
key={limb.id}
|
||||
className={`mtree-limb${lit ? ' mtree-limb-lit' : ''}${
|
||||
dim ? ' mtree-limb-dim' : ''
|
||||
}`}
|
||||
onMouseEnter={() => setHoveredId(limb.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMovementClick?.(limb.id);
|
||||
}}
|
||||
>
|
||||
<title>{`${limb.name} · ${limb.yearRange}`}</title>
|
||||
{limb.roots.map((d, i) => (
|
||||
<path key={`root-${i}`} d={d} fill={limb.shade} className="mtree-root" />
|
||||
))}
|
||||
{limb.junction && (
|
||||
<path d={limb.junction} fill={`url(#mtree-limb-${limb.id})`} />
|
||||
)}
|
||||
<path d={limb.d} fill={`url(#mtree-limb-${limb.id})`} />
|
||||
<circle cx={limb.tip.x} cy={limb.tip.y} r={limb.tip.r} fill={limb.color} />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{limbs
|
||||
.filter((limb) => limb.labelVisible)
|
||||
.map((limb) => (
|
||||
<button
|
||||
key={`label-${limb.id}`}
|
||||
type="button"
|
||||
className={`mtree-label${
|
||||
hoveredId != null && !lineageIds.has(limb.id) ? ' mtree-label-dim' : ''
|
||||
}`}
|
||||
style={{ left: `${limb.labelX}px`, top: `${limb.labelY}px` }}
|
||||
title={`${limb.name} · ${limb.yearRange}`}
|
||||
onMouseEnter={() => setHoveredId(limb.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMovementClick?.(limb.id);
|
||||
}}
|
||||
>
|
||||
{limb.name}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{!anyInView && (
|
||||
<div className="mtree-out-of-range">
|
||||
<p>No movements in this time range — zoom out to see the whole tree.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { PaintingAnnotation } from '../types';
|
||||
import './PaintingAnnotations.css';
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
technique: 'Technique',
|
||||
composition: 'Composition',
|
||||
symbolism: 'Symbolism',
|
||||
history: 'History',
|
||||
subject: 'Subject',
|
||||
const CATEGORY_KEYS: Record<string, string> = {
|
||||
technique: 'categoryTechnique',
|
||||
composition: 'categoryComposition',
|
||||
symbolism: 'categorySymbolism',
|
||||
history: 'categoryHistorical',
|
||||
subject: 'categorySubject',
|
||||
};
|
||||
|
||||
interface PanelProps {
|
||||
@@ -57,6 +58,7 @@ export default function PaintingAnnotationsPanel({
|
||||
activeId,
|
||||
onSelect,
|
||||
}: PanelProps) {
|
||||
const { t } = useTranslation('annotations');
|
||||
const cardRefs = useRef<Map<number, HTMLLIElement>>(new Map());
|
||||
|
||||
if (!annotations.length) return null;
|
||||
@@ -70,11 +72,12 @@ export default function PaintingAnnotationsPanel({
|
||||
|
||||
return (
|
||||
<aside className="painting-annotations-panel" aria-label="Art history annotations">
|
||||
<h3 className="painting-annotations-title">Art history notes</h3>
|
||||
<h3 className="painting-annotations-title">{t('title')}</h3>
|
||||
<ul className="painting-annotations-list">
|
||||
{annotations.map((ann, index) => {
|
||||
const isActive = activeId === ann.id;
|
||||
const category = CATEGORY_LABELS[ann.category] || ann.category;
|
||||
const categoryKey = CATEGORY_KEYS[ann.category];
|
||||
const category = categoryKey ? t(categoryKey) : ann.category;
|
||||
return (
|
||||
<li
|
||||
key={ann.id}
|
||||
@@ -110,7 +113,7 @@ export default function PaintingAnnotationsPanel({
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Read source
|
||||
{t('readSource')}
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -407,6 +407,149 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tour-stop-panel {
|
||||
max-width: 700px;
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
padding: 16px 18px;
|
||||
background: rgba(201, 169, 110, 0.12);
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
}
|
||||
|
||||
.tour-stop-panel h3 {
|
||||
margin: 0 0 10px;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #c9a96e;
|
||||
}
|
||||
|
||||
.tour-stop-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;
|
||||
}
|
||||
|
||||
.tour-stop-empty {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: rgba(201, 169, 110, 0.55);
|
||||
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;
|
||||
@@ -552,6 +695,48 @@
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.debug-upload-input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.debug-upload-btn-disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.debug-upload-btn-loading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.debug-upload-page-overlay.gallery-loading-marker-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 300;
|
||||
}
|
||||
|
||||
.painting-frame-uploading,
|
||||
.bio-portrait-uploading {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.debug-image-panel.debug-image-panel-blocked {
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.debug-clear-btn {
|
||||
@@ -576,8 +761,7 @@
|
||||
border-color: #8cbe8c;
|
||||
}
|
||||
|
||||
.debug-clear-btn:disabled,
|
||||
.debug-upload-btn:disabled {
|
||||
.debug-clear-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ChangeEvent, type SyntheticEvent } from 'react';
|
||||
import { useCallback, useEffect, useState, type SyntheticEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { InfluenceLink, Painting, PaintingDetail } from '../types';
|
||||
import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type DebugImageSearchResultItem, type FixPaintingImageResult } from '../api/client';
|
||||
import DebugSearchResultsModal from './DebugSearchResultsModal';
|
||||
import DebugUploadButton from './DebugUploadButton';
|
||||
import GalleryLoadingMarker from './GalleryLoadingMarker';
|
||||
import PaintingAnnotationsPanel, { PaintingAnnotationMarkers } from './PaintingAnnotations';
|
||||
import PaintingLightbox from './PaintingLightbox';
|
||||
import './PaintingDetail.css';
|
||||
import './GalleryLoadingMarker.css';
|
||||
|
||||
interface Props {
|
||||
data: PaintingDetail;
|
||||
artistPaintings?: Painting[];
|
||||
backLabel?: string;
|
||||
tourTitle?: string | null;
|
||||
tourText?: string | null;
|
||||
onBack: () => void;
|
||||
onPaintingClick: (paintingId: number) => void;
|
||||
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?: (
|
||||
@@ -25,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 {
|
||||
@@ -188,18 +199,31 @@ function InfluenceCard({
|
||||
export default function PaintingDetailView({
|
||||
data,
|
||||
artistPaintings = [],
|
||||
backLabel = '← Back to Gallery',
|
||||
tourTitle = null,
|
||||
tourText = null,
|
||||
onBack,
|
||||
onPaintingClick,
|
||||
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);
|
||||
@@ -214,15 +238,13 @@ export default function PaintingDetailView({
|
||||
const [applyingUrl, setApplyingUrl] = useState<string | null>(null);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadStatus, setUploadStatus] = useState<string | null>(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
const [activeAnnotationId, setActiveAnnotationId] = useState<number | null>(null);
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const baseImageUrl = paintingImageUrl(painting);
|
||||
const imageSrc = baseImageUrl
|
||||
? `${baseImageUrl}${baseImageUrl.includes('?') ? '&' : '?'}v=${imageVersion}`
|
||||
: null;
|
||||
const imageCleared = !baseImageUrl && !!painting.checkup_fixed;
|
||||
const imageSrc = paintingImageUrl(painting, imageVersion || undefined);
|
||||
const displayImageSrc = uploading ? null : imageSrc;
|
||||
const imageCleared = !painting.image_path && !painting.thumbnail_path && !!painting.checkup_fixed;
|
||||
|
||||
const catalogIndex = artistPaintings.findIndex((p) => p.id === painting.id);
|
||||
const previousPainting = catalogIndex > 0 ? artistPaintings[catalogIndex - 1] : null;
|
||||
@@ -243,10 +265,17 @@ export default function PaintingDetailView({
|
||||
setFixing(false);
|
||||
setClearing(false);
|
||||
setUploading(false);
|
||||
setUploadStatus(null);
|
||||
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) {
|
||||
@@ -255,6 +284,9 @@ export default function PaintingDetailView({
|
||||
setMoreOpen(false);
|
||||
return;
|
||||
}
|
||||
if (uploading) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setDebugLoading(true);
|
||||
@@ -275,7 +307,35 @@ export default function PaintingDetailView({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [debugMode, painting.id, painting.title, painting.artist_name]);
|
||||
}, [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);
|
||||
@@ -382,20 +442,27 @@ export default function PaintingDetailView({
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
uploadInputRef.current?.click();
|
||||
const handleUploadPress = () => {
|
||||
setDebugSearch(null);
|
||||
setDebugLoading(false);
|
||||
setMoreOpen(false);
|
||||
setMoreResults(null);
|
||||
setMoreError(null);
|
||||
setDebugError(null);
|
||||
};
|
||||
|
||||
const handleUploadFile = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file || uploading || fixing || clearing) return;
|
||||
const handleUploadFile = async (file: File) => {
|
||||
if (uploading || fixing || clearing) return;
|
||||
handleUploadPress();
|
||||
setUploading(true);
|
||||
setDebugError(null);
|
||||
setUploadStatus('Reading file…');
|
||||
try {
|
||||
setUploadStatus('Uploading…');
|
||||
const result = await api.uploadPaintingImage(painting.id, file);
|
||||
await applyImageUpdate(result);
|
||||
setUploadStatus('Upload complete.');
|
||||
} catch (err) {
|
||||
setUploadStatus(null);
|
||||
setDebugError(err instanceof Error ? err.message : 'Could not upload image.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
@@ -437,8 +504,15 @@ export default function PaintingDetailView({
|
||||
|
||||
return (
|
||||
<div className={`painting-detail${fullscreen ? ' painting-detail-fullscreen-active' : ''}`}>
|
||||
{uploading && (
|
||||
<GalleryLoadingMarker
|
||||
overlay
|
||||
className="debug-upload-page-overlay"
|
||||
message={uploadStatus ?? 'Loading…'}
|
||||
/>
|
||||
)}
|
||||
<header className="painting-header">
|
||||
<button className="back-btn" onClick={onBack}>← Back to Gallery</button>
|
||||
<button className="back-btn" onClick={onBack}>{backLabel}</button>
|
||||
<div className="painting-title-block">
|
||||
<h1>{painting.title}</h1>
|
||||
<p className="painting-meta">
|
||||
@@ -447,21 +521,23 @@ export default function PaintingDetailView({
|
||||
{showCatalogNav && (
|
||||
<span className="painting-catalog-position">
|
||||
{' · '}
|
||||
{catalogIndex + 1} of {artistPaintings.length}
|
||||
{inTour
|
||||
? t('tourStopPosition', { current: catalogIndex + 1, total: artistPaintings.length })
|
||||
: `${catalogIndex + 1} of ${artistPaintings.length}`}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button className="bio-btn" onClick={onArtistBio}>
|
||||
About {painting.artist_name}
|
||||
{t('aboutArtist', { name: painting.artist_name })}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="painting-layout">
|
||||
<aside className="influence-panel influence-left">
|
||||
<h3>Influenced By</h3>
|
||||
<h3>{t('influencedBy')}</h3>
|
||||
{influencedBy.length === 0 ? (
|
||||
<p className="no-influences">No documented influences for this work.</p>
|
||||
<p className="no-influences">{t('noInfluences')}</p>
|
||||
) : (
|
||||
<div className="influence-list">
|
||||
{influencedBy.map((inf, index) => (
|
||||
@@ -494,12 +570,12 @@ export default function PaintingDetailView({
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`painting-frame-large${imageSrc ? ' painting-frame-clickable' : ' painting-frame-empty'}${imageCleared ? ' painting-frame-cleared' : ''}`}
|
||||
role={imageSrc ? 'button' : undefined}
|
||||
tabIndex={imageSrc ? 0 : undefined}
|
||||
onClick={imageSrc ? () => setFullscreen(true) : undefined}
|
||||
className={`painting-frame-large${displayImageSrc ? ' painting-frame-clickable' : ' painting-frame-empty'}${imageCleared && !uploading ? ' painting-frame-cleared' : ''}${uploading ? ' painting-frame-uploading' : ''}`}
|
||||
role={displayImageSrc ? 'button' : undefined}
|
||||
tabIndex={displayImageSrc ? 0 : undefined}
|
||||
onClick={displayImageSrc ? () => setFullscreen(true) : undefined}
|
||||
onKeyDown={
|
||||
imageSrc
|
||||
displayImageSrc
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
@@ -508,14 +584,14 @@ export default function PaintingDetailView({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
title={imageSrc ? 'View full screen' : undefined}
|
||||
aria-label={imageSrc ? `View ${painting.title} full screen` : `${painting.title} (no image)`}
|
||||
title={displayImageSrc ? 'View full screen' : undefined}
|
||||
aria-label={displayImageSrc ? `View ${painting.title} full screen` : `${painting.title} (no image)`}
|
||||
>
|
||||
<div className="painting-frame-image-wrap">
|
||||
{imageSrc ? (
|
||||
<img src={imageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
|
||||
{displayImageSrc ? (
|
||||
<img src={displayImageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
|
||||
) : null}
|
||||
{imageSrc && annotations.length > 0 && (
|
||||
{displayImageSrc && annotations.length > 0 && (
|
||||
<PaintingAnnotationMarkers
|
||||
annotations={annotations}
|
||||
activeId={activeAnnotationId}
|
||||
@@ -546,6 +622,67 @@ export default function PaintingDetailView({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{inTour && (
|
||||
<aside className="tour-stop-panel" aria-label={t('tourNotes')}>
|
||||
<h3>{tourTitle ? t('tourNotesFor', { title: tourTitle }) : t('tourNotes')}</h3>
|
||||
{tourText.trim() ? (
|
||||
<p className="tour-stop-body">{tourText}</p>
|
||||
) : (
|
||||
<p className="tour-stop-empty">{t('tourNotesEmpty')}</p>
|
||||
)}
|
||||
</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>
|
||||
@@ -554,9 +691,9 @@ export default function PaintingDetailView({
|
||||
</main>
|
||||
|
||||
<aside className="influence-panel influence-right">
|
||||
<h3>Influenced</h3>
|
||||
<h3>{t('influenced')}</h3>
|
||||
{influenced.length === 0 ? (
|
||||
<p className="no-influences">No documented works influenced by this painting yet.</p>
|
||||
<p className="no-influences">{t('noInfluencedWorks')}</p>
|
||||
) : (
|
||||
<div className="influence-list">
|
||||
{influenced.map((inf, index) => (
|
||||
@@ -573,14 +710,17 @@ export default function PaintingDetailView({
|
||||
</div>
|
||||
|
||||
{debugMode && (
|
||||
<aside className="debug-image-panel" aria-label="Debug image search">
|
||||
<aside
|
||||
className={`debug-image-panel${uploading ? ' debug-image-panel-blocked' : ''}`}
|
||||
aria-label="Debug image search"
|
||||
>
|
||||
<h4>{debugSearch?.sourceLabel ?? 'Google image search'}</h4>
|
||||
<p className="debug-image-query">
|
||||
{debugSearch?.query ?? `${painting.artist_name} ${painting.title} painting`}
|
||||
</p>
|
||||
{debugLoading && <p className="debug-image-status">Searching…</p>}
|
||||
{debugError && <p className="debug-image-error">{debugError}</p>}
|
||||
{!debugLoading && debugSearch?.imageUrl && (
|
||||
{debugLoading && !uploading && <p className="debug-image-status">Searching…</p>}
|
||||
{debugError && !uploading && <p className="debug-image-error">{debugError}</p>}
|
||||
{!uploading && !debugLoading && debugSearch?.imageUrl && (
|
||||
<img
|
||||
className="debug-image-preview"
|
||||
src={debugImageProxyUrl(debugSearch.imageUrl, {
|
||||
@@ -593,23 +733,25 @@ export default function PaintingDetailView({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
|
||||
{!uploading && !debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
|
||||
<p className="debug-image-status">No Google image result found.</p>
|
||||
)}
|
||||
<div className="debug-action-buttons">
|
||||
<button
|
||||
type="button"
|
||||
className="debug-checked-btn"
|
||||
onClick={handleMarkChecked}
|
||||
disabled={!!painting.checkup_checked || markingChecked}
|
||||
>
|
||||
{markingChecked ? '…' : 'Checked'}
|
||||
</button>
|
||||
{canCheckup && (
|
||||
<button
|
||||
type="button"
|
||||
className="debug-checked-btn"
|
||||
onClick={handleMarkChecked}
|
||||
disabled={!!painting.checkup_checked || markingChecked || uploading}
|
||||
>
|
||||
{markingChecked ? '…' : 'Checked'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="debug-fix-btn"
|
||||
onClick={handleFixImage}
|
||||
disabled={fixing || debugLoading || !debugSearch?.imageUrl}
|
||||
disabled={fixing || debugLoading || uploading || !debugSearch?.imageUrl}
|
||||
>
|
||||
{fixing ? '…' : 'Fix it'}
|
||||
</button>
|
||||
@@ -617,7 +759,7 @@ export default function PaintingDetailView({
|
||||
type="button"
|
||||
className="debug-more-btn"
|
||||
onClick={handleOpenMore}
|
||||
disabled={debugLoading || moreLoading}
|
||||
disabled={debugLoading || moreLoading || uploading}
|
||||
>
|
||||
{moreLoading ? '…' : 'More'}
|
||||
</button>
|
||||
@@ -631,20 +773,11 @@ export default function PaintingDetailView({
|
||||
>
|
||||
{clearing ? '…' : 'Clear'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="debug-upload-btn"
|
||||
onClick={handleUploadClick}
|
||||
disabled={uploading || fixing || clearing}
|
||||
>
|
||||
{uploading ? '…' : 'Upload'}
|
||||
</button>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={handleUploadFile}
|
||||
<DebugUploadButton
|
||||
uploading={uploading}
|
||||
disabled={fixing || clearing}
|
||||
onUploadPress={handleUploadPress}
|
||||
onFileSelected={handleUploadFile}
|
||||
/>
|
||||
</div>
|
||||
<div className="debug-action-buttons debug-action-buttons-danger">
|
||||
@@ -661,7 +794,7 @@ export default function PaintingDetailView({
|
||||
)}
|
||||
|
||||
<DebugSearchResultsModal
|
||||
open={moreOpen}
|
||||
open={moreOpen && !uploading}
|
||||
title="Choose painting image"
|
||||
data={moreResults}
|
||||
loading={moreLoading}
|
||||
@@ -671,9 +804,9 @@ export default function PaintingDetailView({
|
||||
onSelect={handleSelectMoreResult}
|
||||
/>
|
||||
|
||||
{fullscreen && imageSrc && (
|
||||
{fullscreen && displayImageSrc && (
|
||||
<PaintingLightbox
|
||||
src={imageSrc}
|
||||
src={displayImageSrc}
|
||||
alt={painting.title}
|
||||
title={painting.title}
|
||||
subtitle={[painting.artist_name, painting.year ? String(painting.year) : '']
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { HistoricalEra } from '../types';
|
||||
import {
|
||||
HISTORICAL_EVENTS,
|
||||
@@ -38,6 +39,7 @@ function formatYear(year: number): string {
|
||||
}
|
||||
|
||||
export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absoluteMin, absoluteMax, lifespanHighlight }: Props) {
|
||||
const { t } = useTranslation('home');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dragging, setDragging] = useState<'left' | 'right' | 'pan' | null>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(800);
|
||||
@@ -365,7 +367,7 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
|
||||
</div>
|
||||
|
||||
<p className="timeline-hint">
|
||||
Click an era or event to zoom · Scroll to zoom · Drag to pan
|
||||
{t('captionClassicTimeline')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
.tours-popup-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;
|
||||
}
|
||||
|
||||
.tours-popup-modal {
|
||||
width: min(100%, 520px);
|
||||
max-height: min(85vh, 640px);
|
||||
overflow: auto;
|
||||
padding: 20px 22px 24px;
|
||||
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);
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.tours-popup-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tours-popup-header h2 {
|
||||
margin: 0;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 1.25rem;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.tours-popup-close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(201, 169, 110, 0.85);
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.tours-popup-hint {
|
||||
margin: 0 0 14px;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(201, 169, 110, 0.7);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.tours-popup-muted {
|
||||
margin: 0;
|
||||
color: rgba(201, 169, 110, 0.55);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tours-popup-error {
|
||||
margin: 0 0 10px;
|
||||
color: #ffaaaa;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tours-popup-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tours-popup-card {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.28);
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tours-popup-card:hover {
|
||||
border-color: rgba(201, 169, 110, 0.55);
|
||||
background: rgba(201, 169, 110, 0.08);
|
||||
}
|
||||
|
||||
.tours-popup-cover {
|
||||
flex: 0 0 72px;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.tours-popup-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tours-popup-cover-empty {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, rgba(201, 169, 110, 0.15), rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
|
||||
.tours-popup-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tours-popup-body strong {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tours-popup-body p {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
color: rgba(232, 213, 181, 0.75);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tours-popup-meta {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(201, 169, 110, 0.65);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api, imageUrl } from '../api/client';
|
||||
import type { TourSummary } from '../types';
|
||||
import './ToursPopup.css';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelectTour: (tourId: number) => void;
|
||||
}
|
||||
|
||||
export default function ToursPopup({ open, onClose, onSelectTour }: Props) {
|
||||
const { t } = useTranslation('tours');
|
||||
const [tours, setTours] = useState<TourSummary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api
|
||||
.listPublishedTours()
|
||||
.then((data) => {
|
||||
if (!cancelled) setTours(data.tours);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
setTours([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, t]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="tours-popup-backdrop" role="presentation" onClick={onClose}>
|
||||
<div
|
||||
className="tours-popup-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="tours-popup-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="tours-popup-header">
|
||||
<h2 id="tours-popup-title">{t('popupTitle')}</h2>
|
||||
<button type="button" className="tours-popup-close" onClick={onClose} aria-label={t('close')}>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<p className="tours-popup-hint">{t('popupHint')}</p>
|
||||
{loading && <p className="tours-popup-muted">{t('loading')}</p>}
|
||||
{error && <p className="tours-popup-error">{error}</p>}
|
||||
{!loading && !error && tours.length === 0 && (
|
||||
<p className="tours-popup-muted">{t('noPublished')}</p>
|
||||
)}
|
||||
<ul className="tours-popup-list">
|
||||
{tours.map((tour) => {
|
||||
const cover = tour.coverThumbnailPath || tour.coverImagePath;
|
||||
return (
|
||||
<li key={tour.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="tours-popup-card"
|
||||
onClick={() => onSelectTour(tour.id)}
|
||||
>
|
||||
<div className="tours-popup-cover">
|
||||
{cover ? (
|
||||
<img src={imageUrl(cover)} alt="" />
|
||||
) : (
|
||||
<span className="tours-popup-cover-empty" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
<div className="tours-popup-body">
|
||||
<strong>{tour.title}</strong>
|
||||
{tour.description ? <p>{tour.description}</p> : null}
|
||||
<span className="tours-popup-meta">
|
||||
{t('stopCount', { count: tour.stopCount })}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
.vflow {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 12px 12px;
|
||||
}
|
||||
|
||||
.vflow-caption {
|
||||
margin: 0 0 8px;
|
||||
text-align: center;
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
color: rgba(201, 169, 110, 0.65);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vflow-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
color: rgba(201, 169, 110, 0.6);
|
||||
font-family: 'Georgia', serif;
|
||||
}
|
||||
|
||||
.vflow-canvas {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
background:
|
||||
radial-gradient(ellipse 70% 80% at 50% 0%, rgba(201, 169, 110, 0.07), transparent 70%),
|
||||
rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(201, 169, 110, 0.12);
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.vflow-canvas.vflow-panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.vflow-svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.vflow-stream {
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
opacity: 0.72;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease, filter 0.15s ease;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.vflow-stream-highlighted {
|
||||
opacity: 1;
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
.vflow-branch {
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
opacity: 0.95;
|
||||
pointer-events: none;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.vflow-branch-highlighted {
|
||||
opacity: 1;
|
||||
filter: brightness(1.25) drop-shadow(0 0 4px rgba(255, 230, 180, 0.45));
|
||||
}
|
||||
|
||||
.vflow-label {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
transform: translate(-50%, 50%);
|
||||
margin: 0;
|
||||
padding: 2px 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: rgba(12, 12, 22, 0.72);
|
||||
color: rgba(245, 230, 200, 0.95);
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.vflow-label:hover {
|
||||
background: rgba(30, 28, 40, 0.9);
|
||||
color: #fff6e0;
|
||||
}
|
||||
@@ -0,0 +1,734 @@
|
||||
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ArtMovement } from '../types';
|
||||
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
|
||||
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
||||
import { vividMovementColor } from '../utils/movementColor';
|
||||
import './VerticalMovementBands.css';
|
||||
|
||||
interface Props {
|
||||
movements: ArtMovement[];
|
||||
viewStart: number;
|
||||
viewEnd: number;
|
||||
absoluteMin: number;
|
||||
absoluteMax: number;
|
||||
onViewChange: (start: number, end: number) => void;
|
||||
onMovementClick?: (movementId: number) => void;
|
||||
}
|
||||
|
||||
interface VLayout {
|
||||
movement: ArtMovement;
|
||||
/** Year span as % from bottom (earlier = lower). */
|
||||
yStart: number;
|
||||
yEnd: number;
|
||||
/** Lane center as % from left. */
|
||||
x: number;
|
||||
strokePx: number;
|
||||
displayColor: string;
|
||||
parentIds: number[];
|
||||
}
|
||||
|
||||
interface VBranch {
|
||||
key: string;
|
||||
d: string;
|
||||
colorFrom: string;
|
||||
colorTo: string;
|
||||
strokePx: number;
|
||||
fromId: number;
|
||||
toId: number;
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
const MAX_STROKE = 108; // ~300% of prior max (36)
|
||||
const MIN_STROKE = 54; // ~300% of prior min (18)
|
||||
const SIDE_PAD = 24;
|
||||
/** Preferred centre-to-centre spacing: stroke + small gap (keeps columns tight). */
|
||||
const PREFERRED_LANE_PITCH = MAX_STROKE + 16;
|
||||
const LANE_MIN_GAP_YEARS = 2;
|
||||
|
||||
function yearToBottomPercent(year: number, start: number, end: number): number {
|
||||
return ((year - start) / (end - start)) * 100;
|
||||
}
|
||||
|
||||
function buildLineageParentMap(
|
||||
visible: ArtMovement[],
|
||||
nameToId: Map<string, number>
|
||||
): Map<number, number[]> {
|
||||
const parents = new Map<number, number[]>();
|
||||
const visibleIds = new Set(visible.map((m) => m.id));
|
||||
for (const [parentName, childName] of MOVEMENT_LINEAGE) {
|
||||
const parentId = nameToId.get(parentName);
|
||||
const childId = nameToId.get(childName);
|
||||
if (parentId == null || childId == null) continue;
|
||||
if (!visibleIds.has(parentId) || !visibleIds.has(childId)) continue;
|
||||
const list = parents.get(childId) || [];
|
||||
if (!list.includes(parentId)) list.push(parentId);
|
||||
parents.set(childId, list);
|
||||
}
|
||||
return parents;
|
||||
}
|
||||
|
||||
function assignDepths(
|
||||
group: ArtMovement[],
|
||||
lineageParents: Map<number, number[]>
|
||||
): Map<number, number> {
|
||||
const depths = new Map<number, number>();
|
||||
const visiting = new Set<number>();
|
||||
const visit = (id: number): number => {
|
||||
if (depths.has(id)) return depths.get(id)!;
|
||||
if (visiting.has(id)) return 0;
|
||||
visiting.add(id);
|
||||
const parents = lineageParents.get(id) || [];
|
||||
const d = parents.length ? 1 + Math.max(...parents.map(visit)) : 0;
|
||||
visiting.delete(id);
|
||||
depths.set(id, d);
|
||||
return d;
|
||||
};
|
||||
for (const m of group) visit(m.id);
|
||||
return depths;
|
||||
}
|
||||
|
||||
/** Prefer center, then alternate right / left: 0, +1, -1, +2, -2, … */
|
||||
function centerOutOffsets(max = 64): number[] {
|
||||
const out = [0];
|
||||
for (let d = 1; d <= max; d++) out.push(d, -d);
|
||||
return out;
|
||||
}
|
||||
|
||||
function pickNearestFreeLane(preferred: number, isFree: (lane: number) => boolean): number {
|
||||
for (const delta of centerOutOffsets()) {
|
||||
const lane = preferred + delta;
|
||||
if (isFree(lane)) return lane;
|
||||
}
|
||||
return preferred;
|
||||
}
|
||||
|
||||
/** Collapse signed lane indices to contiguous 0..n-1 (left → right). */
|
||||
function compactSignedLanes(laneById: Map<number, number>): void {
|
||||
const usedSorted = [...new Set(laneById.values())].sort((a, b) => a - b);
|
||||
const remap = new Map(usedSorted.map((lane, index) => [lane, index]));
|
||||
for (const [id, lane] of laneById) {
|
||||
laneById.set(id, remap.get(lane) ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
function assignTemporalLanes(
|
||||
group: ArtMovement[],
|
||||
viewStart: number,
|
||||
viewEnd: number,
|
||||
lineageParents: Map<number, number[]>
|
||||
): Map<number, number> {
|
||||
const depths = assignDepths(group, lineageParents);
|
||||
const spans = group
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
start: Math.max(m.start_year, viewStart),
|
||||
end: Math.min(m.end_year, viewEnd),
|
||||
depth: depths.get(m.id) ?? 0,
|
||||
}))
|
||||
.filter((s) => s.end > s.start)
|
||||
.sort((a, b) => a.depth - b.depth || a.start - b.start || a.end - b.end);
|
||||
|
||||
/** Signed lane → year when that lane frees up. */
|
||||
const laneEnds = new Map<number, number>();
|
||||
const laneById = new Map<number, number>();
|
||||
|
||||
for (const span of spans) {
|
||||
const parentLanes = (lineageParents.get(span.id) || [])
|
||||
.map((pid) => laneById.get(pid))
|
||||
.filter((lane): lane is number => lane != null);
|
||||
const preferred =
|
||||
parentLanes.length > 0
|
||||
? Math.round(parentLanes.reduce((s, l) => s + l, 0) / parentLanes.length)
|
||||
: 0;
|
||||
|
||||
const lane = pickNearestFreeLane(preferred, (candidate) => {
|
||||
const end = laneEnds.get(candidate);
|
||||
return end == null || end + LANE_MIN_GAP_YEARS <= span.start;
|
||||
});
|
||||
laneEnds.set(lane, span.end);
|
||||
laneById.set(span.id, lane);
|
||||
}
|
||||
compactSignedLanes(laneById);
|
||||
return laneById;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer one column per movement when the canvas is wide enough, so streams
|
||||
* do not stack in the same vertical lane. Fall back to temporal packing only
|
||||
* when there is not enough horizontal room.
|
||||
*
|
||||
* Lanes grow from the center outward (0, +1, −1, …) so the layout reads as a tree.
|
||||
*/
|
||||
function assignVerticalLanes(
|
||||
group: ArtMovement[],
|
||||
viewStart: number,
|
||||
viewEnd: number,
|
||||
lineageParents: Map<number, number[]>,
|
||||
canvasWidth: number
|
||||
): Map<number, number> {
|
||||
const usable = Math.max(200, canvasWidth - SIDE_PAD * 2);
|
||||
const minLanePx = PREFERRED_LANE_PITCH;
|
||||
const maxExclusive = Math.max(1, Math.floor(usable / minLanePx));
|
||||
|
||||
if (group.length <= maxExclusive) {
|
||||
const depths = assignDepths(group, lineageParents);
|
||||
// Roots first so the trunk claims center; children then fan around parents.
|
||||
const sorted = [...group].sort(
|
||||
(a, b) =>
|
||||
(depths.get(a.id) ?? 0) - (depths.get(b.id) ?? 0) ||
|
||||
a.start_year - b.start_year ||
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
const laneById = new Map<number, number>();
|
||||
const used = new Set<number>();
|
||||
for (const m of sorted) {
|
||||
const parentLanes = (lineageParents.get(m.id) || [])
|
||||
.map((pid) => laneById.get(pid))
|
||||
.filter((lane): lane is number => lane != null);
|
||||
const preferred =
|
||||
parentLanes.length > 0
|
||||
? Math.round(parentLanes.reduce((s, l) => s + l, 0) / parentLanes.length)
|
||||
: 0;
|
||||
const lane = pickNearestFreeLane(preferred, (candidate) => !used.has(candidate));
|
||||
used.add(lane);
|
||||
laneById.set(m.id, lane);
|
||||
}
|
||||
compactSignedLanes(laneById);
|
||||
return laneById;
|
||||
}
|
||||
|
||||
return assignTemporalLanes(group, viewStart, viewEnd, lineageParents);
|
||||
}
|
||||
|
||||
function influenceCount(m: ArtMovement): number {
|
||||
const n = m.influence_link_count;
|
||||
return typeof n === 'number' && Number.isFinite(n) ? Math.max(0, n) : 0;
|
||||
}
|
||||
|
||||
function pctToSvg(xPct: number, yBottomPct: number, widthPx: number, heightPx: number) {
|
||||
return {
|
||||
x: (xPct / 100) * widthPx,
|
||||
y: ((100 - yBottomPct) / 100) * heightPx,
|
||||
};
|
||||
}
|
||||
|
||||
/** Vertical stream path: time along Y (SVG y grows down → invert bottom%). */
|
||||
function verticalStreamPath(
|
||||
xPct: number,
|
||||
yStartPct: number,
|
||||
yEndPct: number,
|
||||
heightPx: number,
|
||||
widthPx: number
|
||||
): string {
|
||||
const start = pctToSvg(xPct, yEndPct, widthPx, heightPx);
|
||||
const end = pctToSvg(xPct, yStartPct, widthPx, heightPx);
|
||||
const midY = (start.y + end.y) / 2;
|
||||
const bulge = Math.min(18, Math.abs(end.y - start.y) * 0.06);
|
||||
return `M ${start.x} ${start.y} C ${start.x + bulge} ${midY}, ${end.x - bulge} ${midY}, ${end.x} ${end.y}`;
|
||||
}
|
||||
|
||||
/** Absolute-year anchors so pan/scroll keeps a constant connection angle. */
|
||||
function branchAnchorYears(
|
||||
parent: ArtMovement,
|
||||
child: ArtMovement,
|
||||
childIndex: number,
|
||||
childCount: number
|
||||
): { originYear: number; targetYear: number } | null {
|
||||
const parentSpan = parent.end_year - parent.start_year;
|
||||
if (parentSpan <= 0) return null;
|
||||
|
||||
const tBase = childCount === 1 ? 0.38 : 0.28 + (childIndex / Math.max(1, childCount - 1)) * 0.22;
|
||||
let originYear = parent.start_year + parentSpan * tBase;
|
||||
const targetYear = child.start_year;
|
||||
|
||||
if (originYear >= targetYear) {
|
||||
originYear = Math.min(parent.start_year + parentSpan * 0.2, targetYear - 1);
|
||||
}
|
||||
originYear = Math.max(parent.start_year, Math.min(parent.end_year, originYear));
|
||||
if (originYear >= targetYear) return null;
|
||||
|
||||
return { originYear, targetYear };
|
||||
}
|
||||
|
||||
function branchPath(x1: number, y1: number, x2: number, y2: number): string {
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
// Pull control points along the diagonal so the curve reads as a waterfall, not an L-stair.
|
||||
const c1x = x1 + dx * 0.35;
|
||||
const c1y = y1 + dy * 0.55;
|
||||
const c2x = x2 - dx * 0.25;
|
||||
const c2y = y2 - dy * 0.2;
|
||||
return `M ${x1} ${y1} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
/** Exponential chase rate (1/s) so lane / view shifts read as motion, not snaps. */
|
||||
const LAYOUT_ANIM_RATE = 14;
|
||||
const LAYOUT_ANIM_EPS = 0.06;
|
||||
|
||||
interface AnimatedVFlow {
|
||||
layouts: VLayout[];
|
||||
branches: VBranch[];
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
function geomSettled(a: number, b: number, eps = LAYOUT_ANIM_EPS): boolean {
|
||||
return Math.abs(a - b) <= eps;
|
||||
}
|
||||
|
||||
function lerpVLayout(from: VLayout, to: VLayout, t: number): VLayout {
|
||||
return {
|
||||
...to,
|
||||
yStart: lerp(from.yStart, to.yStart, t),
|
||||
yEnd: lerp(from.yEnd, to.yEnd, t),
|
||||
x: lerp(from.x, to.x, t),
|
||||
strokePx: lerp(from.strokePx, to.strokePx, t),
|
||||
};
|
||||
}
|
||||
|
||||
function vLayoutSettled(a: VLayout, b: VLayout): boolean {
|
||||
return (
|
||||
geomSettled(a.yStart, b.yStart) &&
|
||||
geomSettled(a.yEnd, b.yEnd) &&
|
||||
geomSettled(a.x, b.x) &&
|
||||
geomSettled(a.strokePx, b.strokePx, 0.35)
|
||||
);
|
||||
}
|
||||
|
||||
function lerpVBranch(from: VBranch, to: VBranch, t: number): VBranch {
|
||||
const x1 = lerp(from.x1, to.x1, t);
|
||||
const y1 = lerp(from.y1, to.y1, t);
|
||||
const x2 = lerp(from.x2, to.x2, t);
|
||||
const y2 = lerp(from.y2, to.y2, t);
|
||||
const strokePx = lerp(from.strokePx, to.strokePx, t);
|
||||
return {
|
||||
...to,
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
strokePx,
|
||||
d: branchPath(x1, y1, x2, y2),
|
||||
};
|
||||
}
|
||||
|
||||
function vBranchSettled(a: VBranch, b: VBranch): boolean {
|
||||
return (
|
||||
geomSettled(a.x1, b.x1) &&
|
||||
geomSettled(a.y1, b.y1) &&
|
||||
geomSettled(a.x2, b.x2) &&
|
||||
geomSettled(a.y2, b.y2) &&
|
||||
geomSettled(a.strokePx, b.strokePx, 0.35)
|
||||
);
|
||||
}
|
||||
|
||||
function blendVFlow(
|
||||
from: AnimatedVFlow,
|
||||
to: AnimatedVFlow,
|
||||
t: number
|
||||
): { next: AnimatedVFlow; settled: boolean } {
|
||||
const fromLayouts = new Map(from.layouts.map((l) => [l.movement.id, l]));
|
||||
const fromBranches = new Map(from.branches.map((b) => [b.key, b]));
|
||||
let settled = true;
|
||||
|
||||
const layouts = to.layouts.map((target) => {
|
||||
const prev = fromLayouts.get(target.movement.id);
|
||||
if (!prev) return target;
|
||||
if (vLayoutSettled(prev, target)) return target;
|
||||
settled = false;
|
||||
return lerpVLayout(prev, target, t);
|
||||
});
|
||||
|
||||
const branches = to.branches.map((target) => {
|
||||
const prev = fromBranches.get(target.key);
|
||||
if (!prev) return target;
|
||||
if (vBranchSettled(prev, target)) return target;
|
||||
settled = false;
|
||||
return lerpVBranch(prev, target, t);
|
||||
});
|
||||
|
||||
return { next: { layouts, branches }, settled };
|
||||
}
|
||||
|
||||
export default function VerticalMovementBands({
|
||||
movements,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
absoluteMin,
|
||||
absoluteMax,
|
||||
onViewChange,
|
||||
onMovementClick,
|
||||
}: Props) {
|
||||
const { t } = useTranslation('home');
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const [canvasSize, setCanvasSize] = useState({ w: 800, h: 600 });
|
||||
const [panning, setPanning] = useState(false);
|
||||
const [hoveredMovementId, setHoveredMovementId] = useState<number | null>(null);
|
||||
const [flowVisual, setFlowVisual] = useState<AnimatedVFlow | null>(null);
|
||||
const panStart = useRef({ y: 0, viewStart: 0, viewEnd: 0 });
|
||||
const viewRef = useRef({ viewStart, viewEnd });
|
||||
const onViewChangeRef = useRef(onViewChange);
|
||||
const flowVisualRef = useRef<AnimatedVFlow | null>(null);
|
||||
const flowTargetRef = useRef<AnimatedVFlow | null>(null);
|
||||
const flowRafRef = useRef<number | null>(null);
|
||||
const flowLastTsRef = useRef(0);
|
||||
viewRef.current = { viewStart, viewEnd };
|
||||
onViewChangeRef.current = onViewChange;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = canvasRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
setCanvasSize({ w: Math.round(rect.width), h: Math.round(rect.height) });
|
||||
}
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const visibleMovements = useMemo(
|
||||
() => movements.filter((m) => m.end_year > viewStart && m.start_year < viewEnd),
|
||||
[movements, viewStart, viewEnd]
|
||||
);
|
||||
|
||||
const { layouts, branches } = useMemo(() => {
|
||||
if (visibleMovements.length === 0) {
|
||||
return { layouts: [] as VLayout[], branches: [] as VBranch[] };
|
||||
}
|
||||
const nameToId = new Map(movements.map((m) => [m.name, m.id]));
|
||||
const lineageParents = buildLineageParentMap(visibleMovements, nameToId);
|
||||
const laneIndex = assignVerticalLanes(
|
||||
visibleMovements,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
lineageParents,
|
||||
canvasSize.w
|
||||
);
|
||||
|
||||
let maxLanes = 0;
|
||||
for (const m of visibleMovements) {
|
||||
const lane = laneIndex.get(m.id) ?? 0;
|
||||
maxLanes = Math.max(maxLanes, lane + 1);
|
||||
}
|
||||
maxLanes = Math.max(1, maxLanes);
|
||||
|
||||
const usable = Math.max(200, canvasSize.w - SIDE_PAD * 2);
|
||||
// Pack columns tightly; only stretch if the canvas is narrower than the preferred cluster.
|
||||
const lanePitch = Math.min(usable / maxLanes, PREFERRED_LANE_PITCH);
|
||||
const clusterWidth = lanePitch * maxLanes;
|
||||
const startX = SIDE_PAD + Math.max(0, (usable - clusterWidth) / 2);
|
||||
const laneCentersPct: number[] = [];
|
||||
for (let lane = 0; lane < maxLanes; lane++) {
|
||||
const centerPx = startX + lanePitch * lane + lanePitch / 2;
|
||||
laneCentersPct[lane] = (centerPx / Math.max(1, canvasSize.w)) * 100;
|
||||
}
|
||||
|
||||
const maxInf = Math.max(1, ...visibleMovements.map(influenceCount));
|
||||
const layoutById = new Map<number, VLayout>();
|
||||
for (const m of visibleMovements) {
|
||||
const yStart = yearToBottomPercent(Math.max(m.start_year, viewStart), viewStart, viewEnd);
|
||||
const yEnd = yearToBottomPercent(Math.min(m.end_year, viewEnd), viewStart, viewEnd);
|
||||
if (yEnd <= yStart) continue;
|
||||
const lane = laneIndex.get(m.id) ?? 0;
|
||||
const baseStroke =
|
||||
MIN_STROKE + (influenceCount(m) / maxInf) * (MAX_STROKE - MIN_STROKE);
|
||||
// Allow nearly full preferred stroke; only shrink if the pitch is forced smaller.
|
||||
const strokePx = Math.min(MAX_STROKE, Math.max(MIN_STROKE, baseStroke), lanePitch * 0.88);
|
||||
layoutById.set(m.id, {
|
||||
movement: m,
|
||||
yStart,
|
||||
yEnd,
|
||||
x: laneCentersPct[lane] ?? 50,
|
||||
strokePx,
|
||||
displayColor: vividMovementColor(m.color),
|
||||
parentIds: lineageParents.get(m.id) || [],
|
||||
});
|
||||
}
|
||||
|
||||
const childIdsByParent = new Map<number, number[]>();
|
||||
for (const layout of layoutById.values()) {
|
||||
for (const parentId of layout.parentIds) {
|
||||
if (!layoutById.has(parentId)) continue;
|
||||
const children = childIdsByParent.get(parentId) || [];
|
||||
children.push(layout.movement.id);
|
||||
childIdsByParent.set(parentId, children);
|
||||
}
|
||||
}
|
||||
for (const children of childIdsByParent.values()) {
|
||||
children.sort((a, b) => {
|
||||
const la = layoutById.get(a)!;
|
||||
const lb = layoutById.get(b)!;
|
||||
return la.x - lb.x || la.movement.start_year - lb.movement.start_year;
|
||||
});
|
||||
}
|
||||
|
||||
const branchList: VBranch[] = [];
|
||||
for (const layout of layoutById.values()) {
|
||||
for (const parentId of layout.parentIds) {
|
||||
const parent = layoutById.get(parentId);
|
||||
if (!parent) continue;
|
||||
const children = childIdsByParent.get(parentId) || [layout.movement.id];
|
||||
const childIndex = children.indexOf(layout.movement.id);
|
||||
const anchors = branchAnchorYears(
|
||||
parent.movement,
|
||||
layout.movement,
|
||||
childIndex,
|
||||
children.length
|
||||
);
|
||||
if (!anchors) continue;
|
||||
|
||||
// Map fixed calendar years → current view % so pan keeps dx/dy (and angle) stable.
|
||||
const originY = yearToBottomPercent(anchors.originYear, viewStart, viewEnd);
|
||||
const targetY = yearToBottomPercent(anchors.targetYear, viewStart, viewEnd);
|
||||
const from = pctToSvg(parent.x, originY, canvasSize.w, canvasSize.h);
|
||||
const to = pctToSvg(layout.x, targetY, canvasSize.w, canvasSize.h);
|
||||
branchList.push({
|
||||
key: `${parentId}-${layout.movement.id}`,
|
||||
d: branchPath(from.x, from.y, to.x, to.y),
|
||||
colorFrom: parent.displayColor,
|
||||
colorTo: layout.displayColor,
|
||||
strokePx: Math.max(14, Math.min(parent.strokePx, layout.strokePx) * 0.55),
|
||||
fromId: parentId,
|
||||
toId: layout.movement.id,
|
||||
x1: from.x,
|
||||
y1: from.y,
|
||||
x2: to.x,
|
||||
y2: to.y,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
layouts: [...layoutById.values()].sort((a, b) => a.movement.start_year - b.movement.start_year),
|
||||
branches: branchList,
|
||||
};
|
||||
}, [visibleMovements, movements, viewStart, viewEnd, canvasSize.w, canvasSize.h]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const target: AnimatedVFlow = { layouts, branches };
|
||||
flowTargetRef.current = target;
|
||||
|
||||
if (!flowVisualRef.current) {
|
||||
flowVisualRef.current = target;
|
||||
setFlowVisual(target);
|
||||
return;
|
||||
}
|
||||
|
||||
if (flowRafRef.current != null) return;
|
||||
|
||||
flowLastTsRef.current = performance.now();
|
||||
const step = (now: number) => {
|
||||
const prev = flowVisualRef.current;
|
||||
const goal = flowTargetRef.current;
|
||||
if (!prev || !goal) {
|
||||
flowRafRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const dt = Math.min(0.048, Math.max(0, (now - flowLastTsRef.current) / 1000));
|
||||
flowLastTsRef.current = now;
|
||||
const t = 1 - Math.exp(-LAYOUT_ANIM_RATE * dt);
|
||||
const { next, settled } = blendVFlow(prev, goal, t);
|
||||
flowVisualRef.current = settled ? goal : next;
|
||||
setFlowVisual(flowVisualRef.current);
|
||||
|
||||
if (settled) {
|
||||
flowRafRef.current = null;
|
||||
return;
|
||||
}
|
||||
flowRafRef.current = requestAnimationFrame(step);
|
||||
};
|
||||
flowRafRef.current = requestAnimationFrame(step);
|
||||
}, [layouts, branches]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (flowRafRef.current != null) {
|
||||
cancelAnimationFrame(flowRafRef.current);
|
||||
flowRafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = canvasRef.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = el.getBoundingClientRect();
|
||||
const { viewStart: vs, viewEnd: ve } = viewRef.current;
|
||||
const invertedClientY = rect.bottom - (e.clientY - rect.top);
|
||||
const next = zoomTimelineView(
|
||||
invertedClientY,
|
||||
0,
|
||||
rect.height,
|
||||
e.deltaY,
|
||||
vs,
|
||||
ve,
|
||||
absoluteMin,
|
||||
absoluteMax
|
||||
);
|
||||
onViewChangeRef.current(next.start, next.end);
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false, capture: true });
|
||||
return () => el.removeEventListener('wheel', onWheel, { capture: true });
|
||||
}, [absoluteMin, absoluteMax]);
|
||||
|
||||
const handlePanStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
setPanning(true);
|
||||
panStart.current = { y: e.clientY, viewStart, viewEnd };
|
||||
},
|
||||
[viewStart, viewEnd]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!panning) return;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const dy = e.clientY - panStart.current.y;
|
||||
const next = panTimelineView(
|
||||
-dy,
|
||||
rect.height,
|
||||
panStart.current.viewStart,
|
||||
panStart.current.viewEnd,
|
||||
absoluteMin,
|
||||
absoluteMax
|
||||
);
|
||||
onViewChangeRef.current(next.start, next.end);
|
||||
};
|
||||
const onUp = () => setPanning(false);
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
}, [panning, absoluteMin, absoluteMax]);
|
||||
|
||||
if (visibleMovements.length === 0) {
|
||||
return (
|
||||
<div className="vflow-empty">
|
||||
<p>No art movements in this time range. Zoom out to explore more periods.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const displayLayouts = flowVisual?.layouts ?? layouts;
|
||||
const displayBranches = flowVisual?.branches ?? branches;
|
||||
|
||||
return (
|
||||
<div className="vflow">
|
||||
<p className="vflow-caption">
|
||||
{t('captionVerticalFlow')}
|
||||
</p>
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className={`vflow-canvas${panning ? ' vflow-panning' : ''}`}
|
||||
onMouseDown={handlePanStart}
|
||||
>
|
||||
<svg
|
||||
className="vflow-svg"
|
||||
viewBox={`0 0 ${canvasSize.w} ${canvasSize.h}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
{displayBranches.map((branch) => (
|
||||
<linearGradient
|
||||
key={`grad-${branch.key}`}
|
||||
id={`vflow-branch-grad-${branch.key}`}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1={branch.x1}
|
||||
y1={branch.y1}
|
||||
x2={branch.x2}
|
||||
y2={branch.y2}
|
||||
>
|
||||
<stop offset="0%" stopColor={branch.colorFrom} stopOpacity={0.9} />
|
||||
<stop offset="100%" stopColor={branch.colorTo} stopOpacity={0.9} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
|
||||
{displayLayouts.map((layout) => {
|
||||
const d = verticalStreamPath(
|
||||
layout.x,
|
||||
layout.yStart,
|
||||
layout.yEnd,
|
||||
canvasSize.h,
|
||||
canvasSize.w
|
||||
);
|
||||
const highlighted = hoveredMovementId === layout.movement.id;
|
||||
return (
|
||||
<path
|
||||
key={layout.movement.id}
|
||||
d={d}
|
||||
className={`vflow-stream${highlighted ? ' vflow-stream-highlighted' : ''}`}
|
||||
stroke={layout.displayColor}
|
||||
fill="none"
|
||||
style={{ strokeWidth: layout.strokePx }}
|
||||
onMouseEnter={() => setHoveredMovementId(layout.movement.id)}
|
||||
onMouseLeave={() => setHoveredMovementId(null)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMovementClick?.(layout.movement.id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{displayBranches.map((branch) => {
|
||||
const highlighted =
|
||||
hoveredMovementId != null &&
|
||||
(branch.fromId === hoveredMovementId || branch.toId === hoveredMovementId);
|
||||
return (
|
||||
<path
|
||||
key={branch.key}
|
||||
d={branch.d}
|
||||
className={`vflow-branch${highlighted ? ' vflow-branch-highlighted' : ''}`}
|
||||
stroke={`url(#vflow-branch-grad-${branch.key})`}
|
||||
fill="none"
|
||||
style={{ strokeWidth: branch.strokePx }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{displayLayouts.map((layout) => {
|
||||
const midY = (layout.yStart + layout.yEnd) / 2;
|
||||
return (
|
||||
<button
|
||||
key={`label-${layout.movement.id}`}
|
||||
type="button"
|
||||
className="vflow-label"
|
||||
style={{
|
||||
left: `${layout.x}%`,
|
||||
bottom: `${midY}%`,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMovementClick?.(layout.movement.id);
|
||||
}}
|
||||
onMouseEnter={() => setHoveredMovementId(layout.movement.id)}
|
||||
onMouseLeave={() => setHoveredMovementId(null)}
|
||||
>
|
||||
{layout.movement.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
.vtimeline-wrapper {
|
||||
flex-shrink: 0;
|
||||
width: 148px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(90deg, #1a1a2e 0%, #16213e 100%);
|
||||
border-right: 2px solid #c9a96e;
|
||||
padding: 8px 8px 12px;
|
||||
z-index: 100;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.vtimeline-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vtimeline-controls button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid #c9a96e;
|
||||
background: rgba(201, 169, 110, 0.15);
|
||||
color: #e8d5b5;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.vtimeline-controls button:hover {
|
||||
background: rgba(201, 169, 110, 0.35);
|
||||
}
|
||||
|
||||
.vtimeline-range {
|
||||
width: 100%;
|
||||
color: #f5e6c8;
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.vtimeline-container {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 120px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
border: 1px solid rgba(201, 169, 110, 0.3);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vtimeline-container:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.vtimeline-track {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.vtimeline-era-block {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 28px;
|
||||
margin: 0;
|
||||
padding: 4px 2px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.vtimeline-era-label {
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 245, 220, 0.92);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7);
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vtimeline-lifespan-overlays {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vtimeline-lifespan-dim {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.vtimeline-lifespan-highlight {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.05) 0%,
|
||||
rgba(255, 255, 255, 0.28) 50%,
|
||||
rgba(255, 255, 255, 0.05) 100%
|
||||
);
|
||||
box-shadow: inset 0 0 0 2px rgba(255, 240, 200, 0.5);
|
||||
border-top: 2px solid rgba(255, 230, 180, 0.75);
|
||||
border-bottom: 2px solid rgba(255, 230, 180, 0.75);
|
||||
}
|
||||
|
||||
.vtimeline-events {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vtimeline-event-mark,
|
||||
.vtimeline-event-span {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
right: 30px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: rgba(232, 196, 120, 0.55);
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vtimeline-event-mark {
|
||||
height: 3px;
|
||||
transform: translateY(50%);
|
||||
}
|
||||
|
||||
.vtimeline-event-span {
|
||||
min-height: 4px;
|
||||
background: rgba(232, 196, 120, 0.28);
|
||||
border-left: 2px solid rgba(232, 196, 120, 0.7);
|
||||
}
|
||||
|
||||
.vtimeline-event-label {
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
margin-left: 4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
writing-mode: horizontal-tb;
|
||||
font-size: 9px;
|
||||
color: rgba(245, 230, 200, 0.85);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vtimeline-ticks {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vtimeline-tick {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: auto;
|
||||
width: 26px;
|
||||
transform: translateY(50%);
|
||||
border-bottom: 1px solid rgba(201, 169, 110, 0.35);
|
||||
text-align: right;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.vtimeline-tick span {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: rgba(232, 213, 181, 0.85);
|
||||
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||
line-height: 1;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
.vtimeline-brush {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 10px;
|
||||
z-index: 8;
|
||||
cursor: ns-resize;
|
||||
background: rgba(201, 169, 110, 0.25);
|
||||
}
|
||||
|
||||
.vtimeline-brush-start {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.vtimeline-brush-end {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.vtimeline-hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: 10px;
|
||||
line-height: 1.3;
|
||||
color: rgba(201, 169, 110, 0.55);
|
||||
font-family: 'Georgia', serif;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.vtimeline-wrapper {
|
||||
width: 112px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { HistoricalEra } from '../types';
|
||||
import {
|
||||
HISTORICAL_EVENTS,
|
||||
eventEndYear,
|
||||
eventInView,
|
||||
type HistoricalEvent,
|
||||
} from '../data/historical-events';
|
||||
import {
|
||||
buildTimelineTickYears,
|
||||
chooseTimelineTickInterval,
|
||||
} from '../utils/timelineView';
|
||||
import './VerticalTimeline.css';
|
||||
|
||||
interface LifespanHighlight {
|
||||
birthYear: number;
|
||||
deathYear: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
eras: HistoricalEra[];
|
||||
viewStart: number;
|
||||
viewEnd: number;
|
||||
onViewChange: (start: number, end: number) => void;
|
||||
absoluteMin: number;
|
||||
absoluteMax: number;
|
||||
lifespanHighlight?: LifespanHighlight | null;
|
||||
}
|
||||
|
||||
/** Earlier years at the bottom (0%), later at the top (100%). */
|
||||
function yearToBottomPercent(year: number, start: number, end: number): number {
|
||||
return ((year - start) / (end - start)) * 100;
|
||||
}
|
||||
|
||||
function formatYear(year: number): string {
|
||||
if (year < 0) return `${Math.abs(year)} BCE`;
|
||||
return `${year} CE`;
|
||||
}
|
||||
|
||||
function getEraColor(name: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
Ancient: 'rgba(139,115,85,0.7)',
|
||||
Medieval: 'rgba(74,85,104,0.7)',
|
||||
Renaissance: 'rgba(184,134,11,0.7)',
|
||||
Baroque: 'rgba(139,0,0,0.6)',
|
||||
'Neoclassicism & Romanticism': 'rgba(70,130,180,0.6)',
|
||||
Modern: 'rgba(100,100,120,0.6)',
|
||||
Contemporary: 'rgba(60,60,80,0.7)',
|
||||
};
|
||||
return colors[name] || 'rgba(100,100,100,0.5)';
|
||||
}
|
||||
|
||||
export default function VerticalTimeline({
|
||||
eras,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
onViewChange,
|
||||
absoluteMin,
|
||||
absoluteMax,
|
||||
lifespanHighlight,
|
||||
}: Props) {
|
||||
const { t } = useTranslation('home');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dragging, setDragging] = useState<'start' | 'end' | 'pan' | null>(null);
|
||||
const [containerHeight, setContainerHeight] = useState(600);
|
||||
const dragStart = useRef({ y: 0, viewStart: 0, viewEnd: 0 });
|
||||
|
||||
const span = viewEnd - viewStart;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
const h = el.getBoundingClientRect().height;
|
||||
if (h > 0) setContainerHeight(Math.round(h));
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(() => measure());
|
||||
ro.observe(el);
|
||||
window.addEventListener('resize', measure);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener('resize', measure);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const tickInterval = useMemo(
|
||||
() => chooseTimelineTickInterval(span, containerHeight, 56),
|
||||
[span, containerHeight]
|
||||
);
|
||||
|
||||
const ticks = useMemo(
|
||||
() => buildTimelineTickYears(viewStart, viewEnd, tickInterval),
|
||||
[viewStart, viewEnd, tickInterval]
|
||||
);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
// Bottom = early: invert Y ratio so scroll-at-bottom zooms around early years.
|
||||
const ratioFromTop = (e.clientY - rect.top) / rect.height;
|
||||
const ratio = 1 - ratioFromTop;
|
||||
const centerYear = viewStart + ratio * span;
|
||||
const factor = e.deltaY > 0 ? 1.15 : 0.85;
|
||||
const newSpan = Math.max(10, Math.min(absoluteMax - absoluteMin, span * factor));
|
||||
let newStart = centerYear - ratio * newSpan;
|
||||
let newEnd = centerYear + (1 - ratio) * newSpan;
|
||||
if (newStart < absoluteMin) {
|
||||
newEnd += absoluteMin - newStart;
|
||||
newStart = absoluteMin;
|
||||
}
|
||||
if (newEnd > absoluteMax) {
|
||||
newStart -= newEnd - absoluteMax;
|
||||
newEnd = absoluteMax;
|
||||
}
|
||||
onViewChange(Math.round(newStart), Math.round(newEnd));
|
||||
},
|
||||
[viewStart, span, absoluteMin, absoluteMax, onViewChange]
|
||||
);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent, mode: 'start' | 'end' | 'pan') => {
|
||||
e.preventDefault();
|
||||
setDragging(mode);
|
||||
dragStart.current = { y: e.clientY, viewStart, viewEnd };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) return;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
// Drag up (negative clientY delta) → toward later years at top → increase view.
|
||||
const dy = e.clientY - dragStart.current.y;
|
||||
const yearDelta = -(dy / rect.height) * span;
|
||||
|
||||
if (dragging === 'pan') {
|
||||
let ns = dragStart.current.viewStart - yearDelta;
|
||||
let ne = dragStart.current.viewEnd - yearDelta;
|
||||
if (ns < absoluteMin) {
|
||||
ne += absoluteMin - ns;
|
||||
ns = absoluteMin;
|
||||
}
|
||||
if (ne > absoluteMax) {
|
||||
ns -= ne - absoluteMax;
|
||||
ne = absoluteMax;
|
||||
}
|
||||
onViewChange(Math.round(ns), Math.round(ne));
|
||||
} else if (dragging === 'start') {
|
||||
const ns = Math.min(dragStart.current.viewEnd - 10, dragStart.current.viewStart + yearDelta);
|
||||
onViewChange(Math.round(ns), viewEnd);
|
||||
} else {
|
||||
const ne = Math.max(dragStart.current.viewStart + 10, dragStart.current.viewEnd + yearDelta);
|
||||
onViewChange(viewStart, Math.round(ne));
|
||||
}
|
||||
};
|
||||
const onUp = () => setDragging(null);
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
}, [dragging, span, viewStart, viewEnd, absoluteMin, absoluteMax, onViewChange]);
|
||||
|
||||
const zoomIn = () => {
|
||||
const center = (viewStart + viewEnd) / 2;
|
||||
const newSpan = Math.max(10, span * 0.5);
|
||||
onViewChange(Math.round(center - newSpan / 2), Math.round(center + newSpan / 2));
|
||||
};
|
||||
|
||||
const zoomOut = () => {
|
||||
const center = (viewStart + viewEnd) / 2;
|
||||
const newSpan = Math.min(absoluteMax - absoluteMin, span * 2);
|
||||
let ns = center - newSpan / 2;
|
||||
let ne = center + newSpan / 2;
|
||||
if (ns < absoluteMin) {
|
||||
ne += absoluteMin - ns;
|
||||
ns = absoluteMin;
|
||||
}
|
||||
if (ne > absoluteMax) {
|
||||
ns -= ne - absoluteMax;
|
||||
ne = absoluteMax;
|
||||
}
|
||||
onViewChange(Math.round(ns), Math.round(ne));
|
||||
};
|
||||
|
||||
const resetView = () => onViewChange(absoluteMin, absoluteMax);
|
||||
|
||||
const zoomToEra = useCallback(
|
||||
(era: HistoricalEra, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const eraSpan = era.end_year - era.start_year;
|
||||
const padding = Math.max(5, Math.round(eraSpan * 0.03));
|
||||
let start = Math.max(absoluteMin, era.start_year - padding);
|
||||
let end = Math.min(absoluteMax, era.end_year + padding);
|
||||
if (end - start < 10) {
|
||||
const center = (era.start_year + era.end_year) / 2;
|
||||
start = Math.max(absoluteMin, Math.round(center - 5));
|
||||
end = Math.min(absoluteMax, Math.round(center + 5));
|
||||
}
|
||||
onViewChange(Math.round(start), Math.round(end));
|
||||
},
|
||||
[absoluteMin, absoluteMax, onViewChange]
|
||||
);
|
||||
|
||||
const zoomToEvent = useCallback(
|
||||
(event: HistoricalEvent, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const end = eventEndYear(event);
|
||||
const eventSpan = Math.max(end - event.startYear, 1);
|
||||
const padding = Math.max(8, Math.round(eventSpan * 0.2));
|
||||
let start = Math.max(absoluteMin, event.startYear - padding);
|
||||
let endView = Math.min(absoluteMax, end + padding);
|
||||
if (endView - start < 10) {
|
||||
const center = (event.startYear + end) / 2;
|
||||
start = Math.max(absoluteMin, Math.round(center - 5));
|
||||
endView = Math.min(absoluteMax, Math.round(center + 5));
|
||||
}
|
||||
onViewChange(Math.round(start), Math.round(endView));
|
||||
},
|
||||
[absoluteMin, absoluteMax, onViewChange]
|
||||
);
|
||||
|
||||
const visibleEvents = useMemo(() => {
|
||||
const inView = HISTORICAL_EVENTS.filter((event) => eventInView(event, viewStart, viewEnd));
|
||||
const minLabelGapYears = span > 200 ? 40 : span > 80 ? 18 : span > 30 ? 10 : 5;
|
||||
let lastLabelYear = -Infinity;
|
||||
return inView.map((event) => {
|
||||
const end = eventEndYear(event);
|
||||
const labelAnchor = event.endYear ? (event.startYear + end) / 2 : event.startYear;
|
||||
const showLabel = labelAnchor - lastLabelYear >= minLabelGapYears;
|
||||
if (showLabel) lastLabelYear = labelAnchor;
|
||||
return { event, showLabel };
|
||||
});
|
||||
}, [viewStart, viewEnd, span]);
|
||||
|
||||
const lifespanBand = useMemo(() => {
|
||||
if (!lifespanHighlight) return null;
|
||||
const bottom = yearToBottomPercent(
|
||||
Math.max(lifespanHighlight.birthYear, viewStart),
|
||||
viewStart,
|
||||
viewEnd
|
||||
);
|
||||
const top = yearToBottomPercent(
|
||||
Math.min(lifespanHighlight.deathYear, viewEnd),
|
||||
viewStart,
|
||||
viewEnd
|
||||
);
|
||||
const height = top - bottom;
|
||||
if (height <= 0) return null;
|
||||
return { bottom, height, color: lifespanHighlight.color };
|
||||
}, [lifespanHighlight, viewStart, viewEnd]);
|
||||
|
||||
return (
|
||||
<aside className="vtimeline-wrapper">
|
||||
<div className="vtimeline-controls">
|
||||
<button type="button" onClick={zoomIn} title="Zoom in">
|
||||
+
|
||||
</button>
|
||||
<button type="button" onClick={zoomOut} title="Zoom out">
|
||||
−
|
||||
</button>
|
||||
<button type="button" onClick={resetView} title="Reset view">
|
||||
⟲
|
||||
</button>
|
||||
<span className="vtimeline-range">
|
||||
{formatYear(viewStart)}
|
||||
<br />—<br />
|
||||
{formatYear(viewEnd)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`vtimeline-container${lifespanBand ? ' vtimeline-container-lifespan-hover' : ''}`}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={(e) => handleMouseDown(e, 'pan')}
|
||||
>
|
||||
<div className="vtimeline-track">
|
||||
{eras.map((era) => {
|
||||
const bottom = yearToBottomPercent(Math.max(era.start_year, viewStart), viewStart, viewEnd);
|
||||
const top = yearToBottomPercent(Math.min(era.end_year, viewEnd), viewStart, viewEnd);
|
||||
if (top <= 0 || bottom >= 100) return null;
|
||||
const height = Math.min(100, top) - Math.max(0, bottom);
|
||||
return (
|
||||
<button
|
||||
key={era.id}
|
||||
type="button"
|
||||
className="vtimeline-era-block"
|
||||
style={{
|
||||
bottom: `${Math.max(0, bottom)}%`,
|
||||
height: `${height}%`,
|
||||
borderBottom: era.start_definite ? '2px solid rgba(255,255,255,0.6)' : undefined,
|
||||
borderTop: era.end_definite ? '2px solid rgba(255,255,255,0.6)' : undefined,
|
||||
background: `linear-gradient(0deg,
|
||||
${era.start_definite ? 'var(--era-color)' : 'transparent'} 0%,
|
||||
var(--era-color) 15%,
|
||||
var(--era-color) 85%,
|
||||
${era.end_definite ? 'var(--era-color)' : 'transparent'} 100%)`,
|
||||
['--era-color' as string]: getEraColor(era.name),
|
||||
}}
|
||||
title={`${era.name}: ${formatYear(era.start_year)} – ${formatYear(era.end_year)}`}
|
||||
onClick={(e) => zoomToEra(era, e)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="vtimeline-era-label">{era.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{lifespanBand && (
|
||||
<div className="vtimeline-lifespan-overlays" aria-hidden>
|
||||
{lifespanBand.bottom > 0 && (
|
||||
<div className="vtimeline-lifespan-dim" style={{ bottom: 0, height: `${lifespanBand.bottom}%` }} />
|
||||
)}
|
||||
{lifespanBand.bottom + lifespanBand.height < 100 && (
|
||||
<div
|
||||
className="vtimeline-lifespan-dim"
|
||||
style={{
|
||||
bottom: `${lifespanBand.bottom + lifespanBand.height}%`,
|
||||
height: `${100 - lifespanBand.bottom - lifespanBand.height}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="vtimeline-lifespan-highlight"
|
||||
style={{
|
||||
bottom: `${lifespanBand.bottom}%`,
|
||||
height: `${lifespanBand.height}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="vtimeline-events">
|
||||
{visibleEvents.map(({ event, showLabel }) => {
|
||||
const end = eventEndYear(event);
|
||||
const isSpan = event.endYear != null && event.endYear !== event.startYear;
|
||||
if (isSpan) {
|
||||
const bottom = yearToBottomPercent(Math.max(event.startYear, viewStart), viewStart, viewEnd);
|
||||
const top = yearToBottomPercent(Math.min(end, viewEnd), viewStart, viewEnd);
|
||||
if (top <= bottom) return null;
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
type="button"
|
||||
className="vtimeline-event-span"
|
||||
style={{ bottom: `${bottom}%`, height: `${top - bottom}%` }}
|
||||
title={event.name}
|
||||
onClick={(e) => zoomToEvent(event, e)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{showLabel && (
|
||||
<span className="vtimeline-event-label">{event.shortLabel ?? event.name}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const bottom = yearToBottomPercent(event.startYear, viewStart, viewEnd);
|
||||
if (bottom < 0 || bottom > 100) return null;
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
type="button"
|
||||
className="vtimeline-event-mark"
|
||||
style={{ bottom: `${bottom}%` }}
|
||||
title={event.name}
|
||||
onClick={(e) => zoomToEvent(event, e)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{showLabel && (
|
||||
<span className="vtimeline-event-label">{event.shortLabel ?? event.name}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="vtimeline-ticks">
|
||||
{ticks.map((year) => (
|
||||
<div
|
||||
key={year}
|
||||
className="vtimeline-tick"
|
||||
style={{ bottom: `${yearToBottomPercent(year, viewStart, viewEnd)}%` }}
|
||||
>
|
||||
<span>{formatYear(year)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="vtimeline-brush vtimeline-brush-start"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMouseDown(e, 'start');
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="vtimeline-brush vtimeline-brush-end"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMouseDown(e, 'end');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="vtimeline-hint">{t('captionVerticalTimeline')}</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -419,3 +419,9 @@
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.exit-nav-footer {
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(201, 169, 110, 0.25);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,271 @@
|
||||
import {
|
||||
CofferedCeiling,
|
||||
CorniceRing,
|
||||
FlutedShaft,
|
||||
HangingFixture,
|
||||
RoundArch,
|
||||
ShaftBase,
|
||||
TuscanCapital,
|
||||
WallBand,
|
||||
} from './primitives';
|
||||
import {
|
||||
shade,
|
||||
useBayZ,
|
||||
useCorners,
|
||||
useRepeat,
|
||||
useWallClearance,
|
||||
type PeriodProps,
|
||||
} from './geometry';
|
||||
|
||||
/**
|
||||
* Roman atrium — Ancient / Classical.
|
||||
*
|
||||
* Engaged Tuscan half-columns on a bay rhythm carry a compressed but complete
|
||||
* entablature (architrave, plain frieze, dentil course, projecting cornice).
|
||||
* Below the frame line the walls carry the Pompeian red socle of a Fourth Style
|
||||
* room; a bronze polycandelon hangs over the centre in place of ceiling lights.
|
||||
*/
|
||||
export function RomanAtriumDetails({ room, trim }: PeriodProps) {
|
||||
const bays = useBayZ(room.depth, room.halfD, 4.0, 9);
|
||||
const corners = useCorners(room.halfW, room.halfD, 0.1);
|
||||
const stone = '#e6dcc6';
|
||||
const shadowStone = shade(stone, -0.14);
|
||||
const socle = '#7c3227';
|
||||
|
||||
const dentils = useRepeat(room.depth, 0.46, 30);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Pompeian socle with a black skirting course */}
|
||||
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||
<group key={wall}>
|
||||
<WallBand wall={wall} room={room} y={0.62} height={1.16} proud={0.07} color={socle} roughness={0.8} metalness={0.04} />
|
||||
<WallBand wall={wall} room={room} y={0.09} height={0.18} proud={0.09} color="#241a14" roughness={0.7} metalness={0.06} />
|
||||
<WallBand wall={wall} room={room} y={1.24} height={0.08} proud={0.1} color={stone} roughness={0.66} metalness={0.06} />
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Engaged half-columns on the bay rhythm */}
|
||||
{bays.map((z, i) => (
|
||||
<group key={i}>
|
||||
{[-1, 1].map((side) => (
|
||||
<group key={side} position={[side * (room.halfW - 0.115), 0, z]}>
|
||||
<ShaftBase size={0.15} color={shadowStone} />
|
||||
<group position={[0, 0.26, 0]}>
|
||||
<FlutedShaft height={3.34} radius={0.135} color={stone} flutes={10} facing="x" />
|
||||
</group>
|
||||
<TuscanCapital y={3.72} size={0.15} color={stone} />
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Entablature: architrave, frieze and dentils along the colonnaded walls */}
|
||||
{(['left', 'right'] as const).map((wall) => (
|
||||
<group key={wall}>
|
||||
<WallBand wall={wall} room={room} y={3.9} height={0.1} proud={0.16} color={shadowStone} roughness={0.7} />
|
||||
<WallBand wall={wall} room={room} y={3.99} height={0.11} proud={0.13} color={stone} roughness={0.74} />
|
||||
{dentils.map((z, i) => (
|
||||
<mesh
|
||||
key={i}
|
||||
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.16), 4.07, z]}
|
||||
>
|
||||
<boxGeometry args={[0.16, 0.07, 0.16]} />
|
||||
<meshStandardMaterial color={shade(stone, 0.06)} roughness={0.7} metalness={0.05} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Projecting cornice returning around the whole room */}
|
||||
<CorniceRing room={room} y={4.13} height={0.09} proud={0.19} color={trim} roughness={0.62} metalness={0.12} />
|
||||
|
||||
{/* Corner antae — square pilasters closing the order at each corner */}
|
||||
{corners.map(([x, z], i) => (
|
||||
<mesh key={i} position={[x, 1.95, z]}>
|
||||
<boxGeometry args={[0.2, 3.9, 0.2]} />
|
||||
<meshStandardMaterial color={shade(stone, 0.04)} roughness={0.76} metalness={0.04} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
<HangingFixture
|
||||
position={[0, 4.05, 0]}
|
||||
dropLength={0.72}
|
||||
radius={0.46}
|
||||
arms={8}
|
||||
metalColor={trim}
|
||||
light
|
||||
lightColor="#ffb862"
|
||||
lightIntensity={0.9}
|
||||
lightDistance={9}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Florentine palazzo — Early Renaissance.
|
||||
*
|
||||
* Brunelleschi's grammar: flat pietra serena pilasters set against lime plaster,
|
||||
* a blind arcade of semicircular arches springing between them, glazed roundels
|
||||
* in the spandrels, and a painted-and-beamed quattrocento ceiling.
|
||||
*/
|
||||
export function QuattrocentoDetails({ room, trim, windows }: PeriodProps) {
|
||||
const bays = useBayZ(room.depth, room.halfD, 3.8, 9);
|
||||
const clearLeft = useWallClearance(windows, 'left');
|
||||
const clearRight = useWallClearance(windows, 'right');
|
||||
const serena = '#8b8f8a';
|
||||
const serenaLight = shade(serena, 0.16);
|
||||
const step = bays.length > 1 ? bays[1] - bays[0] : room.depth / 2;
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Pietra serena dado with a moulded cap */}
|
||||
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||
<group key={wall}>
|
||||
<WallBand wall={wall} room={room} y={0.6} height={1.2} proud={0.05} color={shade(serena, 0.24)} roughness={0.72} metalness={0.05} />
|
||||
<WallBand wall={wall} room={room} y={1.24} height={0.09} proud={0.08} color={serena} roughness={0.62} metalness={0.07} />
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Flat pilasters carrying the arcade */}
|
||||
{bays.map((z, i) => (
|
||||
<group key={i}>
|
||||
{[-1, 1].map((side) => (
|
||||
<group key={side} position={[side * (room.halfW - 0.05), 0, z]}>
|
||||
<mesh position={[0, 1.78, 0]}>
|
||||
<boxGeometry args={[0.08, 3.56, 0.34]} />
|
||||
<meshStandardMaterial color={serena} roughness={0.66} metalness={0.07} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.11, 0]}>
|
||||
<boxGeometry args={[0.11, 0.22, 0.44]} />
|
||||
<meshStandardMaterial color={shade(serena, -0.08)} roughness={0.7} metalness={0.06} />
|
||||
</mesh>
|
||||
{/* Simplified Corinthian capital */}
|
||||
<mesh position={[0, 3.68, 0]}>
|
||||
<boxGeometry args={[0.1, 0.2, 0.44]} />
|
||||
<meshStandardMaterial color={serenaLight} roughness={0.6} metalness={0.08} />
|
||||
</mesh>
|
||||
<mesh position={[0, 3.82, 0]}>
|
||||
<boxGeometry args={[0.12, 0.09, 0.5]} />
|
||||
<meshStandardMaterial color={serenaLight} roughness={0.58} metalness={0.08} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Blind arcade between the pilasters, plus a roundel in each spandrel */}
|
||||
{bays.slice(0, -1).map((z, i) => {
|
||||
const mid = z + step / 2;
|
||||
return (
|
||||
<group key={i}>
|
||||
{([-1, 1] as const).map((side) => (
|
||||
<group key={side} position={[side * (room.halfW - 0.05), 3.88, mid]} rotation={[0, Math.PI / 2, 0]}>
|
||||
<RoundArch
|
||||
span={step - 0.34}
|
||||
rise={0.24}
|
||||
thickness={0.12}
|
||||
depth={0.09}
|
||||
color={serena}
|
||||
segments={9}
|
||||
roughness={0.64}
|
||||
metalness={0.07}
|
||||
/>
|
||||
{/* Glazed tondo, only where no window claims the bay */}
|
||||
{(side < 0 ? clearLeft : clearRight)(mid, 0.22) && (
|
||||
<mesh position={[0, -0.5, 0]}>
|
||||
<torusGeometry args={[0.17, 0.035, 8, 20]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.42} metalness={0.35} />
|
||||
</mesh>
|
||||
)}
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Painted beam ceiling over the arcade */}
|
||||
<CorniceRing room={room} y={4.11} height={0.1} proud={0.14} color={serena} roughness={0.64} metalness={0.07} />
|
||||
<CofferedCeiling
|
||||
room={room}
|
||||
y={4.09}
|
||||
cell={2.0}
|
||||
panelColor="#e2d3ba"
|
||||
ribColor="#8a5a30"
|
||||
ribWidth={0.14}
|
||||
ribDrop={0.14}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Roman palazzo — High Renaissance.
|
||||
*
|
||||
* Bramante's cinquecento order: paired fluted Corinthian pilasters on a wide
|
||||
* bay, a full marble entablature, panelled dado, and a rosetted coffer field
|
||||
* modelled on the Pantheon soffit.
|
||||
*/
|
||||
export function CinquecentoDetails({ room, trim }: PeriodProps) {
|
||||
const bays = useBayZ(room.depth, room.halfD, 5.2, 6);
|
||||
const marble = '#efe9dd';
|
||||
const grey = shade(marble, -0.16);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Panelled marble dado */}
|
||||
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||
<group key={wall}>
|
||||
<WallBand wall={wall} room={room} y={0.58} height={1.16} proud={0.06} color={grey} roughness={0.44} metalness={0.1} />
|
||||
<WallBand wall={wall} room={room} y={1.2} height={0.1} proud={0.09} color={marble} roughness={0.38} metalness={0.12} />
|
||||
<WallBand wall={wall} room={room} y={0.07} height={0.14} proud={0.1} color={shade(grey, -0.18)} roughness={0.4} metalness={0.12} />
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Paired fluted pilasters */}
|
||||
{bays.map((z, i) => (
|
||||
<group key={i}>
|
||||
{[-1, 1].map((side) =>
|
||||
[-0.19, 0.19].map((dz) => (
|
||||
<group key={`${side}-${dz}`} position={[side * (room.halfW - 0.13), 0, z + dz]}>
|
||||
<ShaftBase size={0.12} color={grey} />
|
||||
<group position={[0, 0.24, 0]}>
|
||||
<FlutedShaft height={3.32} radius={0.11} color={marble} flutes={8} facing="x" />
|
||||
</group>
|
||||
<mesh position={[0, 3.66, 0]}>
|
||||
<cylinderGeometry args={[0.15, 0.1, 0.24, 12]} />
|
||||
<meshStandardMaterial color={marble} roughness={0.42} metalness={0.1} />
|
||||
</mesh>
|
||||
<mesh position={[0, 3.82, 0]}>
|
||||
<boxGeometry args={[0.12, 0.1, 0.34]} />
|
||||
<meshStandardMaterial color={shade(marble, 0.06)} roughness={0.4} metalness={0.11} />
|
||||
</mesh>
|
||||
</group>
|
||||
))
|
||||
)}
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Full entablature on the pilastered walls, cornice all round */}
|
||||
{(['left', 'right'] as const).map((wall) => (
|
||||
<group key={wall}>
|
||||
<WallBand wall={wall} room={room} y={3.92} height={0.1} proud={0.15} color={marble} roughness={0.42} metalness={0.1} />
|
||||
<WallBand wall={wall} room={room} y={4.02} height={0.1} proud={0.12} color={grey} roughness={0.46} metalness={0.1} />
|
||||
</group>
|
||||
))}
|
||||
<CorniceRing room={room} y={4.12} height={0.09} proud={0.18} color={trim} roughness={0.4} metalness={0.28} />
|
||||
|
||||
<CofferedCeiling
|
||||
room={room}
|
||||
y={4.08}
|
||||
cell={2.6}
|
||||
panelColor="#f2ede3"
|
||||
ribColor="#ded5c4"
|
||||
ribWidth={0.2}
|
||||
ribDrop={0.18}
|
||||
rosette={trim}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import {
|
||||
CeilingBeams,
|
||||
CeilingRose,
|
||||
CofferedCeiling,
|
||||
CorniceRing,
|
||||
FlutedShaft,
|
||||
HangingFixture,
|
||||
IonicCapital,
|
||||
ShaftBase,
|
||||
WallBand,
|
||||
} from './primitives';
|
||||
import {
|
||||
shade,
|
||||
useBayZ,
|
||||
useCorners,
|
||||
useRepeat,
|
||||
useWallClearance,
|
||||
type PeriodProps,
|
||||
} from './geometry';
|
||||
|
||||
/**
|
||||
* Flemish panel hall — Northern Renaissance.
|
||||
*
|
||||
* Oak linenfold wainscot under a moulded cap rail, carved corbels carrying an
|
||||
* exposed beam-and-joist ceiling, and the brass ring chandelier that hangs in
|
||||
* the background of half the period's interiors.
|
||||
*/
|
||||
export function FlemishDetails({ room, trim }: PeriodProps) {
|
||||
const beams = useBayZ(room.depth, room.halfD, 2.3, 10);
|
||||
const oak = '#6f4a2c';
|
||||
const oakLight = shade(oak, 0.2);
|
||||
|
||||
const folds = useRepeat(room.depth, 0.55, 34);
|
||||
|
||||
const backFolds = useRepeat(room.width, 0.55, 34);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Wainscot ground, cap rail and skirting */}
|
||||
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||
<group key={wall}>
|
||||
<WallBand wall={wall} room={room} y={0.66} height={1.24} proud={0.06} color={oak} roughness={0.66} metalness={0.08} />
|
||||
<WallBand wall={wall} room={room} y={1.32} height={0.12} proud={0.1} color={oakLight} roughness={0.54} metalness={0.1} />
|
||||
<WallBand wall={wall} room={room} y={0.08} height={0.16} proud={0.09} color={shade(oak, -0.3)} roughness={0.6} metalness={0.09} />
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Linenfold ribs across the wainscot */}
|
||||
{(['left', 'right'] as const).map((wall) =>
|
||||
folds.map((z, i) => (
|
||||
<mesh
|
||||
key={`${wall}-${i}`}
|
||||
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.07), 0.68, z]}
|
||||
>
|
||||
<cylinderGeometry args={[0.035, 0.035, 1.18, 6]} />
|
||||
<meshStandardMaterial color={oakLight} roughness={0.6} metalness={0.09} />
|
||||
</mesh>
|
||||
))
|
||||
)}
|
||||
{backFolds.map((x, i) => (
|
||||
<mesh key={`b-${i}`} position={[x, 0.68, -room.halfD + 0.07]}>
|
||||
<cylinderGeometry args={[0.035, 0.035, 1.18, 6]} />
|
||||
<meshStandardMaterial color={oakLight} roughness={0.6} metalness={0.09} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{/* Carved corbels carrying the beams */}
|
||||
{beams.map((z, i) => (
|
||||
<group key={i}>
|
||||
{[-1, 1].map((side) => (
|
||||
<mesh key={side} position={[side * (room.halfW - 0.11), 3.84, z]}>
|
||||
<boxGeometry args={[0.2, 0.24, 0.2]} />
|
||||
<meshStandardMaterial color={shade(oak, -0.12)} roughness={0.66} metalness={0.08} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
))}
|
||||
|
||||
<CeilingBeams room={room} positions={beams} axis="x" width={0.18} height={0.26} y={3.98} color={shade(oak, -0.2)} roughness={0.72} metalness={0.06} />
|
||||
<CorniceRing room={room} y={4.14} height={0.08} proud={0.12} color={oakLight} roughness={0.56} metalness={0.1} />
|
||||
|
||||
<HangingFixture
|
||||
position={[0, 3.94, 0]}
|
||||
dropLength={0.5}
|
||||
radius={0.4}
|
||||
arms={8}
|
||||
metalColor={trim}
|
||||
flameColor="#ffe0a0"
|
||||
light
|
||||
lightColor="#ffca80"
|
||||
lightIntensity={0.85}
|
||||
lightDistance={8}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mannerist gallery — Mannerism.
|
||||
*
|
||||
* The order used against itself: herm pilasters that taper the wrong way,
|
||||
* banded rustication blocking the shafts, oversized scroll consoles, and a
|
||||
* cornice deliberately broken over each bay.
|
||||
*/
|
||||
export function ManneristDetails({ room, trim, windows }: PeriodProps) {
|
||||
const clearLeft = useWallClearance(windows, 'left');
|
||||
const clearRight = useWallClearance(windows, 'right');
|
||||
const bays = useBayZ(room.depth, room.halfD, 4.4, 7);
|
||||
const stone = '#ddc9ae';
|
||||
const dark = shade(stone, -0.34);
|
||||
const bands = [0.55, 1.55, 2.35, 3.05];
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Heavy blocked socle */}
|
||||
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||
<WallBand key={wall} wall={wall} room={room} y={0.5} height={1.0} proud={0.08} color={dark} roughness={0.82} metalness={0.05} />
|
||||
))}
|
||||
|
||||
{/* Herm pilasters — wider at the shoulder than at the foot */}
|
||||
{bays.map((z, i) => (
|
||||
<group key={i}>
|
||||
{[-1, 1].map((side) => (
|
||||
<group key={side} position={[side * (room.halfW - 0.05), 0, z]}>
|
||||
<mesh position={[0, 1.9, 0]}>
|
||||
<boxGeometry args={[0.09, 3.8, 0.3]} />
|
||||
<meshStandardMaterial color={stone} roughness={0.78} metalness={0.05} />
|
||||
</mesh>
|
||||
{/* Rustication blocks interrupting the shaft */}
|
||||
{bands.map((y) => (
|
||||
<mesh key={y} position={[side * 0.03, y, 0]}>
|
||||
<boxGeometry args={[0.14, 0.3, 0.44]} />
|
||||
<meshStandardMaterial color={shade(stone, -0.16)} roughness={0.86} metalness={0.04} />
|
||||
</mesh>
|
||||
))}
|
||||
{/* Scroll console instead of a capital */}
|
||||
<mesh position={[0, 3.86, 0]} rotation={[0, Math.PI / 2, 0]}>
|
||||
<torusGeometry args={[0.14, 0.055, 8, 16, Math.PI * 1.5]} />
|
||||
<meshStandardMaterial color={shade(stone, 0.12)} roughness={0.66} metalness={0.08} />
|
||||
</mesh>
|
||||
<mesh position={[0, 4.03, 0]}>
|
||||
<boxGeometry args={[0.16, 0.12, 0.46]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.24} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Broken cornice — segments that stop short of each bay */}
|
||||
{(['left', 'right'] as const).map((wall) => (
|
||||
<group key={wall}>
|
||||
<WallBand wall={wall} room={room} y={3.96} height={0.12} proud={0.14} span={0.62} color={stone} roughness={0.72} metalness={0.06} />
|
||||
<WallBand wall={wall} room={room} y={4.1} height={0.08} proud={0.17} span={0.86} color={trim} roughness={0.52} metalness={0.26} />
|
||||
</group>
|
||||
))}
|
||||
<CorniceRing room={room} y={4.1} height={0.08} proud={0.15} walls={['back', 'front']} color={trim} roughness={0.52} metalness={0.26} />
|
||||
|
||||
{/* Cartouches slung between the bays, clear of the window openings */}
|
||||
{bays.slice(0, -1).map((z, i) => {
|
||||
const mid = (z + bays[i + 1]) / 2;
|
||||
return (
|
||||
<group key={i}>
|
||||
{([-1, 1] as const)
|
||||
.filter((side) => (side < 0 ? clearLeft : clearRight)(mid, 0.24))
|
||||
.map((side) => (
|
||||
<group key={side} position={[side * (room.halfW - 0.07), 3.6, mid]} rotation={[0, Math.PI / 2, 0]}>
|
||||
<mesh>
|
||||
<sphereGeometry args={[0.17, 12, 8]} />
|
||||
<meshStandardMaterial color={shade(stone, 0.16)} roughness={0.66} metalness={0.08} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0, 0.04]}>
|
||||
<torusGeometry args={[0.19, 0.03, 6, 18]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.44} metalness={0.4} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Baroque state gallery — Baroque.
|
||||
*
|
||||
* Twisted solomonic half-columns at the corners, gilt-framed velvet field
|
||||
* panels between the bays, a modillion cornice, and a crystal-and-gilt
|
||||
* chandelier over the centre of the room.
|
||||
*/
|
||||
export function BaroqueStateDetails({ room, trim, windows }: PeriodProps) {
|
||||
const bays = useBayZ(room.depth, room.halfD, 4.2, 8);
|
||||
const corners = useCorners(room.halfW, room.halfD, 0.16);
|
||||
const clearLeft = useWallClearance(windows, 'left');
|
||||
const clearRight = useWallClearance(windows, 'right');
|
||||
const gilt = trim;
|
||||
|
||||
const modillions = useRepeat(room.depth, 0.86, 26);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Gilt-edged velvet field panels between the bays, clear of the windows */}
|
||||
{bays.slice(0, -1).map((z, i) => {
|
||||
const mid = (z + bays[i + 1]) / 2;
|
||||
const w = Math.min(1.5, Math.max(0.7, (bays[i + 1] - z) * 0.4));
|
||||
return (
|
||||
<group key={i}>
|
||||
{([-1, 1] as const)
|
||||
.filter((side) => (side < 0 ? clearLeft : clearRight)(mid, w / 2 + 0.1))
|
||||
.map((side) => (
|
||||
<group key={side} position={[side * (room.halfW - 0.055), 3.2, mid]}>
|
||||
<mesh>
|
||||
<boxGeometry args={[0.03, 1.0, w]} />
|
||||
<meshStandardMaterial color="#4a1220" roughness={0.9} metalness={0.04} />
|
||||
</mesh>
|
||||
<mesh position={[-side * 0.015, 0, 0]}>
|
||||
<boxGeometry args={[0.04, 1.12, w + 0.12]} />
|
||||
<meshStandardMaterial color={gilt} roughness={0.3} metalness={0.7} emissive="#3a2808" emissiveIntensity={0.06} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Solomonic corner columns — twist read as stacked, rotated drums */}
|
||||
{corners.map(([x, z], i) => (
|
||||
<group key={i} position={[x, 0, z]}>
|
||||
<ShaftBase size={0.13} color="#2a1a12" />
|
||||
{Array.from({ length: 14 }, (_, d) => (
|
||||
<mesh key={d} position={[0, 0.34 + d * 0.24, 0]} rotation={[0, d * 0.42, 0]}>
|
||||
<boxGeometry args={[0.2, 0.24, 0.13]} />
|
||||
<meshStandardMaterial color={gilt} roughness={0.32} metalness={0.68} emissive="#2e2006" emissiveIntensity={0.05} />
|
||||
</mesh>
|
||||
))}
|
||||
<mesh position={[0, 3.86, 0]}>
|
||||
<cylinderGeometry args={[0.2, 0.13, 0.28, 10]} />
|
||||
<meshStandardMaterial color={gilt} roughness={0.28} metalness={0.75} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Modillion brackets under a heavy gilt cornice */}
|
||||
{(['left', 'right'] as const).map((wall) =>
|
||||
modillions.map((z, i) => (
|
||||
<mesh key={`${wall}-${i}`} position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.17), 3.92, z]}>
|
||||
<boxGeometry args={[0.18, 0.16, 0.12]} />
|
||||
<meshStandardMaterial color={shade(gilt, 0.1)} roughness={0.34} metalness={0.62} />
|
||||
</mesh>
|
||||
))
|
||||
)}
|
||||
<CorniceRing room={room} y={4.06} height={0.18} proud={0.19} color={gilt} roughness={0.24} metalness={0.78} emissive="#3a2808" emissiveIntensity={0.08} />
|
||||
|
||||
<CeilingRose y={4.1} radius={1.15} color={gilt} rings={4} />
|
||||
<HangingFixture
|
||||
position={[0, 4.05, 0]}
|
||||
dropLength={0.5}
|
||||
radius={0.6}
|
||||
arms={10}
|
||||
metalColor={gilt}
|
||||
flameColor="#fff0c0"
|
||||
glowColor="#ffbe58"
|
||||
light
|
||||
lightColor="#ffd08a"
|
||||
lightIntensity={1.0}
|
||||
lightDistance={11}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rococo salon — Rococo.
|
||||
*
|
||||
* Boiserie panelling with rounded corner arcs, asymmetric rocaille cartouches,
|
||||
* a deep painted cove instead of a cornice, and a light gilt lustre. Everything
|
||||
* is kept thin and pale: the period's relief is shallow by design.
|
||||
*/
|
||||
export function RococoDetails({ room, trim }: PeriodProps) {
|
||||
const bays = useBayZ(room.depth, room.halfD, 3.4, 9);
|
||||
const gilt = trim;
|
||||
const boiserie = '#efe6ee';
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Boiserie dado with a moulded chair rail */}
|
||||
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||
<group key={wall}>
|
||||
<WallBand wall={wall} room={room} y={0.55} height={1.04} proud={0.045} color={boiserie} roughness={0.6} metalness={0.08} />
|
||||
<WallBand wall={wall} room={room} y={1.12} height={0.07} proud={0.08} color={gilt} roughness={0.34} metalness={0.52} />
|
||||
<WallBand wall={wall} room={room} y={0.07} height={0.14} proud={0.08} color={boiserie} roughness={0.62} metalness={0.07} />
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Panel styles with quarter-round corners, and a rocaille cartouche above */}
|
||||
{bays.map((z, i) => (
|
||||
<group key={i}>
|
||||
{[-1, 1].map((side) => (
|
||||
<group key={side} position={[side * (room.halfW - 0.06), 0, z]}>
|
||||
<mesh position={[0, 2.05, 0]}>
|
||||
<boxGeometry args={[0.04, 4.0, 0.07]} />
|
||||
<meshStandardMaterial color={gilt} roughness={0.36} metalness={0.5} />
|
||||
</mesh>
|
||||
<group position={[0, 3.5, 0]} rotation={[0, Math.PI / 2, 0]}>
|
||||
<mesh>
|
||||
<torusGeometry args={[0.2, 0.028, 6, 18, Math.PI * 1.35]} />
|
||||
<meshStandardMaterial color={gilt} roughness={0.3} metalness={0.6} />
|
||||
</mesh>
|
||||
<mesh position={[0.1, -0.14, 0]} rotation={[0, 0, 0.7]}>
|
||||
<torusGeometry args={[0.12, 0.022, 6, 14, Math.PI * 1.1]} />
|
||||
<meshStandardMaterial color={gilt} roughness={0.3} metalness={0.6} />
|
||||
</mesh>
|
||||
</group>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Painted cove: two stacked, slightly stepped bands reading as a curve */}
|
||||
<CorniceRing room={room} y={3.86} height={0.16} proud={0.08} color={shade(boiserie, 0.04)} roughness={0.72} metalness={0.05} />
|
||||
<CorniceRing room={room} y={4.0} height={0.16} proud={0.14} color={boiserie} roughness={0.7} metalness={0.05} />
|
||||
<CorniceRing room={room} y={4.13} height={0.07} proud={0.17} color={gilt} roughness={0.3} metalness={0.6} />
|
||||
|
||||
<CeilingRose y={4.09} radius={0.9} color={gilt} rings={3} />
|
||||
<HangingFixture
|
||||
position={[0, 4.02, 0]}
|
||||
dropLength={0.42}
|
||||
radius={0.42}
|
||||
arms={8}
|
||||
metalColor={gilt}
|
||||
flameColor="#fff4dc"
|
||||
glowColor="#ffd58c"
|
||||
light
|
||||
lightColor="#ffe6c0"
|
||||
lightIntensity={0.8}
|
||||
lightDistance={10}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Neoclassical museum — Neoclassicism.
|
||||
*
|
||||
* Schinkel's museum grammar: fluted Ionic pilasters on a wide bay, a Greek-key
|
||||
* meander frieze, a dentilled cornice, and a plain coffered soffit around the
|
||||
* lantern. Deliberately cool and flat next to the Baroque hall.
|
||||
*/
|
||||
export function NeoclassicalDetails({ room, trim }: PeriodProps) {
|
||||
const bays = useBayZ(room.depth, room.halfD, 4.8, 6);
|
||||
const stone = '#f1eee8';
|
||||
const shadowStone = shade(stone, -0.14);
|
||||
|
||||
const meander = useRepeat(room.depth, 0.72, 24);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Plinth course under the hang */}
|
||||
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||
<group key={wall}>
|
||||
<WallBand wall={wall} room={room} y={0.36} height={0.72} proud={0.05} color={shadowStone} roughness={0.72} metalness={0.08} />
|
||||
<WallBand wall={wall} room={room} y={0.75} height={0.08} proud={0.08} color={stone} roughness={0.62} metalness={0.1} />
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Fluted Ionic pilasters */}
|
||||
{bays.map((z, i) => (
|
||||
<group key={i}>
|
||||
{[-1, 1].map((side) => (
|
||||
<group key={side} position={[side * (room.halfW - 0.13), 0, z]}>
|
||||
<ShaftBase size={0.13} color={shadowStone} />
|
||||
<group position={[0, 0.26, 0]}>
|
||||
<FlutedShaft height={3.3} radius={0.12} color={stone} flutes={10} facing="x" />
|
||||
</group>
|
||||
<IonicCapital y={3.68} size={0.15} color={stone} axis="z" />
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Meander frieze — alternating key blocks read as a running fret */}
|
||||
{(['left', 'right'] as const).map((wall) => (
|
||||
<group key={wall}>
|
||||
<WallBand wall={wall} room={room} y={3.9} height={0.24} proud={0.1} color={shade(stone, 0.03)} roughness={0.66} metalness={0.09} />
|
||||
{meander.map((z, i) => (
|
||||
<group key={i} position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.14), 3.9, z]}>
|
||||
<mesh position={[0, i % 2 === 0 ? 0.06 : -0.06, 0]}>
|
||||
<boxGeometry args={[0.06, 0.05, 0.34]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.28} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0, 0.15]}>
|
||||
<boxGeometry args={[0.06, 0.16, 0.05]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.28} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
))}
|
||||
|
||||
<CorniceRing room={room} y={4.07} height={0.1} proud={0.16} color={stone} roughness={0.6} metalness={0.1} />
|
||||
<CorniceRing room={room} y={4.15} height={0.06} proud={0.19} color={trim} roughness={0.48} metalness={0.24} />
|
||||
|
||||
<CofferedCeiling
|
||||
room={room}
|
||||
y={4.06}
|
||||
cell={2.9}
|
||||
panelColor="#f7f6f2"
|
||||
ribColor="#e2ded4"
|
||||
ribWidth={0.18}
|
||||
ribDrop={0.13}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { GalleryWindowSpec } from '../../data/movement-interior-styles';
|
||||
|
||||
/**
|
||||
* Shared geometry budget for every period interior.
|
||||
*
|
||||
* Movement halls hang frames 0.23 m proud of the inner wall face
|
||||
* (`MOUNT_OFFSET` + `WALL_STANDOFF`), centred at eye height with tops around
|
||||
* 2.4 m, and each wall keeps a 0.7 m clear margin at both ends
|
||||
* (`WALL_PADDING` in `movementHallLayout.ts`). Period architecture therefore
|
||||
* has to stay inside these limits, exactly as the Gothic nave and Byzantine
|
||||
* basilica do:
|
||||
*
|
||||
* - relief below the frame line: at most 0.11 m proud of the wall face
|
||||
* - friezes and cornices: 2.6 m and above, at most 0.17 m proud
|
||||
* - freestanding masses: only inside the 0.34 m corner pocket at a corner
|
||||
* - doorways: nothing below 2.6 m within 1.45 m of a wall centre
|
||||
* - the end wall carries the hall title from 3.4 m to 3.9 m — keep it clear
|
||||
* - side-wall windows are placed at runtime between 2.4 m and 3.9 m, so
|
||||
* anything wider than a mullion up there goes through `useWallClearance`
|
||||
* - added lights: at most two per hall, so hall materials stay inside the
|
||||
* WebGL light limits the shared track lighting already budgets for
|
||||
*/
|
||||
/** Half of WALL_THICKNESS — distance from a wall's centre plane to its inner face. */
|
||||
export const WALL_INNER = 0.09;
|
||||
/** Highest a detail can sit before it breaks the ceiling plane at 4.2 m. */
|
||||
export const CEILING_Y = 4.13;
|
||||
/** Sink bands this far into the wall so they never z-fight with the surface. */
|
||||
export const EMBED = 0.02;
|
||||
|
||||
export type WallId = 'back' | 'front' | 'left' | 'right';
|
||||
|
||||
export interface RoomDims {
|
||||
width: number;
|
||||
depth: number;
|
||||
halfW: number;
|
||||
halfD: number;
|
||||
}
|
||||
|
||||
/** Every period module takes the same three inputs. */
|
||||
export interface PeriodProps {
|
||||
room: RoomDims;
|
||||
/** The movement's trim colour, already blended with its accent. */
|
||||
trim: string;
|
||||
/** Windows the hall placed at runtime, so upper-wall relief can avoid them. */
|
||||
windows?: GalleryWindowSpec[];
|
||||
}
|
||||
|
||||
/** Lighten (amount > 0) or darken (amount < 0) a hex colour. */
|
||||
export function shade(hex: string, amount: number): string {
|
||||
const n = parseInt(hex.replace('#', ''), 16);
|
||||
const ch = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((c) =>
|
||||
Math.max(0, Math.min(255, Math.round(amount >= 0 ? c + (255 - c) * amount : c * (1 + amount))))
|
||||
);
|
||||
return `#${((ch[0] << 16) | (ch[1] << 8) | ch[2]).toString(16).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
/** Bay rhythm along the depth axis — the spacing that drives piers and beams. */
|
||||
export function useBayZ(depth: number, halfD: number, spacing = 4.2, max = 9): number[] {
|
||||
return useMemo(() => {
|
||||
const count = Math.max(2, Math.min(max, Math.floor(depth / spacing)));
|
||||
const step = depth / (count + 1);
|
||||
return Array.from({ length: count }, (_, i) => -halfD + step * (i + 1));
|
||||
}, [depth, halfD, spacing, max]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evenly spaced positions centred on a wall, for the repeating small members —
|
||||
* dentils, linenfold ribs, modillions, stencil motifs, quatrefoils.
|
||||
*/
|
||||
export function useRepeat(span: number, step: number, max: number): number[] {
|
||||
return useMemo(() => {
|
||||
const count = Math.min(max, Math.max(3, Math.floor((span - 0.6) / step)));
|
||||
const run = (count - 1) * step;
|
||||
return Array.from({ length: count }, (_, i) => -run / 2 + i * step);
|
||||
}, [span, step, max]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Side-wall windows are placed at runtime in whatever gaps the hang leaves, so
|
||||
* any panel, arch or roundel a period module paints across the upper wall has
|
||||
* to step around them. Returns a predicate: true when the span is clear.
|
||||
*/
|
||||
export function useWallClearance(
|
||||
windows: GalleryWindowSpec[] | undefined,
|
||||
wall: 'left' | 'right' | 'back'
|
||||
) {
|
||||
return useMemo(() => {
|
||||
const spans = (windows ?? [])
|
||||
.filter((w) => w.wall === wall)
|
||||
.map((w) => [w.x - w.width / 2 - 0.18, w.x + w.width / 2 + 0.18] as [number, number]);
|
||||
return (pos: number, halfSpan = 0.2) =>
|
||||
!spans.some(([a, b]) => pos + halfSpan > a && pos - halfSpan < b);
|
||||
}, [windows, wall]);
|
||||
}
|
||||
|
||||
/** The four corner columns of the room, each inside its hang-margin pocket. */
|
||||
export function useCorners(halfW: number, halfD: number, inset = 0.08): [number, number][] {
|
||||
return useMemo(
|
||||
() =>
|
||||
[
|
||||
[-halfW + inset, -halfD + inset],
|
||||
[halfW - inset, -halfD + inset],
|
||||
[-halfW + inset, halfD - inset],
|
||||
[halfW - inset, halfD - inset],
|
||||
] as [number, number][],
|
||||
[halfW, halfD, inset]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,717 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
CeilingBeams,
|
||||
CorniceRing,
|
||||
HangingFixture,
|
||||
SideRails,
|
||||
WallBand,
|
||||
} from './primitives';
|
||||
import {
|
||||
shade,
|
||||
useBayZ,
|
||||
useCorners,
|
||||
useRepeat,
|
||||
type PeriodProps,
|
||||
} from './geometry';
|
||||
|
||||
/**
|
||||
* Fauvist studio — Fauvism.
|
||||
*
|
||||
* The Collioure summer room: close-set whitewashed rafters, plank shutters
|
||||
* folded back against the wall beside every window, and a plain painted rail.
|
||||
* The architecture stays vernacular so the wall colour does the work.
|
||||
*/
|
||||
export function FauvistDetails({ room, trim, windows }: PeriodProps) {
|
||||
const rafters = useBayZ(room.depth, room.halfD, 1.5, 14);
|
||||
const timber = '#d8c8a8';
|
||||
|
||||
const shutters = useMemo(
|
||||
() =>
|
||||
(windows ?? [])
|
||||
.filter((w) => w.wall === 'left' || w.wall === 'right')
|
||||
.flatMap((w) =>
|
||||
[-1, 1].map((dir) => ({
|
||||
wall: w.wall as 'left' | 'right',
|
||||
z: w.x + dir * (w.width / 2 + w.width * 0.28),
|
||||
y: w.y,
|
||||
h: w.height,
|
||||
w: w.width * 0.5,
|
||||
}))
|
||||
),
|
||||
[windows]
|
||||
);
|
||||
|
||||
return (
|
||||
<group>
|
||||
<CeilingBeams
|
||||
room={room}
|
||||
positions={rafters}
|
||||
axis="x"
|
||||
width={0.1}
|
||||
height={0.16}
|
||||
y={4.02}
|
||||
color={timber}
|
||||
roughness={0.88}
|
||||
metalness={0.02}
|
||||
/>
|
||||
<CeilingBeams
|
||||
room={room}
|
||||
positions={[0]}
|
||||
axis="z"
|
||||
width={0.2}
|
||||
height={0.24}
|
||||
y={3.86}
|
||||
color={shade(timber, -0.24)}
|
||||
roughness={0.88}
|
||||
metalness={0.02}
|
||||
/>
|
||||
|
||||
{/* Folded-back plank shutters flanking the windows */}
|
||||
{shutters.map((s, i) => (
|
||||
<group
|
||||
key={i}
|
||||
position={[(s.wall === 'left' ? -1 : 1) * (room.halfW - 0.13), s.y, s.z]}
|
||||
>
|
||||
<mesh>
|
||||
<boxGeometry args={[0.06, s.h, s.w]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.8} metalness={0.04} />
|
||||
</mesh>
|
||||
{[-0.3, 0, 0.3].map((t) => (
|
||||
<mesh key={t} position={[0.04, s.h * t, 0]}>
|
||||
<boxGeometry args={[0.02, s.h * 0.22, s.w * 0.86]} />
|
||||
<meshStandardMaterial color={shade(trim, -0.22)} roughness={0.84} metalness={0.03} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
))}
|
||||
|
||||
<SideRails room={room} y={2.6} height={0.08} proud={0.11} color={timber} roughness={0.82} metalness={0.03} />
|
||||
<WallBand wall="back" room={room} y={2.6} height={0.08} proud={0.11} color={timber} roughness={0.82} metalness={0.03} />
|
||||
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||
<WallBand key={wall} wall={wall} room={room} y={0.12} height={0.24} proud={0.07} color={trim} roughness={0.8} metalness={0.04} />
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expressionist room — Expressionism.
|
||||
*
|
||||
* Die Brücke's carved-plank interior pushed towards the raked sets of the
|
||||
* period's cinema: battens canted at alternating angles above the hang, a
|
||||
* zig-zag frieze, and ceiling members that refuse to run square.
|
||||
*/
|
||||
export function ExpressionistDetails({ room, trim }: PeriodProps) {
|
||||
const bays = useBayZ(room.depth, room.halfD, 2.2, 11);
|
||||
const plank = '#5a3f27';
|
||||
const light = shade(plank, 0.3);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Canted battens, alternating lean */}
|
||||
{bays.map((z, i) => (
|
||||
<group key={i}>
|
||||
{[-1, 1].map((side) => (
|
||||
<mesh
|
||||
key={side}
|
||||
position={[side * (room.halfW - 0.07), 3.28, z]}
|
||||
rotation={[i % 2 === 0 ? 0.3 : -0.3, 0, 0]}
|
||||
>
|
||||
<boxGeometry args={[0.08, 1.5, 0.17]} />
|
||||
<meshStandardMaterial color={i % 2 === 0 ? plank : light} roughness={0.84} metalness={0.04} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Zig-zag frieze under the ceiling */}
|
||||
{(['left', 'right'] as const).map((wall) =>
|
||||
bays.map((z, i) => (
|
||||
<mesh
|
||||
key={`${wall}-${i}`}
|
||||
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.08), 4.06, z]}
|
||||
rotation={[i % 2 === 0 ? 0.62 : -0.62, 0, 0]}
|
||||
>
|
||||
<boxGeometry args={[0.1, 0.42, 0.1]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.7} metalness={0.1} />
|
||||
</mesh>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Ceiling members deliberately off-square */}
|
||||
{bays
|
||||
.filter((_, i) => i % 3 === 0)
|
||||
.map((z, i) => (
|
||||
<mesh key={i} position={[0, 3.98, z]} rotation={[0, i % 2 === 0 ? 0.14 : -0.14, 0]}>
|
||||
<boxGeometry args={[room.width * 0.97, 0.2, 0.15]} />
|
||||
<meshStandardMaterial color={shade(plank, -0.24)} roughness={0.88} metalness={0.03} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||
<WallBand key={wall} wall={wall} room={room} y={0.13} height={0.26} proud={0.09} color={shade(plank, -0.3)} roughness={0.86} metalness={0.03} />
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cubist studio — Cubism.
|
||||
*
|
||||
* The Bateau-Lavoir atelier: slim cast-iron columns in the corners, riveted tie
|
||||
* rods across the room, and a cornice broken into faceted plaster planes — the
|
||||
* building itself analysed into flat facets.
|
||||
*/
|
||||
export function CubistAtelierDetails({ room, trim }: PeriodProps) {
|
||||
const corners = useCorners(room.halfW, room.halfD, 0.24);
|
||||
const ties = useBayZ(room.depth, room.halfD, 3.6, 8);
|
||||
const iron = '#4d4a45';
|
||||
const facet = '#dedad2';
|
||||
|
||||
const facets = useRepeat(room.depth, 1.15, 22);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Cast-iron columns in the corner pockets */}
|
||||
{corners.map(([x, z], i) => (
|
||||
<group key={i} position={[x, 0, z]}>
|
||||
<mesh position={[0, 0.1, 0]}>
|
||||
<boxGeometry args={[0.3, 0.2, 0.3]} />
|
||||
<meshStandardMaterial color={shade(iron, -0.2)} roughness={0.6} metalness={0.45} />
|
||||
</mesh>
|
||||
<mesh position={[0, 2.1, 0]}>
|
||||
<cylinderGeometry args={[0.09, 0.11, 3.8, 10]} />
|
||||
<meshStandardMaterial color={iron} roughness={0.52} metalness={0.55} />
|
||||
</mesh>
|
||||
<mesh position={[0, 4.02, 0]}>
|
||||
<boxGeometry args={[0.34, 0.18, 0.34]} />
|
||||
<meshStandardMaterial color={iron} roughness={0.5} metalness={0.58} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Riveted tie rods spanning the room, with a turnbuckle at mid-span */}
|
||||
{ties.map((z, i) => (
|
||||
<group key={i} position={[0, 3.8, z]}>
|
||||
<mesh rotation={[0, 0, Math.PI / 2]}>
|
||||
<cylinderGeometry args={[0.035, 0.035, room.width - 0.2, 8]} />
|
||||
<meshStandardMaterial color={iron} roughness={0.46} metalness={0.62} />
|
||||
</mesh>
|
||||
<mesh rotation={[0, 0, Math.PI / 2]}>
|
||||
<cylinderGeometry args={[0.06, 0.06, 0.3, 8]} />
|
||||
<meshStandardMaterial color={shade(iron, -0.2)} roughness={0.4} metalness={0.7} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Faceted cornice: flat planes tilted alternately */}
|
||||
{(['left', 'right'] as const).map((wall) =>
|
||||
facets.map((z, i) => (
|
||||
<mesh
|
||||
key={`${wall}-${i}`}
|
||||
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.1), 4.02, z]}
|
||||
rotation={[0, 0, i % 2 === 0 ? 0.34 : -0.34]}
|
||||
>
|
||||
<boxGeometry args={[0.2, 0.22, 0.86]} />
|
||||
<meshStandardMaterial
|
||||
color={i % 3 === 0 ? shade(facet, -0.14) : facet}
|
||||
roughness={0.82}
|
||||
metalness={0.05}
|
||||
/>
|
||||
</mesh>
|
||||
))
|
||||
)}
|
||||
<CorniceRing room={room} y={4.15} height={0.08} proud={0.13} color={trim} roughness={0.6} metalness={0.2} />
|
||||
|
||||
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||
<WallBand key={wall} wall={wall} room={room} y={0.1} height={0.2} proud={0.07} color={shade(iron, 0.24)} roughness={0.7} metalness={0.16} />
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Futurist loft — Futurism.
|
||||
*
|
||||
* Sant'Elia's Città Nuova reduced to one room: riveted steel stanchions,
|
||||
* diagonal wind bracing across the upper wall, and a gantry rail running the
|
||||
* length of the hall. Structure celebrated, nothing concealed.
|
||||
*/
|
||||
export function FuturistDetails({ room, trim }: PeriodProps) {
|
||||
const bays = useBayZ(room.depth, room.halfD, 4.0, 8);
|
||||
const steel = '#8f9298';
|
||||
const dark = shade(steel, -0.42);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Riveted stanchions */}
|
||||
{bays.map((z, i) => (
|
||||
<group key={i}>
|
||||
{[-1, 1].map((side) => (
|
||||
<group key={side} position={[side * (room.halfW - 0.06), 0, z]}>
|
||||
<mesh position={[0, 2.05, 0]}>
|
||||
<boxGeometry args={[0.09, 4.1, 0.36]} />
|
||||
<meshStandardMaterial color={steel} roughness={0.42} metalness={0.72} />
|
||||
</mesh>
|
||||
{[0.6, 1.7, 2.8, 3.8].map((y) => (
|
||||
<mesh key={y} position={[side * -0.03, y, 0]}>
|
||||
<boxGeometry args={[0.06, 0.1, 0.44]} />
|
||||
<meshStandardMaterial color={dark} roughness={0.38} metalness={0.8} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Diagonal wind bracing between the stanchions, above the hang */}
|
||||
{bays.slice(0, -1).map((z, i) => {
|
||||
const mid = (z + bays[i + 1]) / 2;
|
||||
const run = bays[i + 1] - z;
|
||||
return (
|
||||
<group key={i}>
|
||||
{[-1, 1].map((side) =>
|
||||
[-1, 1].map((dir) => (
|
||||
<mesh
|
||||
key={`${side}-${dir}`}
|
||||
position={[side * (room.halfW - 0.1), 3.4, mid]}
|
||||
rotation={[dir * Math.atan2(run - 0.4, 0.9), 0, 0]}
|
||||
>
|
||||
<boxGeometry args={[0.06, Math.hypot(run - 0.4, 0.9), 0.06]} />
|
||||
<meshStandardMaterial color={steel} roughness={0.4} metalness={0.75} />
|
||||
</mesh>
|
||||
))
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Gantry rail and its brackets */}
|
||||
<SideRails room={room} y={2.72} height={0.12} proud={0.15} color={dark} roughness={0.36} metalness={0.82} />
|
||||
<CorniceRing room={room} y={4.13} height={0.1} proud={0.16} color={trim} roughness={0.34} metalness={0.7} />
|
||||
|
||||
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||
<WallBand key={wall} wall={wall} room={room} y={0.11} height={0.22} proud={0.09} color={dark} roughness={0.4} metalness={0.7} />
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Suprematist white cube — Suprematism.
|
||||
*
|
||||
* The 0,10 room: a bare white shell, a shelf slung across the upper corner
|
||||
* where Malevich hung the Black Square in the icon position, and thin black and
|
||||
* red geometric bands drifting off the axis of the walls.
|
||||
*/
|
||||
export function SuprematistDetails({ room, trim }: PeriodProps) {
|
||||
const bars = useMemo(() => {
|
||||
const seeds = [-0.62, -0.24, 0.18, 0.55];
|
||||
return seeds.map((t, i) => ({
|
||||
z: t,
|
||||
y: 3.0 + (i % 3) * 0.28,
|
||||
len: 0.9 + (i % 2) * 0.7,
|
||||
tilt: i % 2 === 0 ? 0.22 : -0.34,
|
||||
color: i % 3 === 0 ? '#c8302a' : '#141414',
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Icon-corner shelf, carried on two plain brackets */}
|
||||
<group position={[room.halfW - 0.46, 3.3, -room.halfD + 0.46]} rotation={[0, Math.PI / 4, 0]}>
|
||||
<mesh>
|
||||
<boxGeometry args={[1.16, 0.05, 0.34]} />
|
||||
<meshStandardMaterial color="#f2f2f2" roughness={0.72} metalness={0.04} />
|
||||
</mesh>
|
||||
{[-0.4, 0.4].map((t) => (
|
||||
<mesh key={t} position={[t, -0.16, 0.02]} rotation={[0, 0, 0]}>
|
||||
<boxGeometry args={[0.05, 0.28, 0.24]} />
|
||||
<meshStandardMaterial color="#e2e2e2" roughness={0.74} metalness={0.04} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
|
||||
{/* Drifting bands above the hang */}
|
||||
{(['left', 'right'] as const).map((wall) =>
|
||||
bars.map((b, i) => (
|
||||
<mesh
|
||||
key={`${wall}-${i}`}
|
||||
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.07), b.y, b.z * room.halfD]}
|
||||
rotation={[b.tilt, 0, 0]}
|
||||
>
|
||||
<boxGeometry args={[0.04, 0.14, b.len]} />
|
||||
<meshStandardMaterial color={b.color} roughness={0.66} metalness={0.06} />
|
||||
</mesh>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Shadow-gap reveal instead of a cornice */}
|
||||
<CorniceRing room={room} y={4.04} height={0.05} proud={0.03} color="#d2d2d2" roughness={0.8} metalness={0.04} />
|
||||
<CorniceRing room={room} y={4.16} height={0.06} proud={0.1} color={trim} roughness={0.7} metalness={0.08} />
|
||||
|
||||
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||
<WallBand key={wall} wall={wall} room={room} y={0.07} height={0.14} proud={0.06} color="#dcdcdc" roughness={0.76} metalness={0.05} />
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructivist space — Constructivism.
|
||||
*
|
||||
* Rodchenko's lattice made architecture: red-painted diagonal trusses under the
|
||||
* ceiling, angled Proun panels folded into the corners, and exposed steel ties.
|
||||
* Every member is doing structural work and says so.
|
||||
*/
|
||||
export function ConstructivistDetails({ room, trim }: PeriodProps) {
|
||||
const bays = useBayZ(room.depth, room.halfD, 2.8, 10);
|
||||
const corners = useCorners(room.halfW, room.halfD, 0.3);
|
||||
const red = '#b8342a';
|
||||
const steel = '#9a9a96';
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Lattice truss under the ceiling — top chord, bottom chord, diagonals */}
|
||||
<CeilingBeams room={room} positions={[-0.9, 0.9]} axis="z" width={0.1} height={0.12} y={4.06} color={steel} roughness={0.44} metalness={0.6} />
|
||||
<CeilingBeams room={room} positions={[-0.9, 0.9]} axis="z" width={0.09} height={0.1} y={3.66} color={steel} roughness={0.44} metalness={0.6} />
|
||||
{bays.map((z, i) =>
|
||||
[-0.9, 0.9].map((x) => (
|
||||
<mesh key={`${i}-${x}`} position={[x, 3.86, z]} rotation={[i % 2 === 0 ? 0.62 : -0.62, 0, 0]}>
|
||||
<boxGeometry args={[0.07, 0.62, 0.07]} />
|
||||
<meshStandardMaterial color={red} roughness={0.56} metalness={0.3} />
|
||||
</mesh>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Proun panels folded into the corners */}
|
||||
{corners.map(([x, z], i) => (
|
||||
<group key={i} position={[x, 2.9, z]} rotation={[0, Math.PI / 4 + (i % 2) * 0.2, 0]}>
|
||||
<mesh rotation={[0, 0, 0.18]}>
|
||||
<boxGeometry args={[0.66, 1.5, 0.05]} />
|
||||
<meshStandardMaterial color={i % 2 === 0 ? red : '#141414'} roughness={0.66} metalness={0.12} />
|
||||
</mesh>
|
||||
<mesh position={[0.1, -0.3, 0.05]} rotation={[0, 0, -0.5]}>
|
||||
<boxGeometry args={[0.5, 0.14, 0.04]} />
|
||||
<meshStandardMaterial color={steel} roughness={0.42} metalness={0.6} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Steel tie rails at the hang line and the skirting */}
|
||||
<SideRails room={room} y={2.64} height={0.07} proud={0.12} color={steel} roughness={0.4} metalness={0.68} />
|
||||
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||
<WallBand key={wall} wall={wall} room={room} y={0.1} height={0.2} proud={0.08} color={red} roughness={0.62} metalness={0.2} />
|
||||
))}
|
||||
<CorniceRing room={room} y={4.16} height={0.06} proud={0.11} color={trim} roughness={0.5} metalness={0.4} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dada salon — Dada.
|
||||
*
|
||||
* A bourgeois room being dismantled: cornice fragments that stop and restart at
|
||||
* different heights, a picture rail hung visibly out of level, and a service
|
||||
* pipe left running across the wall where the moulding should be.
|
||||
*/
|
||||
export function DadaDetails({ room, trim }: PeriodProps) {
|
||||
const fragments = useMemo(
|
||||
() =>
|
||||
[
|
||||
{ wall: 'left' as const, y: 4.04, span: 0.3, proud: 0.14 },
|
||||
{ wall: 'left' as const, y: 3.78, span: 0.18, proud: 0.1 },
|
||||
{ wall: 'right' as const, y: 4.12, span: 0.44, proud: 0.16 },
|
||||
{ wall: 'right' as const, y: 3.9, span: 0.22, proud: 0.09 },
|
||||
{ wall: 'back' as const, y: 4.06, span: 0.35, proud: 0.13 },
|
||||
],
|
||||
[]
|
||||
);
|
||||
const plaster = '#e6dac4';
|
||||
const pipe = '#6a6259';
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Cornice, in pieces */}
|
||||
{fragments.map((f, i) => (
|
||||
<WallBand
|
||||
key={i}
|
||||
wall={f.wall}
|
||||
room={room}
|
||||
y={f.y}
|
||||
height={0.13}
|
||||
proud={f.proud}
|
||||
span={f.span}
|
||||
color={i % 2 === 0 ? plaster : shade(plaster, -0.18)}
|
||||
roughness={0.76}
|
||||
metalness={0.05}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Picture rail, visibly out of level */}
|
||||
{[-1, 1].map((side) => (
|
||||
<mesh key={side} position={[side * (room.halfW - 0.07), 2.62, 0]} rotation={[side * 0.035, 0, 0]}>
|
||||
<boxGeometry args={[0.1, 0.08, room.depth * 0.86]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.6} metalness={0.18} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{/* Service pipe run, with a clumsy elbow into the ceiling */}
|
||||
<mesh position={[-room.halfW + 0.16, 3.34, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<cylinderGeometry args={[0.06, 0.06, room.depth * 0.8, 10]} />
|
||||
<meshStandardMaterial color={pipe} roughness={0.64} metalness={0.4} />
|
||||
</mesh>
|
||||
<mesh position={[-room.halfW + 0.16, 3.7, room.depth * 0.4]}>
|
||||
<cylinderGeometry args={[0.06, 0.06, 0.75, 10]} />
|
||||
<meshStandardMaterial color={pipe} roughness={0.64} metalness={0.4} />
|
||||
</mesh>
|
||||
|
||||
{/* Skirting that changes its mind halfway along */}
|
||||
<WallBand wall="left" room={room} y={0.11} height={0.22} proud={0.08} span={0.55} color={plaster} roughness={0.78} metalness={0.04} />
|
||||
<WallBand wall="right" room={room} y={0.15} height={0.3} proud={0.1} span={0.8} color={shade(plaster, -0.26)} roughness={0.78} metalness={0.04} />
|
||||
<WallBand wall="back" room={room} y={0.11} height={0.22} proud={0.08} color={plaster} roughness={0.78} metalness={0.04} />
|
||||
|
||||
<HangingFixture
|
||||
position={[room.halfW * 0.32, 3.9, room.halfD * 0.2]}
|
||||
dropLength={0.7}
|
||||
radius={0.2}
|
||||
arms={3}
|
||||
metalColor={trim}
|
||||
flameColor="#fff0cc"
|
||||
glowColor="#ffb45a"
|
||||
light
|
||||
lightColor="#ffd0a0"
|
||||
lightIntensity={0.7}
|
||||
lightDistance={8}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Surrealist interior — Surrealism.
|
||||
*
|
||||
* The 1938 Exposition Internationale: sacks slung under the ceiling, drapery
|
||||
* pulled across the cornice, and a door standing in a corner where no door can
|
||||
* lead anywhere. The room is furnished as an image, not as a gallery.
|
||||
*/
|
||||
export function SurrealistDetails({ room, trim }: PeriodProps) {
|
||||
const sacks = useBayZ(room.depth, room.halfD, 2.6, 9);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Sacks hanging under the ceiling */}
|
||||
{sacks.map((z, i) =>
|
||||
[-1, 0, 1].map((c) => (
|
||||
<mesh
|
||||
key={`${i}-${c}`}
|
||||
position={[c * room.width * 0.26, 3.86 - ((i + c) % 3) * 0.09, z]}
|
||||
rotation={[0, ((i + c) % 4) * 0.4, 0.1 * ((i % 2) * 2 - 1)]}
|
||||
>
|
||||
<sphereGeometry args={[0.34, 10, 8]} />
|
||||
<meshStandardMaterial color={i % 2 === 0 ? '#3a3128' : '#2c261f'} roughness={0.96} metalness={0.02} />
|
||||
</mesh>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Drapery gathered along the top of the side walls */}
|
||||
{(['left', 'right'] as const).map((wall) =>
|
||||
sacks.map((z, i) => (
|
||||
<mesh
|
||||
key={`${wall}-${i}`}
|
||||
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.13), 3.7, z]}
|
||||
rotation={[0, 0, 0]}
|
||||
>
|
||||
<cylinderGeometry args={[0.13, 0.09, 0.86, 8]} />
|
||||
<meshStandardMaterial color="#38304a" roughness={0.92} metalness={0.03} />
|
||||
</mesh>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* A door that leads nowhere, standing in the corner pocket */}
|
||||
<group position={[-room.halfW + 0.34, 0, -room.halfD + 0.34]} rotation={[0, Math.PI / 4, 0]}>
|
||||
<mesh position={[0, 1.05, 0]}>
|
||||
<boxGeometry args={[0.72, 2.1, 0.07]} />
|
||||
<meshStandardMaterial color="#4a3a2a" roughness={0.78} metalness={0.06} />
|
||||
</mesh>
|
||||
<mesh position={[0, 1.12, 0]}>
|
||||
<boxGeometry args={[0.84, 2.24, 0.05]} />
|
||||
<meshStandardMaterial color={shade('#4a3a2a', -0.3)} roughness={0.8} metalness={0.05} />
|
||||
</mesh>
|
||||
<mesh position={[0.26, 1.02, 0.06]}>
|
||||
<sphereGeometry args={[0.05, 10, 10]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.34} metalness={0.66} />
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
<CorniceRing room={room} y={4.14} height={0.09} proud={0.12} color={trim} roughness={0.62} metalness={0.2} />
|
||||
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||
<WallBand key={wall} wall={wall} room={room} y={0.11} height={0.22} proud={0.08} color="#2e2822" roughness={0.82} metalness={0.05} />
|
||||
))}
|
||||
|
||||
{/* A brazier glow in the opposite corner */}
|
||||
<group position={[room.halfW - 0.44, 0, room.halfD - 0.44]}>
|
||||
<mesh position={[0, 0.34, 0]}>
|
||||
<cylinderGeometry args={[0.22, 0.15, 0.28, 10]} />
|
||||
<meshStandardMaterial color="#2a2622" roughness={0.7} metalness={0.35} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.5, 0]}>
|
||||
<sphereGeometry args={[0.16, 10, 8]} />
|
||||
<meshStandardMaterial color="#ff9a40" emissive="#ff6a12" emissiveIntensity={1.1} toneMapped={false} />
|
||||
</mesh>
|
||||
<pointLight position={[0, 0.7, 0]} intensity={0.8} distance={7} color="#ff8a3c" />
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* NYC loft — Abstract Expressionism.
|
||||
*
|
||||
* A Tenth Street cast-iron building: fluted iron columns on their corbelled
|
||||
* caps, a pressed-tin ceiling field, and the sprinkler main running the length
|
||||
* of the room. Industrial fabric left exactly as the painters found it.
|
||||
*/
|
||||
export function LoftDetails({ room, trim }: PeriodProps) {
|
||||
const corners = useCorners(room.halfW, room.halfD, 0.26);
|
||||
const joists = useBayZ(room.depth, room.halfD, 1.7, 13);
|
||||
const iron = '#5c5852';
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Cast-iron columns with corbelled caps */}
|
||||
{corners.map(([x, z], i) => (
|
||||
<group key={i} position={[x, 0, z]}>
|
||||
<mesh position={[0, 0.12, 0]}>
|
||||
<cylinderGeometry args={[0.2, 0.24, 0.24, 12]} />
|
||||
<meshStandardMaterial color={shade(iron, -0.2)} roughness={0.62} metalness={0.42} />
|
||||
</mesh>
|
||||
<mesh position={[0, 2.05, 0]}>
|
||||
<cylinderGeometry args={[0.11, 0.13, 3.6, 12]} />
|
||||
<meshStandardMaterial color={iron} roughness={0.56} metalness={0.48} />
|
||||
</mesh>
|
||||
<mesh position={[0, 3.92, 0]}>
|
||||
<cylinderGeometry args={[0.22, 0.13, 0.2, 12]} />
|
||||
<meshStandardMaterial color={iron} roughness={0.54} metalness={0.5} />
|
||||
</mesh>
|
||||
<mesh position={[0, 4.06, 0]}>
|
||||
<boxGeometry args={[0.4, 0.14, 0.4]} />
|
||||
<meshStandardMaterial color={shade(iron, 0.1)} roughness={0.54} metalness={0.5} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Pressed-tin ceiling field on close-set joists */}
|
||||
<CeilingBeams
|
||||
room={room}
|
||||
positions={joists}
|
||||
axis="x"
|
||||
width={0.08}
|
||||
height={0.09}
|
||||
y={4.05}
|
||||
color="#b4b0a6"
|
||||
roughness={0.48}
|
||||
metalness={0.42}
|
||||
/>
|
||||
<CeilingBeams
|
||||
room={room}
|
||||
positions={[-room.width * 0.24, 0, room.width * 0.24]}
|
||||
axis="z"
|
||||
width={0.14}
|
||||
height={0.16}
|
||||
y={3.96}
|
||||
color={iron}
|
||||
roughness={0.6}
|
||||
metalness={0.4}
|
||||
/>
|
||||
|
||||
{/* Sprinkler main with drops */}
|
||||
<mesh position={[0, 3.78, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<cylinderGeometry args={[0.055, 0.055, room.depth * 0.92, 10]} />
|
||||
<meshStandardMaterial color="#7a3f2c" roughness={0.66} metalness={0.4} />
|
||||
</mesh>
|
||||
{joists
|
||||
.filter((_, i) => i % 4 === 0)
|
||||
.map((z, i) => (
|
||||
<mesh key={i} position={[0, 3.68, z]}>
|
||||
<cylinderGeometry args={[0.02, 0.025, 0.16, 6]} />
|
||||
<meshStandardMaterial color="#8a4a34" roughness={0.6} metalness={0.45} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{/* Steel skirting angle */}
|
||||
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||
<WallBand key={wall} wall={wall} room={room} y={0.09} height={0.18} proud={0.08} color={trim} roughness={0.56} metalness={0.44} />
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop Art gallery — Pop Art.
|
||||
*
|
||||
* The 1960s dealer's room: a flat white shell, recessed fluorescent troffers in
|
||||
* a ceiling grid, an aluminium reveal at the floor, and a shadow gap where a
|
||||
* cornice used to be. The architecture is engineered to disappear.
|
||||
*/
|
||||
export function WhiteCubeDetails({ room, trim }: PeriodProps) {
|
||||
const troffers = useMemo(() => {
|
||||
const cols = Math.max(2, Math.min(3, Math.round(room.width / 4.5)));
|
||||
const rows = Math.max(2, Math.min(6, Math.round(room.depth / 3.4)));
|
||||
const out: [number, number][] = [];
|
||||
for (let c = 0; c < cols; c++) {
|
||||
for (let r = 0; r < rows; r++) {
|
||||
out.push([
|
||||
-room.halfW + (room.width / (cols + 1)) * (c + 1),
|
||||
-room.halfD + (room.depth / (rows + 1)) * (r + 1),
|
||||
]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [room.width, room.depth, room.halfW, room.halfD]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Recessed fluorescent troffers */}
|
||||
{troffers.map(([x, z], i) => (
|
||||
<group key={i} position={[x, 4.08, z]}>
|
||||
<mesh>
|
||||
<boxGeometry args={[1.24, 0.06, 0.62]} />
|
||||
<meshStandardMaterial color="#c8c8c8" roughness={0.4} metalness={0.5} />
|
||||
</mesh>
|
||||
<mesh position={[0, -0.04, 0]}>
|
||||
<boxGeometry args={[1.1, 0.03, 0.5]} />
|
||||
<meshStandardMaterial
|
||||
color="#ffffff"
|
||||
emissive="#f4f8ff"
|
||||
emissiveIntensity={0.85}
|
||||
toneMapped={false}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Ceiling grid tees between the fittings */}
|
||||
<CeilingBeams
|
||||
room={room}
|
||||
positions={troffers.map(([, z]) => z).filter((z, i, a) => a.indexOf(z) === i)}
|
||||
axis="x"
|
||||
width={0.05}
|
||||
height={0.04}
|
||||
y={4.1}
|
||||
color="#d6d6d6"
|
||||
roughness={0.5}
|
||||
metalness={0.36}
|
||||
/>
|
||||
|
||||
{/* Shadow gap and aluminium skirting reveal */}
|
||||
<CorniceRing room={room} y={4.02} height={0.06} proud={0.02} color="#c0c0c0" roughness={0.66} metalness={0.16} />
|
||||
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||
<group key={wall}>
|
||||
<WallBand wall={wall} room={room} y={0.06} height={0.12} proud={0.05} color={trim} roughness={0.42} metalness={0.5} />
|
||||
<WallBand wall={wall} room={room} y={0.14} height={0.03} proud={0.02} color="#9a9a9a" roughness={0.6} metalness={0.3} />
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
import {
|
||||
CeilingBeams,
|
||||
CeilingRose,
|
||||
CorniceRing,
|
||||
HangingFixture,
|
||||
SideRails,
|
||||
WallBand,
|
||||
WallSconce,
|
||||
} from './primitives';
|
||||
import {
|
||||
shade,
|
||||
useBayZ,
|
||||
useRepeat,
|
||||
useWallClearance,
|
||||
type PeriodProps,
|
||||
} from './geometry';
|
||||
|
||||
/**
|
||||
* Romantic gallery — Romanticism (Gothic Revival).
|
||||
*
|
||||
* Pugin's revival vocabulary rather than a real nave: dark walnut linenfold
|
||||
* panelling, a cusped blind arcade of pointed arches, a quatrefoil frieze, and
|
||||
* gasoliers. The pointed arch here is applied ornament, which is exactly what
|
||||
* separates the revival from the Gothic hall it quotes.
|
||||
*/
|
||||
export function GothicRevivalDetails({ room, trim, windows }: PeriodProps) {
|
||||
const bays = useBayZ(room.depth, room.halfD, 3.8, 8);
|
||||
const clearLeft = useWallClearance(windows, 'left');
|
||||
const clearRight = useWallClearance(windows, 'right');
|
||||
const walnut = '#4c3323';
|
||||
const walnutLight = shade(walnut, 0.22);
|
||||
|
||||
const quatrefoils = useRepeat(room.depth, 0.95, 24);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Panelled dado with a heavy moulded rail */}
|
||||
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||
<group key={wall}>
|
||||
<WallBand wall={wall} room={room} y={0.62} height={1.2} proud={0.06} color={walnut} roughness={0.68} metalness={0.08} />
|
||||
<WallBand wall={wall} room={room} y={1.28} height={0.13} proud={0.11} color={walnutLight} roughness={0.52} metalness={0.12} />
|
||||
<WallBand wall={wall} room={room} y={0.09} height={0.18} proud={0.1} color={shade(walnut, -0.35)} roughness={0.62} metalness={0.08} />
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Colonnettes between the bays, carrying an applied pointed arcade */}
|
||||
{bays.map((z, i) => (
|
||||
<group key={i}>
|
||||
{[-1, 1].map((side) => (
|
||||
<group key={side} position={[side * (room.halfW - 0.06), 0, z]}>
|
||||
<mesh position={[0, 2.0, 0]}>
|
||||
<cylinderGeometry args={[0.055, 0.065, 3.9, 8]} />
|
||||
<meshStandardMaterial color={walnutLight} roughness={0.6} metalness={0.1} />
|
||||
</mesh>
|
||||
<mesh position={[0, 3.98, 0]}>
|
||||
<boxGeometry args={[0.13, 0.16, 0.22]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.2} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Cusped pointed arches over each bay, stepping around the windows */}
|
||||
{bays.slice(0, -1).map((z, i) => {
|
||||
const mid = (z + bays[i + 1]) / 2;
|
||||
// Capped so the raking limbs stay under the ceiling in deep halls.
|
||||
const half = Math.min(1.1, (bays[i + 1] - z) / 2 - 0.1);
|
||||
return (
|
||||
<group key={i}>
|
||||
{([-1, 1] as const)
|
||||
.filter((side) => (side < 0 ? clearLeft : clearRight)(mid, half))
|
||||
.map((side) => (
|
||||
<group key={side} position={[side * (room.halfW - 0.06), 0, mid]}>
|
||||
{[-1, 1].map((dir) => (
|
||||
<mesh
|
||||
key={dir}
|
||||
position={[0, 3.62, (dir * half) / 2]}
|
||||
rotation={[dir * 0.42, 0, 0]}
|
||||
>
|
||||
<boxGeometry args={[0.09, half * 1.12, 0.09]} />
|
||||
<meshStandardMaterial color={walnutLight} roughness={0.58} metalness={0.1} />
|
||||
</mesh>
|
||||
))}
|
||||
<mesh position={[0, 3.9, 0]}>
|
||||
<sphereGeometry args={[0.07, 8, 8]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.44} metalness={0.28} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Quatrefoil frieze under the cornice */}
|
||||
{(['left', 'right'] as const).map((wall) =>
|
||||
quatrefoils.map((z, i) => (
|
||||
<group
|
||||
key={`${wall}-${i}`}
|
||||
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.1), 4.02, z]}
|
||||
rotation={[0, Math.PI / 2, 0]}
|
||||
>
|
||||
{[0, 1, 2, 3].map((q) => {
|
||||
const a = (q / 4) * Math.PI * 2 + Math.PI / 4;
|
||||
return (
|
||||
<mesh key={q} position={[Math.cos(a) * 0.07, Math.sin(a) * 0.07, 0]}>
|
||||
<sphereGeometry args={[0.055, 8, 6]} />
|
||||
<meshStandardMaterial color={shade(walnut, 0.34)} roughness={0.6} metalness={0.1} />
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
))
|
||||
)}
|
||||
|
||||
<CorniceRing room={room} y={4.14} height={0.1} proud={0.15} color={walnutLight} roughness={0.5} metalness={0.14} />
|
||||
|
||||
{[-1, 1].map((side) => (
|
||||
<WallSconce
|
||||
key={side}
|
||||
wall={side < 0 ? 'left' : 'right'}
|
||||
room={room}
|
||||
along={side * room.halfD * 0.3}
|
||||
y={2.9}
|
||||
armColor={trim}
|
||||
glowColor="#ff9c46"
|
||||
shadeColor="#ffd8a0"
|
||||
/>
|
||||
))}
|
||||
|
||||
<HangingFixture
|
||||
position={[0, 4.0, 0]}
|
||||
dropLength={0.55}
|
||||
radius={0.36}
|
||||
arms={6}
|
||||
metalColor={trim}
|
||||
flameColor="#ffdca0"
|
||||
glowColor="#ff9a3c"
|
||||
light
|
||||
lightColor="#ffb268"
|
||||
lightIntensity={0.8}
|
||||
lightDistance={9}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Realist picture gallery — Realism.
|
||||
*
|
||||
* The mid-century bourgeois interior the Realists exhibited against: a plaster
|
||||
* dado and picture rail, run cornice with a moulded frieze, a ceiling rose, and
|
||||
* gas brackets. Restrained, machine-made mouldings — no carving anywhere.
|
||||
*/
|
||||
export function BourgeoisSalonDetails({ room, trim }: PeriodProps) {
|
||||
const plaster = '#efe8dc';
|
||||
const shadow = shade(plaster, -0.12);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Dado, chair rail, skirting */}
|
||||
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||
<group key={wall}>
|
||||
<WallBand wall={wall} room={room} y={0.5} height={0.96} proud={0.04} color={shadow} roughness={0.78} metalness={0.05} />
|
||||
<WallBand wall={wall} room={room} y={1.03} height={0.09} proud={0.09} color={plaster} roughness={0.7} metalness={0.06} />
|
||||
<WallBand wall={wall} room={room} y={0.09} height={0.18} proud={0.1} color={plaster} roughness={0.74} metalness={0.05} />
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Picture rail just above the hang, as the period actually hung its walls */}
|
||||
<SideRails room={room} y={2.52} height={0.07} proud={0.09} color={trim} roughness={0.55} metalness={0.2} />
|
||||
|
||||
{/* Run cornice: cavetto, bead and a moulded frieze */}
|
||||
<CorniceRing room={room} y={3.9} height={0.1} proud={0.07} color={shadow} roughness={0.76} metalness={0.05} />
|
||||
<CorniceRing room={room} y={4.0} height={0.12} proud={0.13} color={plaster} roughness={0.72} metalness={0.05} />
|
||||
<CorniceRing room={room} y={4.13} height={0.07} proud={0.17} color={shade(plaster, 0.06)} roughness={0.7} metalness={0.06} />
|
||||
|
||||
<CeilingRose y={4.09} radius={0.85} color={shade(plaster, 0.04)} rings={3} />
|
||||
|
||||
{[-1, 1].map((side) => (
|
||||
<WallSconce
|
||||
key={side}
|
||||
wall={side < 0 ? 'left' : 'right'}
|
||||
room={room}
|
||||
along={side * room.halfD * 0.34}
|
||||
y={2.78}
|
||||
armColor={trim}
|
||||
/>
|
||||
))}
|
||||
|
||||
<HangingFixture
|
||||
position={[0, 4.04, 0]}
|
||||
dropLength={0.62}
|
||||
radius={0.3}
|
||||
arms={4}
|
||||
metalColor={trim}
|
||||
flameColor="#fff0cc"
|
||||
glowColor="#ffbe6a"
|
||||
light
|
||||
lightColor="#ffd8a8"
|
||||
lightIntensity={0.75}
|
||||
lightDistance={9}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Impressionist salon — Impressionism.
|
||||
*
|
||||
* The 1874 Boulevard des Capucines idea of a hung room: an iron-and-glass
|
||||
* lantern overhead, a coved plaster cornice, and a velvet-faced picture rail
|
||||
* with visible hanging rods above the frames. Nothing carved, everything pale.
|
||||
*/
|
||||
export function NorthLightSalonDetails({ room, trim }: PeriodProps) {
|
||||
const iron = '#6d6a63';
|
||||
const plaster = '#f4f1ea';
|
||||
const trusses = useBayZ(room.depth, room.halfD, 2.4, 11);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Skylight lantern: glazing bars across a shallow raised centre */}
|
||||
<mesh position={[0, 4.11, 0]}>
|
||||
<boxGeometry args={[Math.min(room.width - 2.2, 6.4), 0.06, Math.min(room.depth - 2.2, 14)]} />
|
||||
<meshStandardMaterial
|
||||
color="#ffffff"
|
||||
emissive="#eaf2ff"
|
||||
emissiveIntensity={0.55}
|
||||
roughness={0.4}
|
||||
metalness={0.05}
|
||||
/>
|
||||
</mesh>
|
||||
<CeilingBeams
|
||||
room={room}
|
||||
positions={trusses}
|
||||
axis="x"
|
||||
width={0.07}
|
||||
height={0.1}
|
||||
y={4.05}
|
||||
color={iron}
|
||||
roughness={0.5}
|
||||
metalness={0.55}
|
||||
/>
|
||||
{[-1, 1].map((side) => (
|
||||
<mesh key={side} position={[(side * Math.min(room.width - 2.2, 6.4)) / 2, 4.03, 0]}>
|
||||
<boxGeometry args={[0.1, 0.18, Math.min(room.depth - 2.2, 14)]} />
|
||||
<meshStandardMaterial color={iron} roughness={0.48} metalness={0.6} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{/* Picture rail with hanging rods dropping to the frame line */}
|
||||
<SideRails room={room} y={2.6} height={0.09} proud={0.1} color={trim} roughness={0.52} metalness={0.24} />
|
||||
<WallBand wall="back" room={room} y={2.6} height={0.09} proud={0.1} color={trim} roughness={0.52} metalness={0.24} />
|
||||
|
||||
{/* Coved cornice */}
|
||||
<CorniceRing room={room} y={3.94} height={0.14} proud={0.06} color={shade(plaster, -0.06)} roughness={0.8} metalness={0.04} />
|
||||
<CorniceRing room={room} y={4.07} height={0.1} proud={0.12} color={plaster} roughness={0.78} metalness={0.04} />
|
||||
|
||||
{/* Dark skirting so the pale walls sit on something */}
|
||||
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||
<WallBand key={wall} wall={wall} room={room} y={0.1} height={0.2} proud={0.09} color="#b6a88f" roughness={0.7} metalness={0.06} />
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Montmartre atelier — Post-Impressionism.
|
||||
*
|
||||
* A working painter's room rather than a gallery: a king-post roof truss under
|
||||
* the slope, a cast-iron stove flue climbing the end wall, plain board skirting
|
||||
* and a rough shelf rail carrying the studio's clutter line.
|
||||
*/
|
||||
export function ParisAtelierDetails({ room, trim }: PeriodProps) {
|
||||
const trusses = useBayZ(room.depth, room.halfD, 3.1, 8);
|
||||
const timber = '#8a6a48';
|
||||
const iron = '#3a3632';
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Roof trusses: tie beam, king post and raking struts */}
|
||||
{trusses.map((z, i) => {
|
||||
// Struts run from the head of the king post down to the tie beam, and
|
||||
// have to stay inside the 0.3 m the ceiling leaves above the beam.
|
||||
const run = room.halfW - 0.6;
|
||||
const rise = 0.16;
|
||||
const strut = Math.hypot(run, rise);
|
||||
return (
|
||||
<group key={i} position={[0, 0, z]}>
|
||||
<mesh position={[0, 3.9, 0]}>
|
||||
<boxGeometry args={[room.width - 0.1, 0.16, 0.16]} />
|
||||
<meshStandardMaterial color={timber} roughness={0.86} metalness={0.03} />
|
||||
</mesh>
|
||||
<mesh position={[0, 4.02, 0]}>
|
||||
<boxGeometry args={[0.13, 0.24, 0.13]} />
|
||||
<meshStandardMaterial color={timber} roughness={0.86} metalness={0.03} />
|
||||
</mesh>
|
||||
{[-1, 1].map((side) => (
|
||||
<mesh
|
||||
key={side}
|
||||
position={[(side * run) / 2, 4.06, 0]}
|
||||
rotation={[0, 0, side * Math.atan2(-rise, run)]}
|
||||
>
|
||||
<boxGeometry args={[strut, 0.09, 0.11]} />
|
||||
<meshStandardMaterial color={shade(timber, -0.16)} roughness={0.88} metalness={0.03} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Stove flue running up the end wall and out along the ceiling */}
|
||||
<mesh position={[room.halfW - 0.28, 2.1, -room.halfD + 0.28]}>
|
||||
<cylinderGeometry args={[0.09, 0.09, 4.0, 10]} />
|
||||
<meshStandardMaterial color={iron} roughness={0.62} metalness={0.42} />
|
||||
</mesh>
|
||||
<mesh position={[room.halfW - 0.28, 4.02, -room.halfD + 0.28]}>
|
||||
<cylinderGeometry args={[0.12, 0.12, 0.14, 10]} />
|
||||
<meshStandardMaterial color={shade(iron, 0.14)} roughness={0.58} metalness={0.5} />
|
||||
</mesh>
|
||||
|
||||
{/* Board skirting and a rough shelf rail above the hang */}
|
||||
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||
<group key={wall}>
|
||||
<WallBand wall={wall} room={room} y={0.12} height={0.24} proud={0.06} color={shade(timber, -0.2)} roughness={0.88} metalness={0.03} />
|
||||
<WallBand wall={wall} room={room} y={2.66} height={0.06} proud={0.13} color={timber} roughness={0.84} metalness={0.04} />
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* A single bare bulb on a flex, the way the ateliers were actually lit */}
|
||||
<mesh position={[0, 3.7, 0]}>
|
||||
<cylinderGeometry args={[0.008, 0.008, 0.5, 6]} />
|
||||
<meshStandardMaterial color={iron} roughness={0.6} metalness={0.3} />
|
||||
</mesh>
|
||||
<mesh position={[0, 3.42, 0]}>
|
||||
<sphereGeometry args={[0.09, 12, 12]} />
|
||||
<meshStandardMaterial color="#fff2d0" emissive="#ffc061" emissiveIntensity={0.95} toneMapped={false} />
|
||||
</mesh>
|
||||
<pointLight position={[0, 3.4, 0]} intensity={0.9} distance={9} color={trim} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Symbolist chamber — Symbolism.
|
||||
*
|
||||
* The aesthetic interior: a stencilled frieze of repeating motifs, a heavy
|
||||
* portière rail, dark lacquered dado, and low amber sconces. Light is kept
|
||||
* pooled and low, which is the whole point of the period's rooms.
|
||||
*/
|
||||
export function SymbolistDetails({ room, trim }: PeriodProps) {
|
||||
const lacquer = '#12281f';
|
||||
const stencil = shade(trim, 0.1);
|
||||
|
||||
const motifs = useRepeat(room.depth, 0.8, 26);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Lacquered dado under a gilt bead */}
|
||||
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||
<group key={wall}>
|
||||
<WallBand wall={wall} room={room} y={0.54} height={1.04} proud={0.05} color={lacquer} roughness={0.42} metalness={0.16} />
|
||||
<WallBand wall={wall} room={room} y={1.1} height={0.06} proud={0.09} color={trim} roughness={0.38} metalness={0.44} />
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Stencilled frieze — a repeating lotus/flame motif under the cornice */}
|
||||
{(['left', 'right'] as const).map((wall) => (
|
||||
<group key={wall}>
|
||||
<WallBand wall={wall} room={room} y={3.86} height={0.34} proud={0.04} color={shade(lacquer, 0.1)} roughness={0.6} metalness={0.12} />
|
||||
{motifs.map((z, i) => (
|
||||
<group key={i} position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.11), 3.86, z]} rotation={[0, Math.PI / 2, 0]}>
|
||||
<mesh>
|
||||
<coneGeometry args={[0.09, 0.24, 5]} />
|
||||
<meshStandardMaterial color={stencil} roughness={0.44} metalness={0.36} />
|
||||
</mesh>
|
||||
<mesh position={[0, -0.16, 0]}>
|
||||
<sphereGeometry args={[0.045, 8, 6]} />
|
||||
<meshStandardMaterial color={stencil} roughness={0.44} metalness={0.36} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
))}
|
||||
|
||||
<CorniceRing room={room} y={4.08} height={0.12} proud={0.13} color={shade(lacquer, 0.16)} roughness={0.5} metalness={0.18} />
|
||||
<CorniceRing room={room} y={4.16} height={0.05} proud={0.16} color={trim} roughness={0.36} metalness={0.5} />
|
||||
|
||||
{/* Portière rail across the end wall */}
|
||||
<WallBand wall="back" room={room} y={2.72} height={0.08} proud={0.12} color={trim} roughness={0.4} metalness={0.44} />
|
||||
|
||||
{[-0.36, 0.36].map((t) => (
|
||||
<WallSconce
|
||||
key={t}
|
||||
wall={t < 0 ? 'left' : 'right'}
|
||||
room={room}
|
||||
along={t * room.halfD}
|
||||
y={2.94}
|
||||
armColor={trim}
|
||||
glowColor="#ff8a2e"
|
||||
shadeColor="#ffcf94"
|
||||
/>
|
||||
))}
|
||||
|
||||
<HangingFixture
|
||||
position={[0, 4.04, 0]}
|
||||
dropLength={0.66}
|
||||
radius={0.26}
|
||||
arms={5}
|
||||
metalColor={trim}
|
||||
flameColor="#ffd9a0"
|
||||
glowColor="#ff8f2c"
|
||||
light
|
||||
lightColor="#ffa050"
|
||||
lightIntensity={0.7}
|
||||
lightDistance={8}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Art Nouveau salon — Art Nouveau.
|
||||
*
|
||||
* Horta's structural line: whiplash brackets springing from the wall into the
|
||||
* ceiling, tendrils running the cornice, and organic sconces. The curve is
|
||||
* carried by the structure itself rather than applied as ornament.
|
||||
*/
|
||||
export function ArtNouveauDetails({ room, trim }: PeriodProps) {
|
||||
const bays = useBayZ(room.depth, room.halfD, 3.0, 10);
|
||||
const iron = shade(trim, -0.24);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Whiplash brackets: a stem up the wall curling into the ceiling */}
|
||||
{bays.map((z, i) => (
|
||||
<group key={i}>
|
||||
{[-1, 1].map((side) => (
|
||||
<group key={side} position={[side * (room.halfW - 0.07), 0, z]}>
|
||||
<mesh position={[0, 2.05, 0]}>
|
||||
<cylinderGeometry args={[0.045, 0.06, 4.0, 8]} />
|
||||
<meshStandardMaterial color={iron} roughness={0.44} metalness={0.42} />
|
||||
</mesh>
|
||||
{[0, 1, 2, 3].map((s) => {
|
||||
const t = (s + 1) / 4;
|
||||
return (
|
||||
<mesh
|
||||
key={s}
|
||||
position={[-side * (0.1 + t * 0.32), 4.02 - t * t * 0.22, 0]}
|
||||
rotation={[0, 0, side * (0.35 + t * 0.7)]}
|
||||
>
|
||||
<cylinderGeometry args={[0.035, 0.045, 0.34, 6]} />
|
||||
<meshStandardMaterial color={iron} roughness={0.44} metalness={0.42} />
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
{/* Bud terminal */}
|
||||
<mesh position={[-side * 0.52, 3.78, 0]}>
|
||||
<sphereGeometry args={[0.07, 10, 8]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.36} metalness={0.5} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Tendril cornice — a wave riding the top of every wall */}
|
||||
<CorniceRing room={room} y={4.05} height={0.11} proud={0.11} color={shade(trim, 0.18)} roughness={0.5} metalness={0.3} />
|
||||
<CorniceRing room={room} y={4.15} height={0.06} proud={0.15} color={trim} roughness={0.42} metalness={0.42} />
|
||||
|
||||
{/* Sinuous dado line */}
|
||||
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||
<group key={wall}>
|
||||
<WallBand wall={wall} room={room} y={0.11} height={0.22} proud={0.08} color={shade(trim, -0.3)} roughness={0.52} metalness={0.28} />
|
||||
<WallBand wall={wall} room={room} y={0.86} height={0.05} proud={0.07} color={trim} roughness={0.44} metalness={0.4} />
|
||||
</group>
|
||||
))}
|
||||
|
||||
{[-0.3, 0.3].map((t) => (
|
||||
<WallSconce
|
||||
key={t}
|
||||
wall={t < 0 ? 'left' : 'right'}
|
||||
room={room}
|
||||
along={t * room.halfD}
|
||||
y={2.88}
|
||||
armColor={trim}
|
||||
glowColor="#8fd66a"
|
||||
shadeColor="#e8ffd8"
|
||||
/>
|
||||
))}
|
||||
|
||||
<HangingFixture
|
||||
position={[0, 4.0, 0]}
|
||||
dropLength={0.5}
|
||||
radius={0.44}
|
||||
arms={7}
|
||||
metalColor={trim}
|
||||
flameColor="#f2ffe0"
|
||||
glowColor="#a8d86a"
|
||||
light
|
||||
lightColor="#dff2c0"
|
||||
lightIntensity={0.75}
|
||||
lightDistance={9}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
import { useMemo } from 'react';
|
||||
import { CEILING_Y, EMBED, WALL_INNER, shade, type RoomDims, type WallId } from './geometry';
|
||||
|
||||
/** Wall-flush building blocks shared by the period interiors in this folder. */
|
||||
interface SurfaceProps {
|
||||
color: string;
|
||||
roughness?: number;
|
||||
metalness?: number;
|
||||
emissive?: string;
|
||||
emissiveIntensity?: number;
|
||||
}
|
||||
|
||||
/** A moulding running the length of one wall, flush against its inner face. */
|
||||
export function WallBand({
|
||||
wall,
|
||||
room,
|
||||
y,
|
||||
height,
|
||||
proud = 0.1,
|
||||
span = 1,
|
||||
color,
|
||||
roughness = 0.7,
|
||||
metalness = 0.06,
|
||||
emissive,
|
||||
emissiveIntensity,
|
||||
}: SurfaceProps & {
|
||||
wall: WallId;
|
||||
room: RoomDims;
|
||||
y: number;
|
||||
height: number;
|
||||
proud?: number;
|
||||
span?: number;
|
||||
}) {
|
||||
const alongZ = wall === 'left' || wall === 'right';
|
||||
const len = (alongZ ? room.depth : room.width) * span;
|
||||
const thick = proud + EMBED * 2;
|
||||
const off = WALL_INNER + proud / 2 - EMBED;
|
||||
const args: [number, number, number] = alongZ ? [thick, height, len] : [len, height, thick];
|
||||
const pos: [number, number, number] =
|
||||
wall === 'back'
|
||||
? [0, y, -room.halfD + off]
|
||||
: wall === 'front'
|
||||
? [0, y, room.halfD - off]
|
||||
: wall === 'left'
|
||||
? [-room.halfW + off, y, 0]
|
||||
: [room.halfW - off, y, 0];
|
||||
|
||||
return (
|
||||
<mesh position={pos}>
|
||||
<boxGeometry args={args} />
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
roughness={roughness}
|
||||
metalness={metalness}
|
||||
emissive={emissive}
|
||||
emissiveIntensity={emissiveIntensity}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
/** A moulding ring running right around the room at one height. */
|
||||
export function CorniceRing({
|
||||
room,
|
||||
y,
|
||||
height,
|
||||
proud = 0.12,
|
||||
walls = ['back', 'front', 'left', 'right'],
|
||||
...surface
|
||||
}: SurfaceProps & {
|
||||
room: RoomDims;
|
||||
y: number;
|
||||
height: number;
|
||||
proud?: number;
|
||||
walls?: WallId[];
|
||||
}) {
|
||||
return (
|
||||
<group>
|
||||
{walls.map((wall) => (
|
||||
<WallBand key={wall} wall={wall} room={room} y={y} height={height} proud={proud} {...surface} />
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/** String course / picture rail on the two side walls only. */
|
||||
export function SideRails({
|
||||
room,
|
||||
y,
|
||||
height,
|
||||
proud = 0.1,
|
||||
...surface
|
||||
}: SurfaceProps & { room: RoomDims; y: number; height: number; proud?: number }) {
|
||||
return (
|
||||
<group>
|
||||
{(['left', 'right'] as const).map((wall) => (
|
||||
<WallBand key={wall} wall={wall} room={room} y={y} height={height} proud={proud} {...surface} />
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical shaft with carved flutes, read as alternating light stone and
|
||||
* shadowed grooves. Sits flush: the caller places it against a wall face.
|
||||
*/
|
||||
export function FlutedShaft({
|
||||
height,
|
||||
radius,
|
||||
color,
|
||||
flutes = 12,
|
||||
taper = 0.9,
|
||||
facing = 'x',
|
||||
}: {
|
||||
height: number;
|
||||
radius: number;
|
||||
color: string;
|
||||
flutes?: number;
|
||||
taper?: number;
|
||||
/** Wall normal axis — grooves are only cut on the exposed half. */
|
||||
facing?: 'x' | 'z';
|
||||
}) {
|
||||
const groove = shade(color, -0.28);
|
||||
const arcs = useMemo(
|
||||
() => Array.from({ length: flutes }, (_, i) => (i / flutes) * Math.PI * 2),
|
||||
[flutes]
|
||||
);
|
||||
return (
|
||||
<group>
|
||||
<mesh position={[0, height / 2, 0]}>
|
||||
<cylinderGeometry args={[radius * taper, radius, height, 16]} />
|
||||
<meshStandardMaterial color={color} roughness={0.74} metalness={0.05} />
|
||||
</mesh>
|
||||
{arcs.map((a, i) => {
|
||||
const dx = Math.cos(a) * radius * 0.94;
|
||||
const dz = Math.sin(a) * radius * 0.94;
|
||||
// Skip the grooves buried in the wall behind the shaft.
|
||||
if (facing === 'x' ? dx > 0 : dz > 0) return null;
|
||||
return (
|
||||
<mesh key={i} position={[dx, height / 2, dz]}>
|
||||
<cylinderGeometry args={[radius * 0.16, radius * 0.16, height * 0.98, 6]} />
|
||||
<meshStandardMaterial color={groove} roughness={0.88} metalness={0.03} />
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export function TuscanCapital({ y, size, color }: { y: number; size: number; color: string }) {
|
||||
return (
|
||||
<group position={[0, y, 0]}>
|
||||
<mesh>
|
||||
<cylinderGeometry args={[size * 1.15, size * 0.85, size * 0.42, 14]} />
|
||||
<meshStandardMaterial color={color} roughness={0.7} metalness={0.05} />
|
||||
</mesh>
|
||||
<mesh position={[0, size * 0.36, 0]}>
|
||||
<boxGeometry args={[size * 2.5, size * 0.28, size * 2.5]} />
|
||||
<meshStandardMaterial color={shade(color, 0.08)} roughness={0.68} metalness={0.05} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ionic capital — abacus over a pair of spiral volutes. */
|
||||
export function IonicCapital({
|
||||
y,
|
||||
size,
|
||||
color,
|
||||
axis = 'z',
|
||||
}: {
|
||||
y: number;
|
||||
size: number;
|
||||
color: string;
|
||||
/** Axis the volute pair spreads along. */
|
||||
axis?: 'x' | 'z';
|
||||
}) {
|
||||
return (
|
||||
<group position={[0, y, 0]}>
|
||||
<mesh position={[0, size * 0.1, 0]}>
|
||||
<boxGeometry args={[size * 2.4, size * 0.34, size * 1.5]} />
|
||||
<meshStandardMaterial color={color} roughness={0.66} metalness={0.06} />
|
||||
</mesh>
|
||||
{[-1, 1].map((s) => (
|
||||
<mesh
|
||||
key={s}
|
||||
position={axis === 'z' ? [0, -size * 0.12, s * size * 0.85] : [s * size * 0.85, -size * 0.12, 0]}
|
||||
rotation={axis === 'z' ? [0, Math.PI / 2, 0] : [0, 0, 0]}
|
||||
>
|
||||
<torusGeometry args={[size * 0.38, size * 0.14, 8, 16]} />
|
||||
<meshStandardMaterial color={shade(color, 0.06)} roughness={0.62} metalness={0.07} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Moulded plinth under a shaft. */
|
||||
export function ShaftBase({ size, color }: { size: number; color: string }) {
|
||||
return (
|
||||
<group>
|
||||
<mesh position={[0, 0.08, 0]}>
|
||||
<boxGeometry args={[size * 2.4, 0.16, size * 2.4]} />
|
||||
<meshStandardMaterial color={color} roughness={0.78} metalness={0.04} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.21, 0]}>
|
||||
<cylinderGeometry args={[size * 1.0, size * 1.15, 0.1, 14]} />
|
||||
<meshStandardMaterial color={shade(color, 0.06)} roughness={0.72} metalness={0.05} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Semicircular arch drawn as a chord of straight voussoir blocks. */
|
||||
export function RoundArch({
|
||||
span,
|
||||
rise,
|
||||
thickness = 0.14,
|
||||
depth = 0.16,
|
||||
color,
|
||||
segments = 11,
|
||||
roughness = 0.72,
|
||||
metalness = 0.05,
|
||||
}: {
|
||||
span: number;
|
||||
rise: number;
|
||||
thickness?: number;
|
||||
depth?: number;
|
||||
color: string;
|
||||
segments?: number;
|
||||
roughness?: number;
|
||||
metalness?: number;
|
||||
}) {
|
||||
const blocks = useMemo(() => {
|
||||
const out: { x: number; y: number; len: number; rot: number }[] = [];
|
||||
for (let s = 0; s < segments; s++) {
|
||||
const t0 = s / segments;
|
||||
const t1 = (s + 1) / segments;
|
||||
const at = (t: number) => {
|
||||
const a = Math.PI * (1 - t);
|
||||
return { x: Math.cos(a) * (span / 2), y: Math.sin(a) * rise };
|
||||
};
|
||||
const p0 = at(t0);
|
||||
const p1 = at(t1);
|
||||
out.push({
|
||||
x: (p0.x + p1.x) / 2,
|
||||
y: (p0.y + p1.y) / 2,
|
||||
len: Math.hypot(p1.x - p0.x, p1.y - p0.y) * 1.08,
|
||||
rot: Math.atan2(p1.y - p0.y, p1.x - p0.x),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, [span, rise, segments]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{blocks.map((b, i) => (
|
||||
<mesh key={i} position={[b.x, b.y, 0]} rotation={[0, 0, b.rot]}>
|
||||
<boxGeometry args={[b.len, thickness, depth]} />
|
||||
<meshStandardMaterial color={color} roughness={roughness} metalness={metalness} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Recessed coffer grid under the ceiling plane. */
|
||||
export function CofferedCeiling({
|
||||
room,
|
||||
y = CEILING_Y,
|
||||
cell = 2.3,
|
||||
panelColor,
|
||||
ribColor,
|
||||
ribWidth = 0.16,
|
||||
ribDrop = 0.12,
|
||||
rosette,
|
||||
}: {
|
||||
room: RoomDims;
|
||||
y?: number;
|
||||
cell?: number;
|
||||
panelColor: string;
|
||||
ribColor: string;
|
||||
ribWidth?: number;
|
||||
ribDrop?: number;
|
||||
rosette?: string;
|
||||
}) {
|
||||
const grid = useMemo(() => {
|
||||
const cols = Math.max(2, Math.min(6, Math.round(room.width / cell)));
|
||||
const rows = Math.max(2, Math.min(8, Math.round(room.depth / cell)));
|
||||
const cw = room.width / cols;
|
||||
const cd = room.depth / rows;
|
||||
const cells: { x: number; z: number }[] = [];
|
||||
for (let c = 0; c < cols; c++) {
|
||||
for (let r = 0; r < rows; r++) {
|
||||
cells.push({ x: -room.halfW + cw * (c + 0.5), z: -room.halfD + cd * (r + 0.5) });
|
||||
}
|
||||
}
|
||||
return { cells, cw, cd, cols, rows };
|
||||
}, [room.width, room.depth, room.halfW, room.halfD, cell]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{grid.cells.map((c, i) => (
|
||||
<group key={i} position={[c.x, y, c.z]}>
|
||||
<mesh position={[0, 0.01, 0]}>
|
||||
<boxGeometry args={[grid.cw - ribWidth, 0.03, grid.cd - ribWidth]} />
|
||||
<meshStandardMaterial color={panelColor} roughness={0.86} metalness={0.04} />
|
||||
</mesh>
|
||||
{rosette && (
|
||||
<mesh position={[0, -ribDrop * 0.35, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[grid.cw * 0.06, grid.cw * 0.15, 12]} />
|
||||
<meshStandardMaterial color={rosette} roughness={0.4} metalness={0.5} side={2} />
|
||||
</mesh>
|
||||
)}
|
||||
</group>
|
||||
))}
|
||||
{/* Ribs between the coffers */}
|
||||
{Array.from({ length: grid.cols + 1 }, (_, c) => (
|
||||
<mesh key={`c${c}`} position={[-room.halfW + grid.cw * c, y - ribDrop / 2, 0]}>
|
||||
<boxGeometry args={[ribWidth, ribDrop, room.depth]} />
|
||||
<meshStandardMaterial color={ribColor} roughness={0.7} metalness={0.08} />
|
||||
</mesh>
|
||||
))}
|
||||
{Array.from({ length: grid.rows + 1 }, (_, r) => (
|
||||
<mesh key={`r${r}`} position={[0, y - ribDrop / 2, -room.halfD + grid.cd * r]}>
|
||||
<boxGeometry args={[room.width, ribDrop, ribWidth]} />
|
||||
<meshStandardMaterial color={ribColor} roughness={0.7} metalness={0.08} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Exposed structural beams spanning the room. */
|
||||
export function CeilingBeams({
|
||||
room,
|
||||
positions,
|
||||
axis = 'x',
|
||||
width = 0.22,
|
||||
height = 0.3,
|
||||
y = CEILING_Y - 0.16,
|
||||
color,
|
||||
roughness = 0.82,
|
||||
metalness = 0.05,
|
||||
}: SurfaceProps & {
|
||||
room: RoomDims;
|
||||
positions: number[];
|
||||
/** Axis the beams run along. */
|
||||
axis?: 'x' | 'z';
|
||||
width?: number;
|
||||
height?: number;
|
||||
y?: number;
|
||||
}) {
|
||||
return (
|
||||
<group>
|
||||
{positions.map((p, i) => (
|
||||
<mesh key={i} position={axis === 'x' ? [0, y, p] : [p, y, 0]}>
|
||||
<boxGeometry
|
||||
args={axis === 'x' ? [room.width, height, width] : [width, height, room.depth]}
|
||||
/>
|
||||
<meshStandardMaterial color={color} roughness={roughness} metalness={metalness} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Plaster ceiling rose, the anchor a salon chandelier hangs from. */
|
||||
export function CeilingRose({
|
||||
y = CEILING_Y,
|
||||
radius = 1.0,
|
||||
color,
|
||||
rings = 3,
|
||||
}: {
|
||||
y?: number;
|
||||
radius?: number;
|
||||
color: string;
|
||||
rings?: number;
|
||||
}) {
|
||||
return (
|
||||
<group position={[0, y, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
{Array.from({ length: rings }, (_, i) => {
|
||||
const t = (i + 1) / rings;
|
||||
return (
|
||||
<mesh key={i} position={[0, 0, i * 0.012]}>
|
||||
<ringGeometry args={[radius * t * 0.55, radius * t, 32]} />
|
||||
<meshStandardMaterial color={shade(color, i * 0.06)} roughness={0.7} metalness={0.12} side={2} />
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hanging light fixture. `light` adds a real point light — keep at most two per
|
||||
* hall so the shared track lighting stays inside the WebGL light budget.
|
||||
*/
|
||||
export function HangingFixture({
|
||||
position,
|
||||
dropLength = 0.6,
|
||||
radius = 0.42,
|
||||
arms = 6,
|
||||
metalColor,
|
||||
flameColor = '#ffcf80',
|
||||
glowColor = '#ff9830',
|
||||
light = false,
|
||||
lightColor = '#ffb060',
|
||||
lightIntensity = 0.8,
|
||||
lightDistance = 7,
|
||||
}: {
|
||||
position: [number, number, number];
|
||||
dropLength?: number;
|
||||
radius?: number;
|
||||
arms?: number;
|
||||
metalColor: string;
|
||||
flameColor?: string;
|
||||
glowColor?: string;
|
||||
light?: boolean;
|
||||
lightColor?: string;
|
||||
lightIntensity?: number;
|
||||
lightDistance?: number;
|
||||
}) {
|
||||
const ringY = position[1] - dropLength;
|
||||
return (
|
||||
<group position={[position[0], 0, position[2]]}>
|
||||
<mesh position={[0, position[1] - dropLength / 2, 0]}>
|
||||
<cylinderGeometry args={[0.012, 0.012, dropLength, 6]} />
|
||||
<meshStandardMaterial color={shade(metalColor, -0.4)} roughness={0.42} metalness={0.7} />
|
||||
</mesh>
|
||||
<mesh position={[0, ringY, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[radius, 0.028, 8, 22]} />
|
||||
<meshStandardMaterial color={metalColor} roughness={0.26} metalness={0.82} />
|
||||
</mesh>
|
||||
{Array.from({ length: arms }, (_, i) => {
|
||||
const a = (i / arms) * Math.PI * 2;
|
||||
return (
|
||||
<mesh key={i} position={[Math.cos(a) * radius, ringY - 0.09, Math.sin(a) * radius]}>
|
||||
<sphereGeometry args={[0.05, 8, 8]} />
|
||||
<meshStandardMaterial
|
||||
color={flameColor}
|
||||
emissive={glowColor}
|
||||
emissiveIntensity={0.9}
|
||||
toneMapped={false}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
{light && (
|
||||
<pointLight
|
||||
position={[0, ringY - 0.09, 0]}
|
||||
intensity={lightIntensity}
|
||||
distance={lightDistance}
|
||||
color={lightColor}
|
||||
/>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wall light mounted above the frame line. Emissive only — sconces are for
|
||||
* period character, not for lighting the hall.
|
||||
*/
|
||||
export function WallSconce({
|
||||
wall,
|
||||
room,
|
||||
along,
|
||||
y = 2.86,
|
||||
armColor,
|
||||
glowColor = '#ffb45a',
|
||||
shadeColor = '#ffdca8',
|
||||
}: {
|
||||
wall: 'left' | 'right' | 'back';
|
||||
room: RoomDims;
|
||||
/** Offset along the wall from its centre. */
|
||||
along: number;
|
||||
y?: number;
|
||||
armColor: string;
|
||||
glowColor?: string;
|
||||
shadeColor?: string;
|
||||
}) {
|
||||
const pos: [number, number, number] =
|
||||
wall === 'back'
|
||||
? [along, y, -room.halfD + WALL_INNER + 0.02]
|
||||
: wall === 'left'
|
||||
? [-room.halfW + WALL_INNER + 0.02, y, along]
|
||||
: [room.halfW - WALL_INNER - 0.02, y, along];
|
||||
const rot: [number, number, number] =
|
||||
wall === 'back' ? [0, 0, 0] : wall === 'left' ? [0, Math.PI / 2, 0] : [0, -Math.PI / 2, 0];
|
||||
|
||||
return (
|
||||
<group position={pos} rotation={rot}>
|
||||
<mesh position={[0, 0, 0.03]}>
|
||||
<boxGeometry args={[0.16, 0.22, 0.05]} />
|
||||
<meshStandardMaterial color={armColor} roughness={0.34} metalness={0.68} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.06, 0.09]} rotation={[Math.PI / 2.6, 0, 0]}>
|
||||
<cylinderGeometry args={[0.012, 0.012, 0.16, 6]} />
|
||||
<meshStandardMaterial color={armColor} roughness={0.34} metalness={0.68} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.15, 0.13]}>
|
||||
<sphereGeometry args={[0.075, 10, 10]} />
|
||||
<meshStandardMaterial
|
||||
color={shadeColor}
|
||||
emissive={glowColor}
|
||||
emissiveIntensity={0.75}
|
||||
toneMapped={false}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -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,9 +52,44 @@ 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;
|
||||
/**
|
||||
* Which period architecture the hall is built from. Every movement has its
|
||||
* own — the interiors are not shared between movements — and each kind maps
|
||||
* to one component under `components/hall-details/`.
|
||||
*/
|
||||
details: MovementDetailKind;
|
||||
}
|
||||
|
||||
export type MovementDetailKind =
|
||||
| 'roman'
|
||||
| 'byzantine'
|
||||
| 'gothic'
|
||||
| 'quattrocento'
|
||||
| 'cinquecento'
|
||||
| 'flemish'
|
||||
| 'mannerist'
|
||||
| 'baroque'
|
||||
| 'rococo'
|
||||
| 'neoclassical'
|
||||
| 'gothic-revival'
|
||||
| 'bourgeois'
|
||||
| 'north-light'
|
||||
| 'paris-atelier'
|
||||
| 'symbolist'
|
||||
| 'art-nouveau'
|
||||
| 'fauvist'
|
||||
| 'expressionist'
|
||||
| 'cubist-atelier'
|
||||
| 'futurist'
|
||||
| 'suprematist'
|
||||
| 'constructivist'
|
||||
| 'dada'
|
||||
| 'surrealist'
|
||||
| 'loft'
|
||||
| 'white-cube';
|
||||
|
||||
function windows(...specs: GalleryWindowSpec[]): GalleryWindowSpec[] {
|
||||
return specs;
|
||||
}
|
||||
@@ -85,6 +120,7 @@ function mk(
|
||||
doorWood: opts.doorWood ?? ['#3d2818', '#4e3624', '#261a10'],
|
||||
windows: opts.windows,
|
||||
trackLights: opts.trackLights ?? 0.8,
|
||||
lightScale: opts.lightScale ?? 1,
|
||||
details: opts.details,
|
||||
};
|
||||
}
|
||||
@@ -96,9 +132,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Roman atrium',
|
||||
'Marble-clad villa · mosaic floor · clerestory daylight',
|
||||
{ wall: 'limestone', ceiling: 'plaster-warm', floor: 'mosaic-roman' },
|
||||
{ wall: '#e8e0d0', ceiling: '#f5f0e8', floor: '#c8b898', trim: '#a89878' },
|
||||
{ wall: '#8e8980', ceiling: '#9c9994', floor: '#c8b898', trim: '#a89878' },
|
||||
{
|
||||
details: 'classical',
|
||||
details: 'roman',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#5a4830',
|
||||
ambient: 0.62,
|
||||
warmLight: '#fff8e8',
|
||||
@@ -114,47 +153,57 @@ 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(
|
||||
'gothic-cathedral',
|
||||
'Gothic hall',
|
||||
'Stone vault · tall lancet windows · flagstone floor',
|
||||
{ wall: 'rough-stone', ceiling: 'basalt', floor: 'flagstone' },
|
||||
{ wall: '#8a8478', ceiling: '#3a3630', floor: '#6a6458', trim: '#5c5648' },
|
||||
'Gothic nave',
|
||||
'Blind-arcaded ashlar · ribbed stone vault · worn flagstones',
|
||||
{ wall: 'gothic-ashlar', ceiling: 'gothic-vault', floor: 'gothic-stone-floor' },
|
||||
{ wall: '#8d8471', ceiling: '#6d6555', floor: '#6f6759', trim: '#7d7159' },
|
||||
{
|
||||
details: 'medieval',
|
||||
titleColor: '#e8dcc8',
|
||||
ambient: 0.52,
|
||||
warmLight: '#e8d8c0',
|
||||
sunLight: '#d0e8ff',
|
||||
fog: '#0c0c10',
|
||||
details: 'gothic',
|
||||
titleColor: '#efe4cc',
|
||||
ambient: 0.6,
|
||||
warmLight: '#ffe6bc',
|
||||
sunLight: '#dbe8ff',
|
||||
// Cool shadowed stone, but never a pure void — walls must stay readable.
|
||||
fog: '#1b1a18',
|
||||
background: '#131211',
|
||||
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 }
|
||||
{ wall: 'back', x: -2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.8 },
|
||||
{ wall: 'back', x: 2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.8 },
|
||||
{ wall: 'left', x: -4.5, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.2 },
|
||||
{ wall: 'left', x: 4.5, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.2 },
|
||||
{ wall: 'right', x: -4.5, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.2 },
|
||||
{ wall: 'right', x: 4.5, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.2 }
|
||||
),
|
||||
trackLights: 0.3,
|
||||
trackLights: 0.5,
|
||||
// Naves are lit by window shafts against shadowed stone, not flooded.
|
||||
lightScale: 0.26,
|
||||
}
|
||||
),
|
||||
30: mk(
|
||||
@@ -162,9 +211,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Florentine palazzo',
|
||||
'Lime-washed walls · terracotta accents · arched windows',
|
||||
{ wall: 'painted-lime', wallSide: 'stucco-cream', ceiling: 'fresco-worn', floor: 'terracotta-tiles' },
|
||||
{ wall: '#f4ebe0', ceiling: '#faf6ee', floor: '#c89070', trim: '#c9a227' },
|
||||
{ wall: '#89847e', ceiling: '#96948f', floor: '#c89070', trim: '#c9a227' },
|
||||
{
|
||||
details: 'palazzo',
|
||||
details: 'quattrocento',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#6b4423',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: -2.2, y: 2.6, width: 1.3, height: 1.7, style: 'roman-arch', lightColor: '#fff4e0', lightIntensity: 3.0 },
|
||||
@@ -178,9 +230,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Roman palazzo',
|
||||
'Marble and stucco · coffered ceiling · checker floor',
|
||||
{ wall: 'marble-veined-carrara', wallSide: 'stucco-cream', ceiling: 'plaster-warm', floor: 'marble-checker' },
|
||||
{ wall: '#f0ece4', wallSide: '#efe4d4', ceiling: '#faf6ee', floor: '#ddd8cc', trim: '#c9a227' },
|
||||
{ wall: '#918f8a', wallSide: '#8f8980', ceiling: '#9c9994', floor: '#ddd8cc', trim: '#c9a227' },
|
||||
{
|
||||
details: 'palazzo',
|
||||
details: 'cinquecento',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#6b4423',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.8, width: 2.8, height: 1.8, style: 'baroque-pair', lightColor: '#fff8f0', lightIntensity: 3.8 },
|
||||
@@ -195,9 +250,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Flemish panel hall',
|
||||
'Oak wainscoting · leaded glass · herringbone floor',
|
||||
{ wall: 'oak-panel', wallSide: 'plaster-warm', ceiling: 'dark-wood-panel', floor: 'parquet-herringbone' },
|
||||
{ wall: '#8a6848', wallSide: '#f0ebe3', ceiling: '#3a2818', floor: '#8a6848', trim: '#5c4030' },
|
||||
{ wall: '#8a6848', wallSide: '#8c8985', ceiling: '#3a2818', floor: '#8a6848', trim: '#5c4030' },
|
||||
{
|
||||
details: 'salon',
|
||||
details: 'flemish',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#f0e8d8',
|
||||
warmLight: '#ffe8c8',
|
||||
windows: windows(
|
||||
@@ -213,9 +271,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Mannerist gallery',
|
||||
'Dramatic stucco · elongated windows · veined marble',
|
||||
{ wall: 'stucco-terracotta', ceiling: 'gilded-stucco', floor: 'marble-veined-emerald' },
|
||||
{ wall: '#d8b898', ceiling: '#d8c070', floor: '#dce8e0', trim: '#b8860b' },
|
||||
{ wall: '#98816b', ceiling: '#a79456', floor: '#dce8e0', trim: '#b8860b' },
|
||||
{
|
||||
details: 'baroque',
|
||||
details: 'mannerist',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#4a2818',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: -1.8, y: 2.7, width: 1.0, height: 2.2, style: 'roman-arch', lightColor: '#fff0d8', lightIntensity: 2.8 },
|
||||
@@ -251,9 +312,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Rococo salon',
|
||||
'Pastel painted walls · gilt trim · chevron parquet',
|
||||
{ wall: 'painted-oil-satin', ceiling: 'silk-pale', floor: 'parquet-chevrons' },
|
||||
{ wall: '#e8dce8', ceiling: '#faf6f0', floor: '#c8a882', trim: '#d4af37' },
|
||||
{ wall: '#918a91', ceiling: '#9b9995', floor: '#c8a882', trim: '#d4af37' },
|
||||
{
|
||||
details: 'salon',
|
||||
details: 'rococo',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#6a4858',
|
||||
warmLight: '#fff0f0',
|
||||
windows: windows(
|
||||
@@ -268,9 +332,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Neoclassical museum',
|
||||
'White painted walls · coffered ceiling · skylit salon',
|
||||
{ wall: 'painted-flat', ceiling: 'plaster-white', floor: 'marble-veined-carrara' },
|
||||
{ wall: '#f2f0ec', ceiling: '#fafafa', floor: '#eceae6', trim: '#b8b0a4' },
|
||||
{ wall: '#908f8d', ceiling: '#9e9e9e', floor: '#eceae6', trim: '#b8b0a4' },
|
||||
{
|
||||
details: 'neoclassical',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#4a4844',
|
||||
ambient: 0.65,
|
||||
windows: windows(
|
||||
@@ -288,7 +355,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
{ wall: 'walnut-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
||||
{ wall: '#5a4030', ceiling: '#2a2420', floor: '#6a5040', trim: '#8a7050' },
|
||||
{
|
||||
details: 'salon',
|
||||
details: 'gothic-revival',
|
||||
titleColor: '#e8dcc8',
|
||||
ambient: 0.5,
|
||||
warmLight: '#ffe0c0',
|
||||
@@ -304,9 +371,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Realist picture gallery',
|
||||
'Warm painted walls · bourgeois salon · oak parquet',
|
||||
{ wall: 'painted-emulsion', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
|
||||
{ wall: '#e8e2d8', ceiling: '#f5f0e8', floor: '#a08060', trim: '#8a7050' },
|
||||
{ wall: '#88847e', ceiling: '#97948f', floor: '#a08060', trim: '#8a7050' },
|
||||
{
|
||||
details: 'salon',
|
||||
details: 'bourgeois',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.5, width: 2.4, height: 1.6, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 3.2 },
|
||||
{ wall: 'left', x: 0, y: 2.7, width: 1.6, height: 1.3, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.4 }
|
||||
@@ -318,9 +388,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Impressionist salon',
|
||||
'North-light skylight · pale painted walls · herringbone floor',
|
||||
{ wall: 'painted-emulsion', ceiling: 'painted-flat', floor: 'parquet-herringbone' },
|
||||
{ wall: '#f0ebe3', ceiling: '#fafafa', floor: '#c8a882', trim: '#c9a96e' },
|
||||
{ wall: '#8f8c87', ceiling: '#9e9e9e', floor: '#c8a882', trim: '#c9a96e' },
|
||||
{
|
||||
details: 'salon',
|
||||
details: 'north-light',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
ambient: 0.68,
|
||||
windows: windows(
|
||||
{ wall: 'ceiling', x: 0, y: 0, width: 5.0, height: 3.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 6.0 },
|
||||
@@ -335,9 +408,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Montmartre atelier',
|
||||
'Painted studio walls · large north window · worn floorboards',
|
||||
{ wall: 'painted-emulsion', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
|
||||
{ wall: '#e8e0d0', ceiling: '#f0ebe0', floor: '#9a7858', trim: '#8a6848' },
|
||||
{ wall: '#847f76', ceiling: '#918f88', floor: '#9a7858', trim: '#8a6848' },
|
||||
{
|
||||
details: 'atelier',
|
||||
details: 'paris-atelier',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
windows: windows(
|
||||
{ wall: 'left', x: 0, y: 2.4, width: 2.8, height: 2.0, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 4.5 },
|
||||
{ wall: 'back', x: 0, y: 2.8, width: 1.0, height: 1.2, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.0 }
|
||||
@@ -351,7 +427,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
{ wall: 'painted-oil-matte', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
||||
{ wall: '#1a4030', ceiling: '#1a1814', floor: '#4a3828', trim: '#8a7050' },
|
||||
{
|
||||
details: 'salon',
|
||||
details: 'symbolist',
|
||||
titleColor: '#d8e8c8',
|
||||
ambient: 0.55,
|
||||
warmLight: '#ffd898',
|
||||
@@ -367,9 +443,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Art Nouveau salon',
|
||||
'Painted plaster · stained glass · terrazzo floor',
|
||||
{ wall: 'painted-oil-satin', ceiling: 'silk-gold', floor: 'terrazzo' },
|
||||
{ wall: '#f0ece4', ceiling: '#d8c890', floor: '#d8d0c4', trim: '#6a9868' },
|
||||
{ wall: '#8f8c87', ceiling: '#a0946b', floor: '#d8d0c4', trim: '#6a9868' },
|
||||
{
|
||||
details: 'salon',
|
||||
details: 'art-nouveau',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#3a5840',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.5, width: 2.2, height: 2.0, style: 'art-nouveau', lightColor: '#e8ffe8', lightIntensity: 3.2 },
|
||||
@@ -383,9 +462,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Fauvist studio',
|
||||
'Bold painted walls · flooded with color and light',
|
||||
{ wall: 'painted-oil-matte', wallSide: 'painted-oil-matte', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
|
||||
{ wall: '#e89060', wallSide: '#e8c860', ceiling: '#f8f0e0', floor: '#a07048', trim: '#c04020' },
|
||||
{ wall: '#e89060', wallSide: '#e8c860', ceiling: '#9e998e', floor: '#a07048', trim: '#c04020' },
|
||||
{
|
||||
details: 'atelier',
|
||||
details: 'fauvist',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#402010',
|
||||
ambient: 0.7,
|
||||
windows: windows(
|
||||
@@ -401,7 +483,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
{ wall: 'dark-wood-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
||||
{ wall: '#3a2818', ceiling: '#2a2018', floor: '#5a4030', trim: '#8a6040' },
|
||||
{
|
||||
details: 'atelier',
|
||||
details: 'expressionist',
|
||||
titleColor: '#f0d8b0',
|
||||
ambient: 0.52,
|
||||
windows: windows(
|
||||
@@ -415,9 +497,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Cubist studio',
|
||||
'Paris atelier · factory windows · painted plaster',
|
||||
{ wall: 'painted-emulsion', ceiling: 'painted-flat', floor: 'parquet-herringbone' },
|
||||
{ wall: '#e8e4dc', ceiling: '#f5f5f5', floor: '#9a8870', trim: '#888888' },
|
||||
{ wall: '#8c8a85', ceiling: '#999999', floor: '#9a8870', trim: '#888888' },
|
||||
{
|
||||
details: 'atelier',
|
||||
details: 'cubist-atelier',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
ambient: 0.65,
|
||||
windows: windows(
|
||||
{ wall: 'left', x: 0, y: 2.5, width: 3.5, height: 2.4, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 5.5 },
|
||||
@@ -430,9 +515,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Futurist loft',
|
||||
'Steel and glass · industrial concrete · sweeping daylight',
|
||||
{ wall: 'concrete-raw', ceiling: 'concrete-raw', floor: 'industrial-floor' },
|
||||
{ wall: '#a8a8a8', ceiling: '#989898', floor: '#888880', trim: '#606060' },
|
||||
{ wall: '#858585', ceiling: '#7a7a7a', floor: '#888880', trim: '#606060' },
|
||||
{
|
||||
details: 'industrial',
|
||||
details: 'futurist',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#303030',
|
||||
ambient: 0.62,
|
||||
fog: '#181818',
|
||||
@@ -449,9 +537,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Suprematist white cube',
|
||||
'Matte white paint · geometric light · polished floor',
|
||||
{ wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' },
|
||||
{ wall: '#ffffff', ceiling: '#ffffff', floor: '#e0e0e0', trim: '#cccccc' },
|
||||
{ wall: '#a8a8a8', ceiling: '#b3b3b3', floor: '#e0e0e0', trim: '#cccccc' },
|
||||
{
|
||||
details: 'modern',
|
||||
details: 'suprematist',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#222222',
|
||||
ambient: 0.72,
|
||||
fog: '#1a1a1a',
|
||||
@@ -469,9 +560,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Constructivist space',
|
||||
'Glass block · concrete · angular daylight',
|
||||
{ wall: 'concrete-block', ceiling: 'concrete-raw', floor: 'concrete-polished' },
|
||||
{ wall: '#b0b0b0', ceiling: '#a0a0a0', floor: '#c0c0c0', trim: '#808080' },
|
||||
{ wall: '#858585', ceiling: '#7a7a7a', floor: '#c0c0c0', trim: '#808080' },
|
||||
{
|
||||
details: 'industrial',
|
||||
details: 'constructivist',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#303030',
|
||||
ambient: 0.65,
|
||||
windows: windows(
|
||||
@@ -487,9 +581,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Dada salon',
|
||||
'Eclectic bourgeois room · painted walls and exposed brick',
|
||||
{ wall: 'painted-emulsion', wallSide: 'brick', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
|
||||
{ wall: '#e8dcc8', wallSide: '#8a5040', ceiling: '#f0ebe0', floor: '#8a6848', trim: '#6a4830' },
|
||||
{ wall: '#8b8478', wallSide: '#8a5040', ceiling: '#97948d', floor: '#8a6848', trim: '#6a4830' },
|
||||
{
|
||||
details: 'salon',
|
||||
details: 'dada',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
windows: windows(
|
||||
{ wall: 'back', x: -1.5, y: 2.5, width: 1.0, height: 1.4, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.5 },
|
||||
{ wall: 'back', x: 1.5, y: 2.6, width: 0.8, height: 1.8, style: 'gothic-lancet', lightColor: '#ffe8c0', lightIntensity: 2.0 },
|
||||
@@ -505,7 +602,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
{ wall: 'painted-oil-matte', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
||||
{ wall: '#1a2848', ceiling: '#2a2428', floor: '#5a4838', trim: '#8a7050' },
|
||||
{
|
||||
details: 'salon',
|
||||
details: 'surrealist',
|
||||
titleColor: '#e8dcc8',
|
||||
ambient: 0.58,
|
||||
warmLight: '#ffd898',
|
||||
@@ -522,9 +619,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'NYC loft',
|
||||
'Raw brick and painted plaster · north-light factory windows',
|
||||
{ wall: 'brick', wallSide: 'painted-flat', ceiling: 'concrete-raw', floor: 'industrial-floor' },
|
||||
{ wall: '#8a5040', wallSide: '#f5f5f5', ceiling: '#989898', floor: '#888880', trim: '#606060' },
|
||||
{ wall: '#8a5040', wallSide: '#858585', ceiling: '#989898', floor: '#888880', trim: '#606060' },
|
||||
{
|
||||
details: 'industrial',
|
||||
details: 'loft',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#303030',
|
||||
ambient: 0.65,
|
||||
windows: windows(
|
||||
@@ -538,9 +638,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Pop Art gallery',
|
||||
'White painted walls · fluorescent daylight · polished concrete',
|
||||
{ wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' },
|
||||
{ wall: '#ffffff', ceiling: '#ffffff', floor: '#d8d8d8', trim: '#cccccc' },
|
||||
{ wall: '#a8a8a8', ceiling: '#b3b3b3', floor: '#d8d8d8', trim: '#cccccc' },
|
||||
{
|
||||
details: 'modern',
|
||||
details: 'white-cube',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#222222',
|
||||
ambient: 0.75,
|
||||
warmLight: '#ffffff',
|
||||
@@ -578,13 +681,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 +731,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]);
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import { readStoredLocale, writeStoredLocale } from '../utils/localeStorage';
|
||||
|
||||
import enCommon from '../locales/en/common.json';
|
||||
import enHome from '../locales/en/home.json';
|
||||
import enSearch from '../locales/en/search.json';
|
||||
import enGallery from '../locales/en/gallery.json';
|
||||
import enPainting from '../locales/en/painting.json';
|
||||
import enBio from '../locales/en/bio.json';
|
||||
import enAnnotations from '../locales/en/annotations.json';
|
||||
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 enAudit from '../locales/en/audit.json';
|
||||
|
||||
import ruCommon from '../locales/ru/common.json';
|
||||
import ruHome from '../locales/ru/home.json';
|
||||
import ruSearch from '../locales/ru/search.json';
|
||||
import ruGallery from '../locales/ru/gallery.json';
|
||||
import ruPainting from '../locales/ru/painting.json';
|
||||
import ruBio from '../locales/ru/bio.json';
|
||||
import ruAnnotations from '../locales/ru/annotations.json';
|
||||
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';
|
||||
import ruAudit from '../locales/ru/audit.json';
|
||||
|
||||
const initialLocale = readStoredLocale();
|
||||
writeStoredLocale(initialLocale);
|
||||
|
||||
void i18n.use(initReactI18next).init({
|
||||
lng: initialLocale,
|
||||
fallbackLng: 'en',
|
||||
supportedLngs: ['en', 'ru'],
|
||||
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences', 'tours', 'users', 'audit'],
|
||||
defaultNS: 'common',
|
||||
resources: {
|
||||
en: {
|
||||
common: enCommon,
|
||||
home: enHome,
|
||||
search: enSearch,
|
||||
gallery: enGallery,
|
||||
painting: enPainting,
|
||||
bio: enBio,
|
||||
annotations: enAnnotations,
|
||||
debug: enDebug,
|
||||
translations: enTranslations,
|
||||
influences: enInfluences,
|
||||
tours: enTours,
|
||||
users: enUsers,
|
||||
audit: enAudit,
|
||||
},
|
||||
ru: {
|
||||
common: ruCommon,
|
||||
home: ruHome,
|
||||
search: ruSearch,
|
||||
gallery: ruGallery,
|
||||
painting: ruPainting,
|
||||
bio: ruBio,
|
||||
annotations: ruAnnotations,
|
||||
debug: ruDebug,
|
||||
translations: ruTranslations,
|
||||
influences: ruInfluences,
|
||||
tours: ruTours,
|
||||
users: ruUsers,
|
||||
audit: ruAudit,
|
||||
},
|
||||
},
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": "Art history notes",
|
||||
"readSource": "Read source",
|
||||
"categoryTechnique": "Technique",
|
||||
"categoryComposition": "Composition",
|
||||
"categorySubject": "Subject",
|
||||
"categorySymbolism": "Symbolism",
|
||||
"categoryHistorical": "Historical context",
|
||||
"categoryOther": "Notes"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"title": "Curator activity",
|
||||
"back": "← Back",
|
||||
"loadFailed": "Failed to load audit log",
|
||||
"loading": "Loading…",
|
||||
"empty": "No curator actions match these filters.",
|
||||
"databaseUnknown": "Reading the audit log for this environment’s database.",
|
||||
"databaseDev": "Dev database: {{name}}",
|
||||
"databaseProd": "Production database: {{name}}",
|
||||
"databaseNamed": "Database: {{name}}",
|
||||
"statTotal": "Total (filtered)",
|
||||
"stat24h": "Last 24 hours",
|
||||
"stat7d": "Last 7 days",
|
||||
"statNewest": "Most recent",
|
||||
"filterCurator": "Curator",
|
||||
"filterAction": "Action",
|
||||
"filterResource": "Resource type",
|
||||
"filterResourceId": "Resource id",
|
||||
"filterFrom": "From",
|
||||
"filterTo": "To",
|
||||
"filterSearch": "Search",
|
||||
"filterSearchPlaceholder": "Action, user, IP, details…",
|
||||
"allCurators": "All curators",
|
||||
"allActions": "All actions",
|
||||
"allResources": "All resources",
|
||||
"apply": "Apply filters",
|
||||
"clear": "Clear",
|
||||
"byCurator": "By curator",
|
||||
"byAction": "By action",
|
||||
"showing": "Showing {{count}} of {{total}}",
|
||||
"page": "Page {{page}} / {{pages}}",
|
||||
"colWhen": "Date & time",
|
||||
"colCurator": "Curator",
|
||||
"colAction": "Action",
|
||||
"colResource": "Resource",
|
||||
"colDetails": "Details",
|
||||
"colIp": "IP",
|
||||
"prev": "Previous",
|
||||
"next": "Next"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"enterGallery": "Enter Gallery",
|
||||
"noBio": "No biography available yet.",
|
||||
"wikipediaSource": "Text adapted from Wikipedia",
|
||||
"backToTimeline": "← Back to Timeline"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"loading": "Loading…",
|
||||
"close": "Close",
|
||||
"back": "Back",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"error": "Something went wrong",
|
||||
"localeEn": "EN",
|
||||
"localeRu": "RU",
|
||||
"language": "Language"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"checked": "Checked",
|
||||
"fixIt": "Fix it",
|
||||
"more": "More",
|
||||
"clear": "Clear",
|
||||
"upload": "Upload",
|
||||
"searching": "Searching…",
|
||||
"saving": "Saving…",
|
||||
"loginTitle": "Curator login",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"signIn": "Sign in",
|
||||
"loginFailed": "Login failed"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"backToTimeline": "← Back to Timeline",
|
||||
"exitToTimeline": "Exit to Timeline",
|
||||
"loadingGallery": "Loading gallery…",
|
||||
"instructionsTitle": "Gallery controls",
|
||||
"instructionMove": "Drag to look around",
|
||||
"instructionZoom": "Scroll to zoom",
|
||||
"instructionClick": "Click a painting to view details",
|
||||
"wingOf": "Wing {{current}} of {{total}}",
|
||||
"exitConfirmTitle": "Leave gallery?",
|
||||
"exitConfirmBody": "Return to the timeline or stay in the hall.",
|
||||
"stay": "Stay",
|
||||
"exit": "Exit"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"title": "Virtual Art Gallery",
|
||||
"subtitle": "Explore art history on an interactive timeline — enter 3D galleries and discover connections between artists and masterpieces.",
|
||||
"loadingArtHistory": "Loading art history…",
|
||||
"loadingPortraits": "Loading portraits…",
|
||||
"openingArtistGallery": "Opening artist gallery…",
|
||||
"openingMovementGallery": "Opening movement gallery…",
|
||||
"backToTimeline": "← Back to Timeline",
|
||||
"backToGallery": "← Back to Gallery",
|
||||
"curatorLogin": "Curator login",
|
||||
"curatorLogout": "Log out",
|
||||
"signedInAs": "Signed in as {{username}}",
|
||||
"debugMode": "Debug mode",
|
||||
"showMoreDebug": "Show more (debug)",
|
||||
"checkup": "Checkup",
|
||||
"translations": "Translations",
|
||||
"influences": "Influences",
|
||||
"tours": "Tours",
|
||||
"toursEditor": "Tour editor",
|
||||
"users": "Users",
|
||||
"audit": "Activity",
|
||||
"layoutHorizontal": "Classic timeline →",
|
||||
"layoutVertical": "↑ Vertical timeline",
|
||||
"layoutTree": "🌳 Tree of art",
|
||||
"captionClassicTimeline": "Click an era or event to zoom · Scroll to zoom · Drag to pan",
|
||||
"captionClassicFlow": "Scroll to zoom · drag to pan · each movement stream is a solid colour band through history",
|
||||
"captionVerticalTimeline": "Bottom → top · Scroll to zoom · Drag to pan",
|
||||
"captionVerticalFlow": "Bottom → top through history · scroll to zoom · drag to pan · click a stream",
|
||||
"captionTreeFlow": "Roots at the bottom, living movements at the crown · scroll to zoom · drag to pan · click a branch to enter its gallery",
|
||||
"openingTourGallery": "Opening guided tour…",
|
||||
"tourEmpty": "This tour has no paintings yet.",
|
||||
"tourLoadFailed": "Failed to load the tour.",
|
||||
"curatorRequiredTitle": "Curator access required",
|
||||
"curatorRequiredBody": "Sign in as a curator to use this tool.",
|
||||
"backToGalleryBtn": "Back to gallery"
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"title": "Influence links",
|
||||
"back": "← Back to gallery",
|
||||
"tabList": "List",
|
||||
"tabImport": "Import",
|
||||
"tabGraph": "Graph",
|
||||
"loadFailed": "Failed to load influences",
|
||||
"searchPlaceholder": "Search artist, painting, source…",
|
||||
"refresh": "Refresh",
|
||||
"loading": "Loading…",
|
||||
"loadingParse": "Reading file…",
|
||||
"loadingPreview": "Validating influence links…",
|
||||
"loadingCommit": "Importing links into the database…",
|
||||
"loadingSave": "Saving link…",
|
||||
"addEdge": "Add link",
|
||||
"cancelAdd": "Cancel",
|
||||
"total": "{{count}} links",
|
||||
"filtered": "filtered by artist",
|
||||
"clearFilter": "Clear artist filter",
|
||||
"subjectPainting": "Subject painting",
|
||||
"searchPainting": "Search painting…",
|
||||
"sourceType": "Source type",
|
||||
"typeArtist": "Artist",
|
||||
"typePainting": "Painting",
|
||||
"typeMovement": "Movement",
|
||||
"sourceEntity": "Source entity",
|
||||
"searchSource": "Search source…",
|
||||
"notes": "Notes",
|
||||
"saveEdge": "Save link",
|
||||
"addRequiresIds": "Pick a subject painting and a source entity",
|
||||
"colSubject": "Subject",
|
||||
"colSource": "Source",
|
||||
"colType": "Type",
|
||||
"colNotes": "Notes",
|
||||
"colActions": "Actions",
|
||||
"colAction": "Action",
|
||||
"colDirection": "Direction",
|
||||
"delete": "Delete",
|
||||
"confirmDelete": "Delete this influence link?",
|
||||
"noEdges": "No influence links match.",
|
||||
"stepUpload": "1. Upload",
|
||||
"stepMapping": "2. Map columns",
|
||||
"stepPreview": "3. Validate",
|
||||
"stepDone": "4. Done",
|
||||
"uploadHelp": "Choose a CSV, JSON, or XLSX file with influence rows (e.g. Inputs/artist_influences_web_sources.xlsx).",
|
||||
"parseFailed": "Failed to parse file",
|
||||
"previewFailed": "Failed to build preview",
|
||||
"commitFailed": "Failed to commit import",
|
||||
"rowsMissing": "File rows were not returned (too large). Use a smaller file (≤2000 rows).",
|
||||
"fileInfo": "{{name}} · {{rows}} rows · {{format}}",
|
||||
"sheet": "Sheet",
|
||||
"preset": "Mapping preset",
|
||||
"column": "Column",
|
||||
"role": "Role",
|
||||
"sample": "Sample",
|
||||
"backStep": "Back",
|
||||
"runPreview": "Validate & preview",
|
||||
"previewCounts": "Will create {{create}} · skip {{skip}} · row errors {{errors}} · proposals {{proposals}}",
|
||||
"warnings": "{{count}} warnings",
|
||||
"commitImport": "Import {{count}} links",
|
||||
"commitSummary": "Imported {{inserted}} links ({{skipped}} already present or skipped).",
|
||||
"alreadyImportedWarn": "Already imported {{when}} by {{who}} ({{match}}: {{file}}). Commit is blocked unless you force re-import.",
|
||||
"alreadyImportedBlock": "This file or identical data was already imported. Enable “Import anyway” to force, or cancel.",
|
||||
"forceImport": "Import anyway (force — may recreate skipped edges only; existing edges stay unique)",
|
||||
"matchFile": "same file bytes",
|
||||
"matchData": "same mapped data",
|
||||
"searchArtist": "Search artist for neighborhood graph…",
|
||||
"graphEmpty": "Select an artist to visualize influence neighbors.",
|
||||
"graphStats": "{{nodes}} nodes · {{edges}} edges"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"aboutArtist": "About {{name}}",
|
||||
"influencedBy": "Influenced By",
|
||||
"influenced": "Influenced",
|
||||
"noInfluences": "No documented influences for this work.",
|
||||
"noInfluencedWorks": "No documented works influenced by this painting yet.",
|
||||
"catalogPosition": "Catalog position",
|
||||
"lightboxHint": "Click anywhere to close",
|
||||
"prevPainting": "Previous painting",
|
||||
"nextPainting": "Next painting",
|
||||
"tourNotes": "Tour notes",
|
||||
"tourNotesFor": "Tour notes · {{title}}",
|
||||
"tourNotesEmpty": "No notes for this stop.",
|
||||
"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,10 @@
|
||||
{
|
||||
"label": "Search",
|
||||
"placeholder": "Search artists, paintings, movements…",
|
||||
"resultsLabel": "Search results",
|
||||
"noMatches": "No matches found.",
|
||||
"groupArtist": "Artists",
|
||||
"groupMovement": "Movements",
|
||||
"groupPainting": "Paintings",
|
||||
"searchFailed": "Search failed"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"title": "Guided tours",
|
||||
"popupTitle": "Guided tours",
|
||||
"popupHint": "Choose a curated walkthrough of selected works.",
|
||||
"close": "Close",
|
||||
"loading": "Loading…",
|
||||
"loadFailed": "Failed to load tours",
|
||||
"noPublished": "No published tours yet.",
|
||||
"noTours": "No tours yet. Create one to get started.",
|
||||
"stopCount": "{{count}} stops",
|
||||
"back": "← Back to gallery",
|
||||
"create": "Create",
|
||||
"newTourPlaceholder": "New tour title…",
|
||||
"selectTour": "Select a tour to edit.",
|
||||
"tourTitle": "Title",
|
||||
"tourDescription": "Description",
|
||||
"status": "Status",
|
||||
"draft": "Draft",
|
||||
"published": "Published",
|
||||
"saveMeta": "Save details",
|
||||
"delete": "Delete tour",
|
||||
"confirmDelete": "Delete this tour and all its stops?",
|
||||
"searchPainting": "Search paintings to add…",
|
||||
"saveStops": "Save stops",
|
||||
"remove": "Remove",
|
||||
"stopBodyPlaceholder": "Tour notes for this stop (English)…",
|
||||
"noStops": "No stops yet. Search and add paintings."
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"title": "Translation review",
|
||||
"back": "← Back to gallery",
|
||||
"locale": "Locale",
|
||||
"entityType": "Entity type",
|
||||
"status": "Status",
|
||||
"all": "All",
|
||||
"draft": "Draft",
|
||||
"reviewed": "Reviewed",
|
||||
"published": "Published",
|
||||
"coverage": "Coverage",
|
||||
"artistsBio": "Artists with bio (ru)",
|
||||
"paintingsTitle": "Paintings with title alias (ru)",
|
||||
"canonical": "English (canonical)",
|
||||
"translation": "Translation",
|
||||
"saveDraft": "Save draft",
|
||||
"publish": "Publish",
|
||||
"noRows": "No translation rows match the filter.",
|
||||
"loadFailed": "Failed to load translations"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": "Историко-художественные заметки",
|
||||
"readSource": "Источник",
|
||||
"categoryTechnique": "Техника",
|
||||
"categoryComposition": "Композиция",
|
||||
"categorySubject": "Сюжет",
|
||||
"categorySymbolism": "Символика",
|
||||
"categoryHistorical": "Исторический контекст",
|
||||
"categoryOther": "Заметки"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"title": "Действия кураторов",
|
||||
"back": "← Назад",
|
||||
"loadFailed": "Не удалось загрузить журнал действий",
|
||||
"loading": "Загрузка…",
|
||||
"empty": "Нет действий кураторов по этим фильтрам.",
|
||||
"databaseUnknown": "Журнал читается из базы данных текущего окружения.",
|
||||
"databaseDev": "База разработки: {{name}}",
|
||||
"databaseProd": "Продакшен-база: {{name}}",
|
||||
"databaseNamed": "База данных: {{name}}",
|
||||
"statTotal": "Всего (фильтр)",
|
||||
"stat24h": "За 24 часа",
|
||||
"stat7d": "За 7 дней",
|
||||
"statNewest": "Последнее",
|
||||
"filterCurator": "Куратор",
|
||||
"filterAction": "Действие",
|
||||
"filterResource": "Тип ресурса",
|
||||
"filterResourceId": "ID ресурса",
|
||||
"filterFrom": "С",
|
||||
"filterTo": "По",
|
||||
"filterSearch": "Поиск",
|
||||
"filterSearchPlaceholder": "Действие, пользователь, IP, детали…",
|
||||
"allCurators": "Все кураторы",
|
||||
"allActions": "Все действия",
|
||||
"allResources": "Все ресурсы",
|
||||
"apply": "Применить",
|
||||
"clear": "Сбросить",
|
||||
"byCurator": "По кураторам",
|
||||
"byAction": "По действиям",
|
||||
"showing": "Показано {{count}} из {{total}}",
|
||||
"page": "Стр. {{page}} / {{pages}}",
|
||||
"colWhen": "Дата и время",
|
||||
"colCurator": "Куратор",
|
||||
"colAction": "Действие",
|
||||
"colResource": "Ресурс",
|
||||
"colDetails": "Детали",
|
||||
"colIp": "IP",
|
||||
"prev": "Назад",
|
||||
"next": "Далее"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"enterGallery": "Войти в галерею",
|
||||
"noBio": "Биография пока недоступна.",
|
||||
"wikipediaSource": "Текст адаптирован из Википедии",
|
||||
"backToTimeline": "← На шкалу времени"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"loading": "Загрузка…",
|
||||
"close": "Закрыть",
|
||||
"back": "Назад",
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена",
|
||||
"yes": "Да",
|
||||
"no": "Нет",
|
||||
"error": "Что-то пошло не так",
|
||||
"localeEn": "EN",
|
||||
"localeRu": "RU",
|
||||
"language": "Язык"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"checked": "Проверено",
|
||||
"fixIt": "Исправить",
|
||||
"more": "Ещё",
|
||||
"clear": "Очистить",
|
||||
"upload": "Загрузить",
|
||||
"searching": "Поиск…",
|
||||
"saving": "Сохранение…",
|
||||
"loginTitle": "Вход куратора",
|
||||
"username": "Имя пользователя",
|
||||
"password": "Пароль",
|
||||
"signIn": "Войти",
|
||||
"loginFailed": "Ошибка входа"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"backToTimeline": "← На шкалу времени",
|
||||
"exitToTimeline": "Выход на шкалу времени",
|
||||
"loadingGallery": "Загрузка галереи…",
|
||||
"instructionsTitle": "Управление",
|
||||
"instructionMove": "Перетаскивайте для обзора",
|
||||
"instructionZoom": "Колёсико — масштаб",
|
||||
"instructionClick": "Нажмите на картину для подробностей",
|
||||
"wingOf": "Крыло {{current}} из {{total}}",
|
||||
"exitConfirmTitle": "Покинуть галерею?",
|
||||
"exitConfirmBody": "Вернуться на шкалу времени или остаться в зале.",
|
||||
"stay": "Остаться",
|
||||
"exit": "Выход"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"title": "Виртуальная художественная галерея",
|
||||
"subtitle": "Исследуйте историю искусства на интерактивной шкале времени — входите в 3D-залы и открывайте связи между художниками и шедеврами.",
|
||||
"loadingArtHistory": "Загрузка истории искусства…",
|
||||
"loadingPortraits": "Загрузка портретов…",
|
||||
"openingArtistGallery": "Открытие галереи художника…",
|
||||
"openingMovementGallery": "Открытие галереи направления…",
|
||||
"backToTimeline": "← На шкалу времени",
|
||||
"backToGallery": "← В галерею",
|
||||
"curatorLogin": "Вход куратора",
|
||||
"curatorLogout": "Выйти",
|
||||
"signedInAs": "Вы вошли как {{username}}",
|
||||
"debugMode": "Режим отладки",
|
||||
"showMoreDebug": "Показать больше (отладка)",
|
||||
"checkup": "Проверка",
|
||||
"translations": "Переводы",
|
||||
"influences": "Влияния",
|
||||
"tours": "Экскурсии",
|
||||
"toursEditor": "Редактор экскурсий",
|
||||
"users": "Пользователи",
|
||||
"audit": "Активность",
|
||||
"layoutHorizontal": "Классическая шкала →",
|
||||
"layoutVertical": "↑ Вертикальная шкала",
|
||||
"layoutTree": "🌳 Древо искусства",
|
||||
"captionClassicTimeline": "Щёлкните эпоху или событие для приближения · Колесо — масштаб · Перетащите для панорамы",
|
||||
"captionClassicFlow": "Колесо — масштаб · перетащите для панорамы · каждое направление — цветная полоса сквозь историю",
|
||||
"captionVerticalTimeline": "Снизу вверх · Колесо — масштаб · Перетащите для панорамы",
|
||||
"captionVerticalFlow": "Снизу вверх по истории · колесо — масштаб · перетащите для панорамы · щёлкните поток",
|
||||
"captionTreeFlow": "Корни внизу, живые направления в кроне · колесо — масштаб · перетащите для панорамы · щёлкните ветвь, чтобы войти в зал",
|
||||
"openingTourGallery": "Открытие экскурсии…",
|
||||
"tourEmpty": "В этой экскурсии пока нет картин.",
|
||||
"tourLoadFailed": "Не удалось загрузить экскурсию.",
|
||||
"curatorRequiredTitle": "Требуется доступ куратора",
|
||||
"curatorRequiredBody": "Войдите как куратор, чтобы использовать этот инструмент.",
|
||||
"backToGalleryBtn": "Вернуться в галерею"
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"title": "Связи влияния",
|
||||
"back": "← Назад в галерею",
|
||||
"tabList": "Список",
|
||||
"tabImport": "Импорт",
|
||||
"tabGraph": "Граф",
|
||||
"loadFailed": "Не удалось загрузить связи",
|
||||
"searchPlaceholder": "Поиск: художник, картина, источник…",
|
||||
"refresh": "Обновить",
|
||||
"loading": "Загрузка…",
|
||||
"loadingParse": "Чтение файла…",
|
||||
"loadingPreview": "Проверка связей влияния…",
|
||||
"loadingCommit": "Импорт связей в базу…",
|
||||
"loadingSave": "Сохранение связи…",
|
||||
"addEdge": "Добавить связь",
|
||||
"cancelAdd": "Отмена",
|
||||
"total": "{{count}} связей",
|
||||
"filtered": "фильтр по художнику",
|
||||
"clearFilter": "Сбросить фильтр",
|
||||
"subjectPainting": "Картина (субъект)",
|
||||
"searchPainting": "Поиск картины…",
|
||||
"sourceType": "Тип источника",
|
||||
"typeArtist": "Художник",
|
||||
"typePainting": "Картина",
|
||||
"typeMovement": "Направление",
|
||||
"sourceEntity": "Источник",
|
||||
"searchSource": "Поиск источника…",
|
||||
"notes": "Заметки",
|
||||
"saveEdge": "Сохранить связь",
|
||||
"addRequiresIds": "Выберите картину и источник",
|
||||
"colSubject": "Субъект",
|
||||
"colSource": "Источник",
|
||||
"colType": "Тип",
|
||||
"colNotes": "Заметки",
|
||||
"colActions": "Действия",
|
||||
"colAction": "Действие",
|
||||
"colDirection": "Направление",
|
||||
"delete": "Удалить",
|
||||
"confirmDelete": "Удалить эту связь влияния?",
|
||||
"noEdges": "Связи не найдены.",
|
||||
"stepUpload": "1. Файл",
|
||||
"stepMapping": "2. Столбцы",
|
||||
"stepPreview": "3. Проверка",
|
||||
"stepDone": "4. Готово",
|
||||
"uploadHelp": "Выберите CSV, JSON или XLSX со связями влияния (например Inputs/artist_influences_web_sources.xlsx).",
|
||||
"parseFailed": "Не удалось разобрать файл",
|
||||
"previewFailed": "Не удалось построить превью",
|
||||
"commitFailed": "Не удалось выполнить импорт",
|
||||
"rowsMissing": "Строки файла не получены (слишком большой файл). Используйте ≤2000 строк.",
|
||||
"fileInfo": "{{name}} · {{rows}} строк · {{format}}",
|
||||
"sheet": "Лист",
|
||||
"preset": "Шаблон сопоставления",
|
||||
"column": "Столбец",
|
||||
"role": "Роль",
|
||||
"sample": "Пример",
|
||||
"backStep": "Назад",
|
||||
"runPreview": "Проверить",
|
||||
"previewCounts": "Создать {{create}} · пропустить {{skip}} · ошибки строк {{errors}} · предложений {{proposals}}",
|
||||
"warnings": "{{count}} предупреждений",
|
||||
"commitImport": "Импортировать {{count}} связей",
|
||||
"commitSummary": "Импортировано {{inserted}} (пропущено {{skipped}}).",
|
||||
"alreadyImportedWarn": "Уже импортировано {{when}} пользователем {{who}} ({{match}}: {{file}}). Импорт заблокирован, пока не включите принудительный повтор.",
|
||||
"alreadyImportedBlock": "Этот файл или те же данные уже импортировались. Включите «Импортировать всё равно» или отмените.",
|
||||
"forceImport": "Импортировать всё равно (принудительно — существующие связи остаются уникальными)",
|
||||
"matchFile": "те же байты файла",
|
||||
"matchData": "те же сопоставленные данные",
|
||||
"searchArtist": "Поиск художника для графа…",
|
||||
"graphEmpty": "Выберите художника, чтобы увидеть соседей по влиянию.",
|
||||
"graphStats": "{{nodes}} узлов · {{edges}} рёбер"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"aboutArtist": "О {{name}}",
|
||||
"influencedBy": "Под влиянием",
|
||||
"influenced": "Влияние на",
|
||||
"noInfluences": "Для этой работы нет задокументированных влияний.",
|
||||
"noInfluencedWorks": "Пока нет задокументированных работ под влиянием этой картины.",
|
||||
"catalogPosition": "Позиция в каталоге",
|
||||
"lightboxHint": "Нажмите в любом месте, чтобы закрыть",
|
||||
"prevPainting": "Предыдущая картина",
|
||||
"nextPainting": "Следующая картина",
|
||||
"tourNotes": "Текст экскурсии",
|
||||
"tourNotesFor": "Экскурсия · {{title}}",
|
||||
"tourNotesEmpty": "Для этой остановки нет текста.",
|
||||
"tourStopPosition": "Остановка {{current}} из {{total}}",
|
||||
"curatorNotes": "Заметки куратора",
|
||||
"curatorNotesEmpty": "Заметок куратора пока нет.",
|
||||
"curatorNotesEdit": "Изменить",
|
||||
"curatorNotesSave": "Сохранить",
|
||||
"curatorNotesSaving": "Сохранение…",
|
||||
"curatorNotesCancel": "Отмена",
|
||||
"curatorNotesSaveFailed": "Не удалось сохранить заметки куратора."
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"label": "Поиск",
|
||||
"placeholder": "Художники, картины, направления…",
|
||||
"resultsLabel": "Результаты поиска",
|
||||
"noMatches": "Ничего не найдено.",
|
||||
"groupArtist": "Художники",
|
||||
"groupMovement": "Направления",
|
||||
"groupPainting": "Картины",
|
||||
"searchFailed": "Ошибка поиска"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"title": "Экскурсии",
|
||||
"popupTitle": "Экскурсии",
|
||||
"popupHint": "Выберите кураторскую подборку произведений.",
|
||||
"close": "Закрыть",
|
||||
"loading": "Загрузка…",
|
||||
"loadFailed": "Не удалось загрузить экскурсии",
|
||||
"noPublished": "Пока нет опубликованных экскурсий.",
|
||||
"noTours": "Экскурсий пока нет. Создайте первую.",
|
||||
"stopCount": "{{count}} остановок",
|
||||
"back": "← В галерею",
|
||||
"create": "Создать",
|
||||
"newTourPlaceholder": "Название новой экскурсии…",
|
||||
"selectTour": "Выберите экскурсию для редактирования.",
|
||||
"tourTitle": "Название",
|
||||
"tourDescription": "Описание",
|
||||
"status": "Статус",
|
||||
"draft": "Черновик",
|
||||
"published": "Опубликовано",
|
||||
"saveMeta": "Сохранить сведения",
|
||||
"delete": "Удалить экскурсию",
|
||||
"confirmDelete": "Удалить эту экскурсию и все остановки?",
|
||||
"searchPainting": "Поиск картин для добавления…",
|
||||
"saveStops": "Сохранить остановки",
|
||||
"remove": "Убрать",
|
||||
"stopBodyPlaceholder": "Текст экскурсии для этой остановки (на английском)…",
|
||||
"noStops": "Остановок пока нет. Найдите и добавьте картины."
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"title": "Проверка переводов",
|
||||
"back": "← В галерею",
|
||||
"locale": "Язык",
|
||||
"entityType": "Тип объекта",
|
||||
"status": "Статус",
|
||||
"all": "Все",
|
||||
"draft": "Черновик",
|
||||
"reviewed": "Проверено",
|
||||
"published": "Опубликовано",
|
||||
"coverage": "Охват",
|
||||
"artistsBio": "Художники с биографией (ru)",
|
||||
"paintingsTitle": "Картины с русским названием",
|
||||
"canonical": "Английский (оригинал)",
|
||||
"translation": "Перевод",
|
||||
"saveDraft": "Сохранить черновик",
|
||||
"publish": "Опубликовать",
|
||||
"noRows": "Нет строк по выбранному фильтру.",
|
||||
"loadFailed": "Не удалось загрузить переводы"
|
||||
}
|
||||
@@ -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": "Пароль обновлён"
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import './i18n'
|
||||
import App from './App.tsx'
|
||||
import { AuthProvider } from './context/AuthContext.tsx'
|
||||
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
.audit-page {
|
||||
padding: 1rem 1.5rem 2rem;
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
color: #f5f0e8;
|
||||
}
|
||||
|
||||
.audit-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.audit-header-text h1 {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.audit-env {
|
||||
margin: 0.25rem 0 0;
|
||||
color: rgba(245, 240, 232, 0.72);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.audit-back {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: inherit;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.audit-error {
|
||||
color: #f5a5a5;
|
||||
}
|
||||
|
||||
.audit-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.audit-stat {
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 0.9rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.audit-stat-label {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: rgba(245, 240, 232, 0.65);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.audit-stat-value {
|
||||
font-size: 1.45rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.audit-stat-value-sm {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.audit-filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.65rem 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 8px;
|
||||
padding: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.audit-filters label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.audit-filters input,
|
||||
.audit-filters select,
|
||||
.audit-filter-actions button,
|
||||
.audit-pager button,
|
||||
.audit-chip {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.audit-filters input,
|
||||
.audit-filters select {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.55rem;
|
||||
}
|
||||
|
||||
.audit-filter-search {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.audit-filter-actions {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.audit-filter-actions button,
|
||||
.audit-pager button {
|
||||
background: rgba(201, 169, 110, 0.2);
|
||||
border: 1px solid rgba(201, 169, 110, 0.45);
|
||||
border-radius: 6px;
|
||||
padding: 0.45rem 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.audit-filter-actions button:disabled,
|
||||
.audit-pager button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.audit-secondary {
|
||||
background: transparent !important;
|
||||
border-color: rgba(255, 255, 255, 0.28) !important;
|
||||
}
|
||||
|
||||
.audit-breakdowns {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.audit-breakdown {
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 0.9rem;
|
||||
}
|
||||
|
||||
.audit-breakdown h2 {
|
||||
margin: 0 0 0.55rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.audit-breakdown ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.audit-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 999px;
|
||||
padding: 0.28rem 0.65rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.audit-chip span {
|
||||
color: rgba(201, 169, 110, 0.95);
|
||||
}
|
||||
|
||||
.audit-table-wrap {
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.audit-table-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.55rem;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(245, 240, 232, 0.7);
|
||||
}
|
||||
|
||||
.audit-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.audit-table th,
|
||||
.audit-table td {
|
||||
padding: 0.5rem 0.55rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.audit-table th {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: rgba(245, 240, 232, 0.65);
|
||||
}
|
||||
|
||||
.audit-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.audit-table tbody tr:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.audit-row-open {
|
||||
background: rgba(201, 169, 110, 0.08);
|
||||
}
|
||||
|
||||
.audit-when {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.audit-user {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.audit-role {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(245, 240, 232, 0.55);
|
||||
}
|
||||
|
||||
.audit-resource {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.audit-resource-label {
|
||||
color: rgba(201, 169, 110, 0.95);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.audit-details-cell {
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: rgba(245, 240, 232, 0.7);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.audit-details-row td {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.audit-details-row pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(245, 240, 232, 0.88);
|
||||
}
|
||||
|
||||
.audit-empty {
|
||||
color: rgba(245, 240, 232, 0.65);
|
||||
}
|
||||
|
||||
.audit-pager {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.audit-stats,
|
||||
.audit-filters,
|
||||
.audit-breakdowns {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.audit-filter-search {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.audit-stats,
|
||||
.audit-filters,
|
||||
.audit-breakdowns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.audit-filter-search {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import { useCallback, useEffect, useMemo, useState, Fragment, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
api,
|
||||
type AuditLogEntry,
|
||||
type AuditMeta,
|
||||
type AuditQuery,
|
||||
type AuditSummary,
|
||||
} from '../api/client';
|
||||
import './AuditPage.css';
|
||||
|
||||
interface Props {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
function formatWhen(iso: string) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function detailsPreview(details: Record<string, unknown> | null): string {
|
||||
if (!details || Object.keys(details).length === 0) return '—';
|
||||
try {
|
||||
const raw = JSON.stringify(details);
|
||||
return raw.length > 120 ? `${raw.slice(0, 117)}…` : raw;
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
function emptyFilters() {
|
||||
return {
|
||||
user_id: '' as string,
|
||||
action: '',
|
||||
resource_type: '',
|
||||
resource_id: '',
|
||||
from: '',
|
||||
to: '',
|
||||
q: '',
|
||||
};
|
||||
}
|
||||
|
||||
export default function AuditPage({ onBack }: Props) {
|
||||
const { t } = useTranslation('audit');
|
||||
const [meta, setMeta] = useState<AuditMeta | null>(null);
|
||||
const [summary, setSummary] = useState<AuditSummary | null>(null);
|
||||
const [entries, setEntries] = useState<AuditLogEntry[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [database, setDatabase] = useState<string | null>(null);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [filters, setFilters] = useState(emptyFilters);
|
||||
const [applied, setApplied] = useState(emptyFilters);
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const queryFromFilters = useCallback((f: ReturnType<typeof emptyFilters>, pageOffset: number): AuditQuery => {
|
||||
const q: AuditQuery = { limit: PAGE_SIZE, offset: pageOffset };
|
||||
if (f.user_id) q.user_id = Number(f.user_id);
|
||||
if (f.action) q.action = f.action;
|
||||
if (f.resource_type) q.resource_type = f.resource_type;
|
||||
if (f.resource_id.trim()) {
|
||||
const id = Number(f.resource_id);
|
||||
if (Number.isFinite(id)) q.resource_id = id;
|
||||
}
|
||||
if (f.from) q.from = new Date(f.from).toISOString();
|
||||
if (f.to) {
|
||||
// Inclusive end-of-day when only a date is provided
|
||||
const end = new Date(f.to);
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(f.to)) {
|
||||
end.setHours(23, 59, 59, 999);
|
||||
}
|
||||
q.to = end.toISOString();
|
||||
}
|
||||
if (f.q.trim()) q.q = f.q.trim();
|
||||
return q;
|
||||
}, []);
|
||||
|
||||
const load = useCallback(
|
||||
async (f: ReturnType<typeof emptyFilters>, pageOffset: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const q = queryFromFilters(f, pageOffset);
|
||||
const [list, sum] = await Promise.all([api.listAuditLog(q), api.getAuditSummary(q)]);
|
||||
setEntries(list.entries);
|
||||
setTotal(list.total);
|
||||
setDatabase(list.database ?? sum.database);
|
||||
setSummary(sum);
|
||||
setOffset(pageOffset);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[queryFromFilters, t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const m = await api.getAuditMeta();
|
||||
setMeta(m);
|
||||
setDatabase(m.database);
|
||||
} catch {
|
||||
// Meta is optional for first paint; list will surface auth errors.
|
||||
}
|
||||
await load(emptyFilters(), 0);
|
||||
})();
|
||||
}, [load]);
|
||||
|
||||
const applyFilters = (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
setApplied(filters);
|
||||
setExpandedId(null);
|
||||
void load(filters, 0);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
const cleared = emptyFilters();
|
||||
setFilters(cleared);
|
||||
setApplied(cleared);
|
||||
setExpandedId(null);
|
||||
void load(cleared, 0);
|
||||
};
|
||||
|
||||
const page = Math.floor(offset / PAGE_SIZE) + 1;
|
||||
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
const envLabel = useMemo(() => {
|
||||
if (!database) return t('databaseUnknown');
|
||||
if (database.includes('prod')) return t('databaseProd', { name: database });
|
||||
if (database.includes('dev')) return t('databaseDev', { name: database });
|
||||
return t('databaseNamed', { name: database });
|
||||
}, [database, t]);
|
||||
|
||||
return (
|
||||
<div className="audit-page">
|
||||
<header className="audit-header">
|
||||
<button type="button" className="audit-back" onClick={onBack}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<div className="audit-header-text">
|
||||
<h1>{t('title')}</h1>
|
||||
<p className="audit-env">{envLabel}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <p className="audit-error">{error}</p>}
|
||||
|
||||
{summary && (
|
||||
<section className="audit-stats">
|
||||
<div className="audit-stat">
|
||||
<span className="audit-stat-label">{t('statTotal')}</span>
|
||||
<span className="audit-stat-value">{summary.total}</span>
|
||||
</div>
|
||||
<div className="audit-stat">
|
||||
<span className="audit-stat-label">{t('stat24h')}</span>
|
||||
<span className="audit-stat-value">{summary.last_24h}</span>
|
||||
</div>
|
||||
<div className="audit-stat">
|
||||
<span className="audit-stat-label">{t('stat7d')}</span>
|
||||
<span className="audit-stat-value">{summary.last_7d}</span>
|
||||
</div>
|
||||
<div className="audit-stat">
|
||||
<span className="audit-stat-label">{t('statNewest')}</span>
|
||||
<span className="audit-stat-value audit-stat-value-sm">
|
||||
{summary.newest ? formatWhen(summary.newest) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<form className="audit-filters" onSubmit={applyFilters}>
|
||||
<label>
|
||||
{t('filterCurator')}
|
||||
<select
|
||||
value={filters.user_id}
|
||||
onChange={(e) => setFilters((p) => ({ ...p, user_id: e.target.value }))}
|
||||
>
|
||||
<option value="">{t('allCurators')}</option>
|
||||
{(meta?.users ?? []).map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.username} ({u.role})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t('filterAction')}
|
||||
<select
|
||||
value={filters.action}
|
||||
onChange={(e) => setFilters((p) => ({ ...p, action: e.target.value }))}
|
||||
>
|
||||
<option value="">{t('allActions')}</option>
|
||||
{(meta?.actions ?? []).map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{a}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t('filterResource')}
|
||||
<select
|
||||
value={filters.resource_type}
|
||||
onChange={(e) => setFilters((p) => ({ ...p, resource_type: e.target.value }))}
|
||||
>
|
||||
<option value="">{t('allResources')}</option>
|
||||
{(meta?.resource_types ?? []).map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t('filterResourceId')}
|
||||
<input
|
||||
value={filters.resource_id}
|
||||
onChange={(e) => setFilters((p) => ({ ...p, resource_id: e.target.value }))}
|
||||
inputMode="numeric"
|
||||
placeholder="e.g. 42"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('filterFrom')}
|
||||
<input
|
||||
type="date"
|
||||
value={filters.from}
|
||||
onChange={(e) => setFilters((p) => ({ ...p, from: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('filterTo')}
|
||||
<input
|
||||
type="date"
|
||||
value={filters.to}
|
||||
onChange={(e) => setFilters((p) => ({ ...p, to: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="audit-filter-search">
|
||||
{t('filterSearch')}
|
||||
<input
|
||||
value={filters.q}
|
||||
onChange={(e) => setFilters((p) => ({ ...p, q: e.target.value }))}
|
||||
placeholder={t('filterSearchPlaceholder')}
|
||||
/>
|
||||
</label>
|
||||
<div className="audit-filter-actions">
|
||||
<button type="submit">{t('apply')}</button>
|
||||
<button type="button" className="audit-secondary" onClick={clearFilters}>
|
||||
{t('clear')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{summary && (summary.by_user.length > 0 || summary.by_action.length > 0) && (
|
||||
<section className="audit-breakdowns">
|
||||
<div className="audit-breakdown">
|
||||
<h2>{t('byCurator')}</h2>
|
||||
<ul>
|
||||
{summary.by_user.map((row) => (
|
||||
<li key={row.user_id}>
|
||||
<button
|
||||
type="button"
|
||||
className="audit-chip"
|
||||
onClick={() => {
|
||||
const next = { ...applied, user_id: String(row.user_id) };
|
||||
setFilters(next);
|
||||
setApplied(next);
|
||||
void load(next, 0);
|
||||
}}
|
||||
>
|
||||
<strong>{row.username}</strong>
|
||||
<span>{row.count}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="audit-breakdown">
|
||||
<h2>{t('byAction')}</h2>
|
||||
<ul>
|
||||
{summary.by_action.slice(0, 12).map((row) => (
|
||||
<li key={row.action}>
|
||||
<button
|
||||
type="button"
|
||||
className="audit-chip"
|
||||
onClick={() => {
|
||||
const next = { ...applied, action: row.action };
|
||||
setFilters(next);
|
||||
setApplied(next);
|
||||
void load(next, 0);
|
||||
}}
|
||||
>
|
||||
<strong>{row.action}</strong>
|
||||
<span>{row.count}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="audit-table-wrap">
|
||||
<div className="audit-table-meta">
|
||||
<span>{t('showing', { count: entries.length, total })}</span>
|
||||
<span>
|
||||
{t('page', { page, pages: pageCount })}
|
||||
</span>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p>{t('loading')}</p>
|
||||
) : entries.length === 0 ? (
|
||||
<p className="audit-empty">{t('empty')}</p>
|
||||
) : (
|
||||
<table className="audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('colWhen')}</th>
|
||||
<th>{t('colCurator')}</th>
|
||||
<th>{t('colAction')}</th>
|
||||
<th>{t('colResource')}</th>
|
||||
<th>{t('colDetails')}</th>
|
||||
<th>{t('colIp')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => {
|
||||
const open = expandedId === entry.id;
|
||||
return (
|
||||
<Fragment key={entry.id}>
|
||||
<tr
|
||||
className={open ? 'audit-row-open' : undefined}
|
||||
onClick={() => setExpandedId(open ? null : entry.id)}
|
||||
>
|
||||
<td className="audit-when">{formatWhen(entry.created_at)}</td>
|
||||
<td>
|
||||
<div className="audit-user">
|
||||
<strong>{entry.username}</strong>
|
||||
<span className="audit-role">{entry.user_role}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<code>{entry.action}</code>
|
||||
</td>
|
||||
<td>
|
||||
<div className="audit-resource">
|
||||
<span>
|
||||
{entry.resource_type}
|
||||
{entry.resource_id != null ? ` #${entry.resource_id}` : ''}
|
||||
</span>
|
||||
{entry.resource_label && (
|
||||
<span className="audit-resource-label">{entry.resource_label}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="audit-details-cell">{detailsPreview(entry.details)}</td>
|
||||
<td>{entry.ip_address || '—'}</td>
|
||||
</tr>
|
||||
{open && (
|
||||
<tr className="audit-details-row">
|
||||
<td colSpan={6}>
|
||||
<pre>{JSON.stringify(entry.details ?? {}, null, 2)}</pre>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<div className="audit-pager">
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading || offset <= 0}
|
||||
onClick={() => void load(applied, Math.max(0, offset - PAGE_SIZE))}
|
||||
>
|
||||
{t('prev')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading || offset + PAGE_SIZE >= total}
|
||||
onClick={() => void load(applied, offset + PAGE_SIZE)}
|
||||
>
|
||||
{t('next')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,59 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.home-timeline-stack-vertical {
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.home-movements-section-vertical {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.site-layout-switch {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
z-index: 120;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.site-layout-link {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
background: rgba(15, 15, 26, 0.85);
|
||||
color: rgba(201, 169, 110, 0.9);
|
||||
font-size: 12px;
|
||||
font-family: 'Georgia', serif;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.site-layout-link:hover {
|
||||
background: rgba(201, 169, 110, 0.18);
|
||||
color: #f5e6c8;
|
||||
}
|
||||
|
||||
/* Link to the alternative tree start page — the one worth noticing. */
|
||||
.site-layout-link-feature {
|
||||
border-color: rgba(201, 169, 110, 0.7);
|
||||
background: rgba(201, 169, 110, 0.16);
|
||||
color: #f5e6c8;
|
||||
}
|
||||
|
||||
.site-layout-link-feature:hover {
|
||||
background: rgba(201, 169, 110, 0.3);
|
||||
}
|
||||
|
||||
.gallery-session-suspended {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -54,6 +107,8 @@
|
||||
padding: 24px 16px 8px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 110;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.site-dev-tools {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user