Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
971c1e8dd8 | ||
|
|
4088d7d57b | ||
|
|
f20f811f21 | ||
|
|
cfee69c9a6 | ||
|
|
8a68e98258 | ||
|
|
8c823a6dfe | ||
|
|
dc0ac81081 | ||
|
|
f9b0fb0496 |
@@ -80,7 +80,7 @@ Destroys the session cookie.
|
|||||||
| `tours` | Tour admin CRUD |
|
| `tours` | Tour admin CRUD |
|
||||||
| `users` | `/api/users/*` (Users page) |
|
| `users` | `/api/users/*` (Users page) |
|
||||||
|
|
||||||
Missing session → **`401`** `{ "error": "Curator login required" }`. Missing permission → **`403`** `{ "error": "Permission denied" }`.
|
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" }`.
|
||||||
|
|
||||||
### Users (admin / `users` permission)
|
### Users (admin / `users` permission)
|
||||||
|
|
||||||
@@ -93,6 +93,18 @@ Missing session → **`401`** `{ "error": "Curator login required" }`. Missing p
|
|||||||
|
|
||||||
Only **admins** can create or promote **admin** accounts. Cannot deactivate/demote the last active admin. Audit: `user.create`, `user.update`, `user.reset_password`.
|
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
|
### Staff-gated routes
|
||||||
|
|
||||||
| Route | Permission | Audit action (mutations only) |
|
| Route | Permission | Audit action (mutations only) |
|
||||||
@@ -115,10 +127,11 @@ Only **admins** can create or promote **admin** accounts. Cannot deactivate/demo
|
|||||||
| `/api/influences/*` | `influences` | `influence.*` |
|
| `/api/influences/*` | `influences` | `influence.*` |
|
||||||
| Tour admin (`/api/tours/admin`, POST/PATCH/DELETE, stops) | `tours` | `tour.*` |
|
| Tour admin (`/api/tours/admin`, POST/PATCH/DELETE, stops) | `tours` | `tour.*` |
|
||||||
| `/api/users/*` | `users` | `user.*` |
|
| `/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.
|
**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)).
|
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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -481,7 +494,7 @@ Each painting includes:
|
|||||||
|
|
||||||
| Field | Meaning |
|
| 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_checked` | Reviewed in checkup / debug workflow (gold frame in 3D when true) |
|
||||||
| `checkup_fixed` | Image replaced via **Fix it** |
|
| `checkup_fixed` | Image replaced via **Fix it** |
|
||||||
|
|
||||||
|
|||||||
@@ -251,6 +251,8 @@ Append-only log of staff mutations (fix/clear/upload/delete, checkup flags, tran
|
|||||||
|
|
||||||
**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`.
|
**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:
|
Example query in pgAdmin:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ CURATOR_USERNAME=curator
|
|||||||
CURATOR_PASSWORD=your-secure-password
|
CURATOR_PASSWORD=your-secure-password
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open the gallery → **Curator login** (top-right) → use tools allowed by your role/permissions (debug, Checkup, Translations, Influences, Tour editor, **Users**). Mutations are logged in `curator_audit_log` per user (view in pgAdmin).
|
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**).
|
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**).
|
||||||
|
|
||||||
@@ -105,11 +105,13 @@ If login fails after changing `CURATOR_PASSWORD` in `.env`, run `npm run dev:res
|
|||||||
|------|--------|
|
|------|--------|
|
||||||
| Guest (`user`) | Timeline, movement flow, 3D halls, painting detail, bios |
|
| Guest (`user`) | Timeline, movement flow, 3D halls, painting detail, bios |
|
||||||
| Curator | Public browse + assigned permission flags (`images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`) |
|
| Curator | Public browse + assigned permission flags (`images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`) |
|
||||||
| Admin | All curator tools + **Users** page to create accounts with individual passwords and permissions |
|
| Admin | All curator tools + **Users** + **Activity** audit reports |
|
||||||
|
|
||||||
**Users page:** after admin login, header → **Users** — create/edit staff, reset passwords, disable accounts.
|
**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.
|
||||||
|
|
||||||
**Audit log (pgAdmin on `gallery_dev` or `gallery_prod`):**
|
**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
|
```sql
|
||||||
SELECT l.created_at, u.username, l.action, l.resource_type, l.resource_id
|
SELECT l.created_at, u.username, l.action, l.resource_type, l.resource_id
|
||||||
@@ -143,7 +145,7 @@ 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 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 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 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` | Bidirectional catalog DB + image merge by `updated_at` / file mtime — [harmonize-dev-prod.md](harmonize-dev-prod.md) |
|
||||||
| `npm run harmonize:schema` | Apply dev migrations to prod schema only (dev → prod) |
|
| `npm run harmonize:schema` | Apply dev migrations to prod schema only (dev → prod) |
|
||||||
| `npm run harmonize:db` / `harmonize:images` | DB or image merge only (`harmonize:images` also merges artists/paintings checkup flags + image paths, then regenerates thumbs on both sides) |
|
| `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) |
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ this file contains draft for future releases and features
|
|||||||
|
|
||||||
1. ~~Multi language support, russian version at least~~ — done: UI i18n (EN/RU) + `entity_translations` DB + curator Translations tool — [i18n-russian.md](i18n-russian.md)
|
1. ~~Multi language support, russian version at least~~ — done: UI i18n (EN/RU) + `entity_translations` DB + curator Translations tool — [i18n-russian.md](i18n-russian.md)
|
||||||
2. ~~tool to manage links (influence/influenced by ) import csv's ( define format), edit ,add, delete, visualize, map to pictures/ entities~~ — done: curator Influences page (list CRUD + import wizard CSV/JSON/XLSX + neighborhood graph) — [influence-import.md](influence-import.md)
|
2. ~~tool to manage links (influence/influenced by ) import csv's ( define format), edit ,add, delete, visualize, map to pictures/ entities~~ — done: curator Influences page (list CRUD + import wizard CSV/JSON/XLSX + neighborhood graph) — [influence-import.md](influence-import.md)
|
||||||
3. tool to monitor/manage (plan actions) of curator actions, markers to check painting/text ?
|
3. ~~tool to monitor/manage (plan actions) of curator actions, markers to check painting/text ?~~ — 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)
|
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); see [DB_structure.md](DB_structure.md#curator_audit_log). (UI to browse logs is still item 3.)
|
5. ~~curator_audit_log should contain log of actions like fixit, checked, upload etc with details for which entity it was made and details what was the action and outcome~~ — done: table + `logCuratorAction` on fix/clear/upload/delete/checkup flags (and translation upsert/publish); 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`
|
6. ~~create search by entity (painting, artist, movement)~~ — done: timeline header + `GET /api/search`
|
||||||
7. ~~create guided tours (with text/extra infor, set of entities)~~ — done: `tours` / `tour_stops`, public Tours popup + 3D tour hall, curator Tour editor — [tours.md](tours.md)
|
7. ~~create guided tours (with text/extra infor, set of entities)~~ — done: `tours` / `tour_stops`, public Tours popup + 3D tour hall, curator Tour editor — [tours.md](tours.md)
|
||||||
8. ~~curator role + multi-user accounts with permissions~~ — done: `admin`/`curator` roles, permission flags, Users page + `/api/users`, per-user audit — [API.md](API.md#authentication) / [basics.md](basics.md#user-roles-and-access)
|
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)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ Interactive virtual museum spanning art history: zoomable timeline with event gu
|
|||||||
|
|
||||||
The app is organised as a **drill-down hierarchy**:
|
The app is organised as a **drill-down hierarchy**:
|
||||||
|
|
||||||
1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries.
|
1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries; classic left→right or vertical bottom→up layout (header link).
|
||||||
2. **Movement flow** — art movements as curved SVG streams on the same year axis; documented predecessor→successor branches; portrait thumbnails placed along each stream.
|
2. **Movement flow** — art movements as SVG streams on the same year axis; documented predecessor→successor branches (classic); portrait thumbnails along each stream.
|
||||||
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).
|
3. **3D gallery** — one personal hall per artist, a **movement gallery** (click a movement name → artist filter → hall), or a **guided tour** hall (timeline → **Tours**): period-themed or tour wings of up to ~55 works, U-shaped hang (left → end wall → right).
|
||||||
4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), optional **curator notes** and **art-history notes** with image markers, prev/next catalog browsing, optional fullscreen, link to artist biography.
|
4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), optional **curator notes** and **art-history notes** with image markers, prev/next catalog browsing, optional fullscreen, link to artist biography.
|
||||||
5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`). With debug mode on, the same image-audit panel as painting detail (portrait search, **Checked** / **Fix it** / **More** / **Clear** / **Upload**).
|
5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`). With debug mode on, the same image-audit panel as painting detail (portrait search, **Checked** / **Fix it** / **More** / **Clear** / **Upload**).
|
||||||
@@ -45,14 +45,19 @@ Gallery/
|
|||||||
│ │ ├── components/ArtistBio.tsx # Biography + portrait debug panel
|
│ │ ├── components/ArtistBio.tsx # Biography + portrait debug panel
|
||||||
│ │ ├── components/DebugSearchResultsModal.tsx # “More” search picker (20 results)
|
│ │ ├── components/DebugSearchResultsModal.tsx # “More” search picker (20 results)
|
||||||
│ │ ├── components/Timeline.tsx # Era bar, year ticks, event markers
|
│ │ ├── 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/MovementBands.tsx # Movement flow (SVG streams + branches)
|
||||||
|
│ │ ├── components/VerticalMovementBands.tsx # Bottom-up movement streams
|
||||||
|
│ │ ├── components/TimelineEventGuides.tsx # Event vertical guides into movement flow
|
||||||
│ │ ├── components/CatalogSearchBar.tsx # Timeline header catalog search
|
│ │ ├── components/CatalogSearchBar.tsx # Timeline header catalog search
|
||||||
│ │ ├── components/PaintingAnnotations.tsx # Art-history notes on painting detail
|
│ │ ├── components/PaintingAnnotations.tsx # Art-history notes on painting detail
|
||||||
│ │ ├── pages/CheckupPage.tsx # Image audit table
|
│ │ ├── pages/CheckupPage.tsx # Image audit table
|
||||||
│ │ ├── pages/TranslationsPage.tsx # Russian translation review
|
│ │ ├── pages/TranslationsPage.tsx # Russian translation review
|
||||||
│ │ ├── pages/InfluencesPage.tsx # Influence links CRUD + import wizard
|
│ │ ├── pages/InfluencesPage.tsx # Influence links CRUD + import wizard
|
||||||
│ │ ├── pages/ToursPage.tsx # Guided tour editor
|
│ │ ├── 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
|
│ │ ├── components/ToursPopup.tsx # Public published-tours modal
|
||||||
│ │ ├── i18n/ # react-i18next bootstrap
|
│ │ ├── i18n/ # react-i18next bootstrap
|
||||||
│ │ └── locales/{en,ru}/ # UI chrome strings
|
│ │ └── locales/{en,ru}/ # UI chrome strings
|
||||||
@@ -168,14 +173,19 @@ Implementation: `goToTimelineHome()` in `HomePage.tsx` — do not use the browse
|
|||||||
|
|
||||||
## Timeline and movement flow
|
## 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 two linked views over the **same year window** (`viewStart` / `viewEnd` in `HomePage.tsx`). A header link switches between layouts:
|
||||||
|
|
||||||
|
| Layout | Era rail | Movement flow | Time direction |
|
||||||
|
|--------|----------|---------------|----------------|
|
||||||
|
| **Classic** (default) | `Timeline.tsx` (top bar) | `MovementBands.tsx` | Left → right |
|
||||||
|
| **Vertical** | `VerticalTimeline.tsx` (left rail) | `VerticalMovementBands.tsx` (streams + lineage branches; no portraits) | Bottom → top |
|
||||||
|
|
||||||
| View | Component | Purpose |
|
| View | Component | Purpose |
|
||||||
|------|-----------|---------|
|
|------|-----------|---------|
|
||||||
| Era bar | `Timeline.tsx` | Historical eras, major event markers, click-to-zoom |
|
| Era bar / rail | `Timeline.tsx` / `VerticalTimeline.tsx` | Historical eras, major event markers, click-to-zoom |
|
||||||
| Movement flow | `MovementBands.tsx` | Curved streams per movement, lineage branches, artist portraits |
|
| Movement flow | `MovementBands.tsx` / `VerticalMovementBands.tsx` | Streams per movement, artist portraits (classic also draws lineage branches) |
|
||||||
|
|
||||||
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.
|
Both 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 places the year rail beside the flow (`home-timeline-stack-vertical`).
|
||||||
|
|
||||||
### Catalog search (timeline header)
|
### Catalog search (timeline header)
|
||||||
|
|
||||||
@@ -299,7 +309,7 @@ Each artist has **exactly one hall**. The hall is a rectangular room sized to fi
|
|||||||
| Floor | Herringbone **parquet** procedural texture (`parquetFloorTexture.ts`) |
|
| Floor | Herringbone **parquet** procedural texture (`parquetFloorTexture.ts`) |
|
||||||
| Wall tint | Gallery walls blend the artist’s **movement colour** into cream plaster tones |
|
| Wall tint | Gallery walls blend the artist’s **movement colour** into cream plaster tones |
|
||||||
| Frame finish | **Unchecked** works: black moulding; **Reviewed** (`checkup_checked`): bright gold moulding at **double width** |
|
| Frame finish | **Unchecked** works: black moulding; **Reviewed** (`checkup_checked`): bright gold moulding at **double width** |
|
||||||
| Influence lamps | A golden picture light appears **above frames** whose work has any influence-graph edge (`has_influence_links` from the API); fixture is mounted **upside down** and is **emissive-only** (no per-frame lights — see light budget below) |
|
| 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` |
|
| Curator-note plates | A small brass plate hangs **beneath frames** that have non-empty `curator_notes` |
|
||||||
| Eye-level viewing | Frame centres sit at **eye height (~1.65 m)**; the camera stays **level with the floor** (no pitch up/down) |
|
| Eye-level viewing | Frame centres sit at **eye height (~1.65 m)**; the camera stays **level with the floor** (no pitch up/down) |
|
||||||
| Open centre | Floor and ceiling only — no freestanding columns or pedestals in the walkway |
|
| Open centre | Floor and ceiling only — no freestanding columns or pedestals in the walkway |
|
||||||
@@ -336,14 +346,16 @@ Enter from the home page by clicking a **movement name** on the movement flow. F
|
|||||||
| Wall order | Same U-shaped hang as artist halls (left → end → right) |
|
| Wall order | Same U-shaped hang as artist halls (left → end → right) |
|
||||||
| Frame captions | **Year · artist** label below each frame |
|
| Frame captions | **Year · artist** label below each frame |
|
||||||
| Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, 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 interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, Byzantine basilica, NYC loft, white cube, etc.). Style keys use a legacy id map (`DB_ID_TO_STYLE_KEY`: gallery ids **1–26** → authored keys **27–52**, with Northern/High Renaissance swapped) so Byzantine gets the basilica, not Gothic stone |
|
||||||
| Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco, **marble revetment** (framed book-matched panels), **Cosmatesque paving** (`marble-opus-sectile`), **coffered timber** ceilings — plus **single-colour painted walls** (`painted-lime`, `painted-oil-matte`, `painted-oil-satin`, `painted-emulsion`, `painted-flat`) tinted per movement for Renaissance salons through modern white cubes |
|
| 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 |
|
| Textures | Wall/floor/ceiling maps applied via `useTexturedMaterial`; movement **tints** drive painted-wall hue |
|
||||||
| 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`) |
|
| 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 |
|
| Lighting | Shared hall lights only (ambient / hemisphere / directional + capped ceiling track spots + one fill per window). See **light budget** below |
|
||||||
| Period details | `MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** (never freestanding mid-hall or proud corner shafts that cover frames); `byzantine` adds engaged porphyry colonnettes with basket capitals, a marble revetment dado, and hanging brass polycandela |
|
| Period details | `MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** (never freestanding mid-hall or proud corner shafts that cover frames); `byzantine` adds engaged porphyry colonnettes with basket capitals, a marble revetment dado, and hanging brass polycandela; `gothic` adds bay-spaced compound piers with vault springers, transverse ribs arching across the nave, and a moulded string course — all flush to the side walls |
|
||||||
| Back wall | Single wing: solid display wall. Multi-wing: **Exit double doors** → **Wing navigator** or **Exit to Timeline** |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Missing images | Draped canvas cover in frame |
|
||||||
| Detail return | **Back to Gallery** from painting detail returns to the same wing with camera preserved; **Back to Timeline** exits the hall entirely |
|
| Detail return | **Back to Gallery** from painting detail returns to the same wing with camera preserved; **Back to Timeline** exits the hall entirely |
|
||||||
|
|
||||||
@@ -452,7 +464,7 @@ Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens
|
|||||||
|------|-----|--------|
|
|------|-----|--------|
|
||||||
| **`user`** | Anonymous visitor (default) | Browse timeline, movement flow, 3D artist/movement halls, painting detail, artist bios, images |
|
| **`user`** | Anonymous visitor (default) | Browse timeline, movement flow, 3D artist/movement halls, painting detail, artist bios, images |
|
||||||
| **`curator`** | Named staff account | Public browse + tools allowed by their **permission flags** |
|
| **`curator`** | Named staff account | Public browse + tools allowed by their **permission flags** |
|
||||||
| **`admin`** | Named staff account | All curator tools + **Users** management |
|
| **`admin`** | Named staff account | All curator tools + **Users** management + **Activity** audit reports |
|
||||||
|
|
||||||
**Permission flags:** `images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`. Admins always have every flag.
|
**Permission flags:** `images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`. Admins always have every flag.
|
||||||
|
|
||||||
@@ -460,7 +472,7 @@ Staff sign in via **Curator login** in the site header (individual username/pass
|
|||||||
|
|
||||||
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`).
|
Admins create and manage accounts on the **Users** page (`UsersPage.tsx` / `/api/users`). Bootstrap the first admin with `CURATOR_*` env vars + `npm run dev:migrate` (or `npm run dev:reset-curator`).
|
||||||
|
|
||||||
Mutating actions are appended to **`curator_audit_log`** with `user_id`, action, target id, optional JSON details, and client IP. Query in pgAdmin — see [DB_structure.md](DB_structure.md#curator_audit_log).
|
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)
|
## Developer tools (image audit)
|
||||||
|
|
||||||
@@ -477,6 +489,7 @@ Staff workflow for reviewing and fixing local image files (requires **`images`**
|
|||||||
| **Influences** | Home header → **Influences** (`influences`) | List/CRUD influence edges, CSV/JSON/XLSX import wizard, neighborhood graph — [influence-import.md](influence-import.md) |
|
| **Influences** | Home header → **Influences** (`influences`) | List/CRUD influence edges, CSV/JSON/XLSX import wizard, neighborhood graph — [influence-import.md](influence-import.md) |
|
||||||
| **Tour editor** | Home header → **Tour editor** (`tours`) | Create/publish guided tours and stop text — [tours.md](tours.md) |
|
| **Tour editor** | Home header → **Tour editor** (`tours`) | Create/publish guided tours and stop text — [tours.md](tours.md) |
|
||||||
| **Users** | Home header → **Users** (`users` / admin) | Create staff accounts, roles, permissions, reset passwords, disable accounts |
|
| **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) |
|
| **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 |
|
| **Logout** | Home header (staff) | Ends session; hides staff tools |
|
||||||
| **Debug panel** | Painting detail or artist bio (bottom-left, when debug mode on) | Search preview + action buttons (six on painting detail, five on artist bio) |
|
| **Debug panel** | Painting detail or artist bio (bottom-left, when debug mode on) | Search preview + action buttons (six on painting detail, five on artist bio) |
|
||||||
|
|||||||
@@ -278,7 +278,9 @@ To add or fix a branch, edit `MOVEMENT_LINEAGE` in that file and rebuild the cli
|
|||||||
|
|
||||||
## Movement gallery interiors (frontend)
|
## Movement gallery interiors (frontend)
|
||||||
|
|
||||||
Separate from movement lineage layout, `client/src/data/movement-interior-styles.ts` defines a **unique 3D interior** for each seeded art movement (26 styles): wall/floor/ceiling textures, trim colours, window style, and architectural details (pilasters, coffered ceilings, etc.). Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts` (colour maps in sRGB, normal maps in linear/`NoColorSpace`). Period mesh details live in `client/src/components/MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** so shafts never cover frames.
|
Separate from movement lineage layout, `client/src/data/movement-interior-styles.ts` defines a **unique 3D interior** for each seeded art movement (26 styles): wall/floor/ceiling textures, trim colours, window style, and architectural details (pilasters, coffered ceilings, etc.). Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts` (colour maps in sRGB, normal maps in linear/`NoColorSpace`). Period mesh details live in `client/src/components/MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** so shafts never cover frames; Byzantine adds porphyry colonnettes / polycandela; Gothic (`details: 'gothic'`) adds bay-spaced compound piers with vault springers and transverse ribs flush to the side walls.
|
||||||
|
|
||||||
|
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. Deliberately dim naves set **`lightScale`** (Byzantine / Gothic) so shared lights stay soft while window shafts stay bright.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -244,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.
|
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` is overwritten. The **dev curator account and password become the prod login**. `curator_audit_log` is **not** synced — prod keeps its existing audit history.
|
- **`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`.
|
||||||
- The `session` table is truncated, so any active prod curator sessions are logged out.
|
- 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`.
|
- 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;`
|
> **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;`
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ npm run infra:db:split-dev-prod
|
|||||||
CURATOR_PASSWORD=your-secure-password
|
CURATOR_PASSWORD=your-secure-password
|
||||||
```
|
```
|
||||||
|
|
||||||
Omit `SESSION_COOKIE_SECURE` so cookies follow the request scheme (`TRUST_PROXY` + HTTPS → Secure). Set `true`/`false` to force. `npm run dev:migrate` creates auth tables/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.
|
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:
|
2. Run:
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ 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 devtoprod:images` | Copy `data/images/` → TrueNAS via SMB `Gallery` share |
|
||||||
| `npm run prodto:dev:images` | Copy prod images → dev repo |
|
| `npm run prodto:dev:images` | Copy prod images → dev repo |
|
||||||
| `npm run prodto:dev:db` | Clone `gallery_prod` → `gallery_dev` |
|
| `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 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 prod:build` | Build production SPA into `client/dist` |
|
||||||
| `npm run dev:start` | API + static SPA on `HOST`:`PORT` (uses root `.env`) |
|
| `npm run dev:start` | API + static SPA on `HOST`:`PORT` (uses root `.env`) |
|
||||||
|
|||||||
@@ -77,6 +77,77 @@ export interface StaffUser {
|
|||||||
last_login_at: string | null;
|
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> {
|
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(url, { ...fetchCredentials, ...init });
|
const res = await fetch(url, { ...fetchCredentials, ...init });
|
||||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||||
@@ -407,6 +478,14 @@ export interface PaintingCheckupData {
|
|||||||
export const api = {
|
export const api = {
|
||||||
listUsers: () => fetchJson<{ users: StaffUser[]; permissions: StaffPermission[] }>(`${API}/users`),
|
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: {
|
createUser: (body: {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import * as THREE from 'three';
|
|
||||||
import type { GalleryWindowSpec, GalleryWindowStyle } from '../data/movement-interior-styles';
|
import type { GalleryWindowSpec, GalleryWindowStyle } from '../data/movement-interior-styles';
|
||||||
|
|
||||||
const WALL_HEIGHT = 4.2;
|
const WALL_HEIGHT = 4.2;
|
||||||
@@ -69,10 +68,56 @@ function WindowFrame({
|
|||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
{arch && style === 'gothic-lancet' && (
|
{arch && style === 'gothic-lancet' && (
|
||||||
<mesh position={[0, height / 2 + 0.15, -depth / 2 + 0.02]}>
|
<>
|
||||||
<coneGeometry args={[width / 2 + frameW, 0.5, 4]} />
|
{/* Two-centred arch head built from short chords */}
|
||||||
<meshStandardMaterial color={trimColor} roughness={0.4} metalness={0.35} />
|
{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>
|
</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' && (
|
{style === 'roman-arch' && (
|
||||||
@@ -146,8 +191,6 @@ function SingleWindow({
|
|||||||
spec: GalleryWindowSpec;
|
spec: GalleryWindowSpec;
|
||||||
trimColor: string;
|
trimColor: string;
|
||||||
}) {
|
}) {
|
||||||
const glassColor = useMemo(() => new THREE.Color(spec.lightColor), [spec.lightColor]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
<WindowFrame style={spec.style} width={spec.width} height={spec.height} trimColor={trimColor} />
|
<WindowFrame style={spec.style} width={spec.width} height={spec.height} trimColor={trimColor} />
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ interface MovementLayout {
|
|||||||
/** Band thickness in CSS pixels (proportional to influence_link_count). */
|
/** Band thickness in CSS pixels (proportional to influence_link_count). */
|
||||||
strokePx: number;
|
strokePx: number;
|
||||||
portraitSizePx: number;
|
portraitSizePx: number;
|
||||||
|
/** Saturated stream colour used for bands, branches, and artist lifespan tints. */
|
||||||
|
displayColor: string;
|
||||||
/** True when the on-band name label is wider than the visible movement span. */
|
/** True when the on-band name label is wider than the visible movement span. */
|
||||||
labelFitsInBand: boolean;
|
labelFitsInBand: boolean;
|
||||||
}
|
}
|
||||||
@@ -1953,14 +1955,13 @@ export default function MovementBands({
|
|||||||
};
|
};
|
||||||
}, [visibleMovements.length]);
|
}, [visibleMovements.length]);
|
||||||
|
|
||||||
const { layouts, layoutHeight, branches, childIdsByParent, streamStrokePx, portraitSizePx, streamCurveOffset } =
|
const { layouts, layoutHeight, branches, streamStrokePx, portraitSizePx, streamCurveOffset } =
|
||||||
useMemo(() => {
|
useMemo(() => {
|
||||||
if (visibleMovements.length === 0) {
|
if (visibleMovements.length === 0) {
|
||||||
return {
|
return {
|
||||||
layouts: [] as MovementLayout[],
|
layouts: [] as MovementLayout[],
|
||||||
layoutHeight: DEFAULT_CANVAS_HEIGHT,
|
layoutHeight: DEFAULT_CANVAS_HEIGHT,
|
||||||
branches: [] as BranchSegment[],
|
branches: [] as BranchSegment[],
|
||||||
childIdsByParent: new Map<number, number[]>(),
|
|
||||||
streamStrokePx: MAX_STREAM_STROKE_PX,
|
streamStrokePx: MAX_STREAM_STROKE_PX,
|
||||||
portraitSizePx: 78,
|
portraitSizePx: 78,
|
||||||
streamCurveOffset: 14,
|
streamCurveOffset: 14,
|
||||||
@@ -2148,7 +2149,6 @@ export default function MovementBands({
|
|||||||
layouts: [...layoutById.values()].sort((a, b) => a.depth - b.depth),
|
layouts: [...layoutById.values()].sort((a, b) => a.depth - b.depth),
|
||||||
layoutHeight,
|
layoutHeight,
|
||||||
branches: branchList,
|
branches: branchList,
|
||||||
childIdsByParent,
|
|
||||||
streamStrokePx,
|
streamStrokePx,
|
||||||
portraitSizePx,
|
portraitSizePx,
|
||||||
streamCurveOffset,
|
streamCurveOffset,
|
||||||
@@ -2250,7 +2250,6 @@ export default function MovementBands({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const layoutById = new Map((flowVisual?.layouts ?? layouts).map((l) => [l.movement.id, l]));
|
|
||||||
const drawLayouts = flowVisual?.layouts ?? layouts;
|
const drawLayouts = flowVisual?.layouts ?? layouts;
|
||||||
const drawBranches = flowVisual?.branches ?? branches;
|
const drawBranches = flowVisual?.branches ?? branches;
|
||||||
const drawCurveOffset = flowVisual?.streamCurveOffset ?? streamCurveOffset;
|
const drawCurveOffset = flowVisual?.streamCurveOffset ?? streamCurveOffset;
|
||||||
|
|||||||
@@ -100,41 +100,94 @@ function BaroqueDetails({ width, depth, halfW, halfD, trim }: Props & { trim: st
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MedievalDetails({ halfW, halfD }: Pick<Props, 'halfW' | 'halfD'>) {
|
function GothicDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
|
||||||
const torchPositions = useMemo(
|
// 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.
|
||||||
[-halfW + 0.2, -halfD * 0.5],
|
const bays = useMemo(() => {
|
||||||
[halfW - 0.2, -halfD * 0.5],
|
const spacing = 4.2;
|
||||||
[-halfW + 0.2, halfD * 0.3],
|
const count = Math.max(2, Math.min(9, Math.floor(depth / spacing)));
|
||||||
[halfW - 0.2, halfD * 0.3],
|
const step = depth / (count + 1);
|
||||||
] as [number, number][],
|
return Array.from({ length: count }, (_, i) => -halfD + step * (i + 1));
|
||||||
[halfW, halfD]
|
}, [depth, halfD]);
|
||||||
);
|
|
||||||
|
const shaftHeight = 3.5;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{torchPositions.map(([x, z], i) => (
|
{bays.map((z, i) => (
|
||||||
<group key={i} position={[x, 2.2, z]}>
|
<group key={i}>
|
||||||
<mesh>
|
{[-1, 1].map((side) => (
|
||||||
<boxGeometry args={[0.08, 0.35, 0.12]} />
|
<group key={side} position={[side * (halfW - 0.09), 0, z]}>
|
||||||
<meshStandardMaterial color="#3a3028" roughness={0.9} />
|
{/* 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>
|
</mesh>
|
||||||
<pointLight position={[0, 0.15, 0.08]} intensity={0.65} distance={5} color="#ff9830" />
|
{[-0.17, 0.17].map((dz) => (
|
||||||
<mesh position={[0, 0.2, 0.06]}>
|
<mesh key={dz} position={[0, shaftHeight / 2, dz]}>
|
||||||
<sphereGeometry args={[0.06, 8, 8]} />
|
<cylinderGeometry args={[0.052, 0.06, shaftHeight, 10]} />
|
||||||
<meshStandardMaterial color="#ffb040" emissive="#ff8010" emissiveIntensity={0.8} toneMapped={false} />
|
<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>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Rough stone courses */}
|
{/* Transverse rib arching across the nave between opposite piers */}
|
||||||
{[-halfD + 0.08, halfD - 0.08].map((z, i) => (
|
<group position={[0, shaftHeight + 0.42, z]}>
|
||||||
<mesh key={i} position={[0, 1.5, z]}>
|
{Array.from({ length: 9 }, (_, s) => {
|
||||||
<boxGeometry args={[0.04, 3, 0.04]} />
|
const segs = 9;
|
||||||
<meshStandardMaterial color="#6a6458" roughness={0.98} />
|
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>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 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>
|
</mesh>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* 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>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -324,8 +377,8 @@ export default function MovementHallDetails({ style, width, depth, halfW, halfD
|
|||||||
return <PalazzoDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
|
return <PalazzoDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
|
||||||
case 'baroque':
|
case 'baroque':
|
||||||
return <BaroqueDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
|
return <BaroqueDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
|
||||||
case 'medieval':
|
case 'gothic':
|
||||||
return <MedievalDetails halfW={halfW} halfD={halfD} />;
|
return <GothicDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
|
||||||
case 'byzantine':
|
case 'byzantine':
|
||||||
return <ByzantineDetails halfW={halfW} halfD={halfD} trim={trim} />;
|
return <ByzantineDetails halfW={halfW} halfD={halfD} trim={trim} />;
|
||||||
case 'neoclassical':
|
case 'neoclassical':
|
||||||
|
|||||||
@@ -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,791 @@
|
|||||||
|
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react';
|
||||||
|
import type { ArtMovement } from '../types';
|
||||||
|
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
|
||||||
|
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
||||||
|
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 parseHexColor(hex: string): [number, number, number] {
|
||||||
|
const normalized = hex.replace('#', '');
|
||||||
|
const value =
|
||||||
|
normalized.length === 3
|
||||||
|
? normalized
|
||||||
|
.split('')
|
||||||
|
.map((c) => c + c)
|
||||||
|
.join('')
|
||||||
|
: normalized;
|
||||||
|
const n = parseInt(value, 16);
|
||||||
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
||||||
|
r /= 255;
|
||||||
|
g /= 255;
|
||||||
|
b /= 255;
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
const l = (max + min) / 2;
|
||||||
|
if (max === min) return [0, 0, l];
|
||||||
|
const d = max - min;
|
||||||
|
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||||
|
let h = 0;
|
||||||
|
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||||
|
else if (max === g) h = ((b - r) / d + 2) / 6;
|
||||||
|
else h = ((r - g) / d + 4) / 6;
|
||||||
|
return [h * 360, s, l];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hslToHex(h: number, s: number, l: number): string {
|
||||||
|
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||||
|
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||||
|
const m = l - c / 2;
|
||||||
|
let r = 0;
|
||||||
|
let g = 0;
|
||||||
|
let b = 0;
|
||||||
|
if (h < 60) [r, g, b] = [c, x, 0];
|
||||||
|
else if (h < 120) [r, g, b] = [x, c, 0];
|
||||||
|
else if (h < 180) [r, g, b] = [0, c, x];
|
||||||
|
else if (h < 240) [r, g, b] = [0, x, c];
|
||||||
|
else if (h < 300) [r, g, b] = [x, 0, c];
|
||||||
|
else [r, g, b] = [c, 0, x];
|
||||||
|
const toByte = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
|
||||||
|
return `#${toByte(r)}${toByte(g)}${toByte(b)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function vividMovementColor(hex: string): string {
|
||||||
|
try {
|
||||||
|
const [r, g, b] = parseHexColor(hex);
|
||||||
|
const [h, s, l] = rgbToHsl(r, g, b);
|
||||||
|
const s2 = s < 0.1 ? Math.min(0.55, s + 0.42) : Math.min(1, s * 1.65 + 0.08);
|
||||||
|
const l2 =
|
||||||
|
l < 0.22 ? 0.5 : l > 0.78 ? 0.62 : Math.min(0.68, Math.max(0.4, l * 0.75 + 0.28));
|
||||||
|
return hslToHex(h, s2, l2);
|
||||||
|
} catch {
|
||||||
|
return hex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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">
|
||||||
|
Bottom → top through history · scroll to zoom · drag to pan · click a stream
|
||||||
|
</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,415 @@
|
|||||||
|
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
|
||||||
|
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 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">Bottom → top · Scroll to zoom · Drag to pan</p>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import type {
|
|||||||
} from '../types';
|
} from '../types';
|
||||||
import { galleryImageUrlCandidates, imageUrl, api } from '../api/client';
|
import { galleryImageUrlCandidates, imageUrl, api } from '../api/client';
|
||||||
import { comparePaintingsChronological, paintingHasCuratorNotes, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
|
import { comparePaintingsChronological, paintingHasCuratorNotes, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
|
||||||
import { cloneSurfaceTexture, getSurfaceTexture } from '../utils/galleryProceduralTextures';
|
import { cloneSurfaceTexture, getSurfaceTexture, type SurfaceTextureKind } from '../utils/galleryProceduralTextures';
|
||||||
import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles';
|
import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles';
|
||||||
import { useTexturedMaterial } from '../hooks/useTexturedMaterial';
|
import { useTexturedMaterial } from '../hooks/useTexturedMaterial';
|
||||||
import MovementHallDetails from './MovementHallDetails';
|
import MovementHallDetails from './MovementHallDetails';
|
||||||
@@ -191,8 +191,8 @@ function galleryWallColors(movementColor?: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Height above frame top edge — keeps fixture out of the viewer's line of sight. */
|
/** Clearance above the tallest frame in the hall for influence lamps (metres). */
|
||||||
const INFLUENCE_LAMP_ABOVE_FRAME = 0.46;
|
const INFLUENCE_LAMP_ABOVE_HIGHEST_M = 0.4;
|
||||||
|
|
||||||
type WallSide = 'back' | 'left' | 'right';
|
type WallSide = 'back' | 'left' | 'right';
|
||||||
|
|
||||||
@@ -304,6 +304,20 @@ function frameOuterH(canvasH: number, reviewed: boolean): number {
|
|||||||
return canvasH + matBorder * 2 + rail;
|
return canvasH + matBorder * 2 + rail;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** World Y of the top edge of the tallest allocated frame in the hall. */
|
||||||
|
function highestPaintingTopY(segments: WallSegment[]): number {
|
||||||
|
let maxTop = EYE_HEIGHT;
|
||||||
|
for (const seg of segments) {
|
||||||
|
for (let i = 0; i < seg.paintings.length; i++) {
|
||||||
|
const slot = seg.slots[i];
|
||||||
|
if (!slot) continue;
|
||||||
|
const top = slot.position[1] + frameOuterH(slot.maxH, paintingIsReviewed(seg.paintings[i])) / 2;
|
||||||
|
if (top > maxTop) maxTop = top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxTop;
|
||||||
|
}
|
||||||
|
|
||||||
function layoutRow(paintings: Painting[], span: number) {
|
function layoutRow(paintings: Painting[], span: number) {
|
||||||
const count = paintings.length;
|
const count = paintings.length;
|
||||||
if (count === 0) return { slots: [], spanNeeded: span };
|
if (count === 0) return { slots: [], spanNeeded: span };
|
||||||
@@ -803,22 +817,24 @@ function usePaintingTexture(urls: string[] | string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function InfluencePictureLamp({
|
function InfluencePictureLamp({
|
||||||
frameHeight,
|
frameWorldY,
|
||||||
matBorder,
|
lampWorldY,
|
||||||
frameDepth,
|
frameDepth,
|
||||||
highlighted,
|
highlighted,
|
||||||
}: {
|
}: {
|
||||||
frameHeight: number;
|
/** Painting group world Y — local lamp offset = lampWorldY − frameWorldY. */
|
||||||
matBorder: number;
|
frameWorldY: number;
|
||||||
|
/** Shared hall rail: 40 cm above the tallest frame. */
|
||||||
|
lampWorldY: number;
|
||||||
frameDepth: number;
|
frameDepth: number;
|
||||||
highlighted: boolean;
|
highlighted: boolean;
|
||||||
}) {
|
}) {
|
||||||
const mountY = frameHeight / 2 + matBorder + INFLUENCE_LAMP_ABOVE_FRAME;
|
const mountY = lampWorldY - frameWorldY;
|
||||||
const glow = highlighted ? 1.6 : 1.15;
|
const glow = highlighted ? 1.6 : 1.15;
|
||||||
const scale = 1.35;
|
const scale = 1.35;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={[0, mountY, frameDepth * 0.55]} rotation={[Math.PI, 0, 0]} scale={scale}>
|
<group position={[0, mountY, frameDepth * 0.55]} rotation={[Math.PI, Math.PI, 0]} scale={scale}>
|
||||||
<mesh position={[0, 0.04, -0.05]} renderOrder={30}>
|
<mesh position={[0, 0.04, -0.05]} renderOrder={30}>
|
||||||
<boxGeometry args={[0.11, 0.05, 0.05]} />
|
<boxGeometry args={[0.11, 0.05, 0.05]} />
|
||||||
<meshStandardMaterial color="#2f2f2f" metalness={0.9} roughness={0.2} />
|
<meshStandardMaterial color="#2f2f2f" metalness={0.9} roughness={0.2} />
|
||||||
@@ -918,6 +934,7 @@ function PaintingFrame({
|
|||||||
wallSide,
|
wallSide,
|
||||||
imageRevision,
|
imageRevision,
|
||||||
caption,
|
caption,
|
||||||
|
influenceLampWorldY,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
painting: Painting;
|
painting: Painting;
|
||||||
@@ -928,6 +945,7 @@ function PaintingFrame({
|
|||||||
wallSide: WallSide;
|
wallSide: WallSide;
|
||||||
imageRevision?: number;
|
imageRevision?: number;
|
||||||
caption?: string;
|
caption?: string;
|
||||||
|
influenceLampWorldY: number;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
@@ -1018,8 +1036,8 @@ function PaintingFrame({
|
|||||||
|
|
||||||
{hasInfluenceLinks && (
|
{hasInfluenceLinks && (
|
||||||
<InfluencePictureLamp
|
<InfluencePictureLamp
|
||||||
frameHeight={height}
|
frameWorldY={position[1]}
|
||||||
matBorder={matBorder}
|
lampWorldY={influenceLampWorldY}
|
||||||
frameDepth={frameDepth}
|
frameDepth={frameDepth}
|
||||||
highlighted={hovered}
|
highlighted={hovered}
|
||||||
/>
|
/>
|
||||||
@@ -1059,13 +1077,31 @@ function GalleryWall({
|
|||||||
size,
|
size,
|
||||||
rotation = [0, 0, 0],
|
rotation = [0, 0, 0],
|
||||||
color = '#ebe4d8',
|
color = '#ebe4d8',
|
||||||
|
kind,
|
||||||
|
tint,
|
||||||
}: {
|
}: {
|
||||||
position: [number, number, number];
|
position: [number, number, number];
|
||||||
size: [number, number];
|
size: [number, number];
|
||||||
rotation?: [number, number, number];
|
rotation?: [number, number, number];
|
||||||
color?: string;
|
color?: string;
|
||||||
|
/** When set, the panel is textured like the rest of the hall instead of flat colour. */
|
||||||
|
kind?: SurfaceTextureKind;
|
||||||
|
tint?: string;
|
||||||
}) {
|
}) {
|
||||||
const [w, h] = size;
|
const [w, h] = size;
|
||||||
|
// Door-flanking panels would otherwise read as flat single-colour blocks
|
||||||
|
// beside fully textured walls.
|
||||||
|
if (kind) {
|
||||||
|
return (
|
||||||
|
<TexturedWall
|
||||||
|
kind={kind}
|
||||||
|
tint={tint ?? color}
|
||||||
|
position={position}
|
||||||
|
size={[w, h, WALL_THICKNESS]}
|
||||||
|
rotation={rotation}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<mesh position={position} rotation={rotation} renderOrder={0}>
|
<mesh position={position} rotation={rotation} renderOrder={0}>
|
||||||
<boxGeometry args={[w, h, WALL_THICKNESS]} />
|
<boxGeometry args={[w, h, WALL_THICKNESS]} />
|
||||||
@@ -1487,6 +1523,10 @@ function ArtistHall({
|
|||||||
const hallLightScale = interiorStyle?.lightScale ?? 1;
|
const hallLightScale = interiorStyle?.lightScale ?? 1;
|
||||||
const wallRoughness = interiorStyle ? 0.75 : 0.92;
|
const wallRoughness = interiorStyle ? 0.75 : 0.92;
|
||||||
const wallMetalness = interiorStyle ? 0.08 : 0.06;
|
const wallMetalness = interiorStyle ? 0.08 : 0.06;
|
||||||
|
const influenceLampWorldY = useMemo(
|
||||||
|
() => highestPaintingTopY(segments) + INFLUENCE_LAMP_ABOVE_HIGHEST_M,
|
||||||
|
[segments]
|
||||||
|
);
|
||||||
|
|
||||||
const wallMaterial = (color: string) => (
|
const wallMaterial = (color: string) => (
|
||||||
<meshStandardMaterial color={color} roughness={wallRoughness} metalness={wallMetalness} />
|
<meshStandardMaterial color={color} roughness={wallRoughness} metalness={wallMetalness} />
|
||||||
@@ -1563,16 +1603,22 @@ function ArtistHall({
|
|||||||
position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]}
|
position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]}
|
||||||
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
|
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
|
||||||
color={walls.main}
|
color={walls.main}
|
||||||
|
kind={interiorStyle?.surfaces.wall}
|
||||||
|
tint={interiorStyle?.tints.wall}
|
||||||
/>
|
/>
|
||||||
<GalleryWall
|
<GalleryWall
|
||||||
position={[(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]}
|
position={[(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]}
|
||||||
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
|
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
|
||||||
color={walls.main}
|
color={walls.main}
|
||||||
|
kind={interiorStyle?.surfaces.wall}
|
||||||
|
tint={interiorStyle?.tints.wall}
|
||||||
/>
|
/>
|
||||||
<GalleryWall
|
<GalleryWall
|
||||||
position={[0, DOOR_HEIGHT + (WALL_HEIGHT - DOOR_HEIGHT) / 2, halfD]}
|
position={[0, DOOR_HEIGHT + (WALL_HEIGHT - DOOR_HEIGHT) / 2, halfD]}
|
||||||
size={[DOOR_WIDTH, WALL_HEIGHT - DOOR_HEIGHT]}
|
size={[DOOR_WIDTH, WALL_HEIGHT - DOOR_HEIGHT]}
|
||||||
color={walls.main}
|
color={walls.main}
|
||||||
|
kind={interiorStyle?.surfaces.wall}
|
||||||
|
tint={interiorStyle?.tints.wall}
|
||||||
/>
|
/>
|
||||||
<HallPassage
|
<HallPassage
|
||||||
position={[0, 0, halfD - WALL_THICKNESS / 2 - 0.02]}
|
position={[0, 0, halfD - WALL_THICKNESS / 2 - 0.02]}
|
||||||
@@ -1584,12 +1630,20 @@ function ArtistHall({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : movementMode && endWallHasDoor ? (
|
) : movementMode && endWallHasDoor ? (
|
||||||
<GalleryWall position={[0, WALL_HEIGHT / 2, halfD]} size={[width, WALL_HEIGHT]} color={walls.main} />
|
<GalleryWall position={[0, WALL_HEIGHT / 2, halfD]} size={[width, WALL_HEIGHT]} color={walls.main}
|
||||||
|
kind={interiorStyle?.surfaces.wall}
|
||||||
|
tint={interiorStyle?.tints.wall} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<GalleryWall position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]} size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]} color={walls.main} />
|
<GalleryWall position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]} size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]} color={walls.main}
|
||||||
<GalleryWall position={[(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]} size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]} color={walls.main} />
|
kind={interiorStyle?.surfaces.wall}
|
||||||
<GalleryWall position={[0, DOOR_HEIGHT + (WALL_HEIGHT - DOOR_HEIGHT) / 2, halfD]} size={[DOOR_WIDTH, WALL_HEIGHT - DOOR_HEIGHT]} color={walls.main} />
|
tint={interiorStyle?.tints.wall} />
|
||||||
|
<GalleryWall position={[(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]} size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]} color={walls.main}
|
||||||
|
kind={interiorStyle?.surfaces.wall}
|
||||||
|
tint={interiorStyle?.tints.wall} />
|
||||||
|
<GalleryWall position={[0, DOOR_HEIGHT + (WALL_HEIGHT - DOOR_HEIGHT) / 2, halfD]} size={[DOOR_WIDTH, WALL_HEIGHT - DOOR_HEIGHT]} color={walls.main}
|
||||||
|
kind={interiorStyle?.surfaces.wall}
|
||||||
|
tint={interiorStyle?.tints.wall} />
|
||||||
<ExitPortal
|
<ExitPortal
|
||||||
position={[0, 0, halfD - WALL_THICKNESS / 2 - 0.02]}
|
position={[0, 0, halfD - WALL_THICKNESS / 2 - 0.02]}
|
||||||
active={nearExit}
|
active={nearExit}
|
||||||
@@ -1608,16 +1662,22 @@ function ArtistHall({
|
|||||||
position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, -halfD]}
|
position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, -halfD]}
|
||||||
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
|
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
|
||||||
color={walls.main}
|
color={walls.main}
|
||||||
|
kind={interiorStyle?.surfaces.wall}
|
||||||
|
tint={interiorStyle?.tints.wall}
|
||||||
/>
|
/>
|
||||||
<GalleryWall
|
<GalleryWall
|
||||||
position={[(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, -halfD]}
|
position={[(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, -halfD]}
|
||||||
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
|
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
|
||||||
color={walls.main}
|
color={walls.main}
|
||||||
|
kind={interiorStyle?.surfaces.wall}
|
||||||
|
tint={interiorStyle?.tints.wall}
|
||||||
/>
|
/>
|
||||||
<GalleryWall
|
<GalleryWall
|
||||||
position={[0, DOOR_HEIGHT + (WALL_HEIGHT - DOOR_HEIGHT) / 2, -halfD]}
|
position={[0, DOOR_HEIGHT + (WALL_HEIGHT - DOOR_HEIGHT) / 2, -halfD]}
|
||||||
size={[DOOR_WIDTH, WALL_HEIGHT - DOOR_HEIGHT]}
|
size={[DOOR_WIDTH, WALL_HEIGHT - DOOR_HEIGHT]}
|
||||||
color={walls.main}
|
color={walls.main}
|
||||||
|
kind={interiorStyle?.surfaces.wall}
|
||||||
|
tint={interiorStyle?.tints.wall}
|
||||||
/>
|
/>
|
||||||
<group position={[0, 0, -halfD + WALL_THICKNESS / 2 + 0.02]} rotation={[0, Math.PI, 0]}>
|
<group position={[0, 0, -halfD + WALL_THICKNESS / 2 + 0.02]} rotation={[0, Math.PI, 0]}>
|
||||||
<ExitPortal
|
<ExitPortal
|
||||||
@@ -1714,6 +1774,7 @@ function ArtistHall({
|
|||||||
wallSide={seg.slots[i].side}
|
wallSide={seg.slots[i].side}
|
||||||
imageRevision={imageRevisions?.[painting.id]}
|
imageRevision={imageRevisions?.[painting.id]}
|
||||||
caption={showCaptions ? paintingWallCaption(painting) : undefined}
|
caption={showCaptions ? paintingWallCaption(painting) : undefined}
|
||||||
|
influenceLampWorldY={influenceLampWorldY}
|
||||||
onClick={() => onPaintingClick(painting.id)}
|
onClick={() => onPaintingClick(painting.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export interface MovementInteriorStyle {
|
|||||||
trackLights: number;
|
trackLights: number;
|
||||||
/** Scales the shared hall/scene lights. <1 for deliberately dim period interiors. */
|
/** Scales the shared hall/scene lights. <1 for deliberately dim period interiors. */
|
||||||
lightScale: number;
|
lightScale: number;
|
||||||
details: 'palazzo' | 'baroque' | 'medieval' | 'byzantine' | 'neoclassical' | 'salon' | 'modern' | 'classical' | 'museum' | 'industrial' | 'atelier';
|
details: 'palazzo' | 'baroque' | 'gothic' | 'byzantine' | 'neoclassical' | 'salon' | 'modern' | 'classical' | 'museum' | 'industrial' | 'atelier';
|
||||||
}
|
}
|
||||||
|
|
||||||
function windows(...specs: GalleryWindowSpec[]): GalleryWindowSpec[] {
|
function windows(...specs: GalleryWindowSpec[]): GalleryWindowSpec[] {
|
||||||
@@ -144,26 +144,30 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
),
|
),
|
||||||
29: mk(
|
29: mk(
|
||||||
'gothic-cathedral',
|
'gothic-cathedral',
|
||||||
'Gothic hall',
|
'Gothic nave',
|
||||||
'Stone vault · tall lancet windows · flagstone floor',
|
'Blind-arcaded ashlar · ribbed stone vault · worn flagstones',
|
||||||
{ wall: 'rough-stone', ceiling: 'basalt', floor: 'flagstone' },
|
{ wall: 'gothic-ashlar', ceiling: 'gothic-vault', floor: 'gothic-stone-floor' },
|
||||||
{ wall: '#8a8478', ceiling: '#3a3630', floor: '#6a6458', trim: '#5c5648' },
|
{ wall: '#8d8471', ceiling: '#6d6555', floor: '#6f6759', trim: '#7d7159' },
|
||||||
{
|
{
|
||||||
details: 'medieval',
|
details: 'gothic',
|
||||||
titleColor: '#e8dcc8',
|
titleColor: '#efe4cc',
|
||||||
ambient: 0.72,
|
ambient: 0.6,
|
||||||
warmLight: '#e8d8c0',
|
warmLight: '#ffe6bc',
|
||||||
sunLight: '#d0e8ff',
|
sunLight: '#dbe8ff',
|
||||||
// Keep atmosphere dark, but not pure void (walls must stay readable).
|
// Cool shadowed stone, but never a pure void — walls must stay readable.
|
||||||
fog: '#1a1816',
|
fog: '#1b1a18',
|
||||||
background: '#141210',
|
background: '#131211',
|
||||||
windows: windows(
|
windows: windows(
|
||||||
{ wall: 'back', x: -2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.5 },
|
{ wall: 'back', x: -2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.8 },
|
||||||
{ 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.8 },
|
||||||
{ wall: 'left', x: 0, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 2.8 },
|
{ 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: 0, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 2.8 }
|
{ 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.85,
|
trackLights: 0.5,
|
||||||
|
// Naves are lit by window shafts against shadowed stone, not flooded.
|
||||||
|
lightScale: 0.26,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
30: mk(
|
30: mk(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import enTranslations from '../locales/en/translations.json';
|
|||||||
import enInfluences from '../locales/en/influences.json';
|
import enInfluences from '../locales/en/influences.json';
|
||||||
import enTours from '../locales/en/tours.json';
|
import enTours from '../locales/en/tours.json';
|
||||||
import enUsers from '../locales/en/users.json';
|
import enUsers from '../locales/en/users.json';
|
||||||
|
import enAudit from '../locales/en/audit.json';
|
||||||
|
|
||||||
import ruCommon from '../locales/ru/common.json';
|
import ruCommon from '../locales/ru/common.json';
|
||||||
import ruHome from '../locales/ru/home.json';
|
import ruHome from '../locales/ru/home.json';
|
||||||
@@ -27,6 +28,7 @@ import ruTranslations from '../locales/ru/translations.json';
|
|||||||
import ruInfluences from '../locales/ru/influences.json';
|
import ruInfluences from '../locales/ru/influences.json';
|
||||||
import ruTours from '../locales/ru/tours.json';
|
import ruTours from '../locales/ru/tours.json';
|
||||||
import ruUsers from '../locales/ru/users.json';
|
import ruUsers from '../locales/ru/users.json';
|
||||||
|
import ruAudit from '../locales/ru/audit.json';
|
||||||
|
|
||||||
const initialLocale = readStoredLocale();
|
const initialLocale = readStoredLocale();
|
||||||
writeStoredLocale(initialLocale);
|
writeStoredLocale(initialLocale);
|
||||||
@@ -35,7 +37,7 @@ void i18n.use(initReactI18next).init({
|
|||||||
lng: initialLocale,
|
lng: initialLocale,
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
supportedLngs: ['en', 'ru'],
|
supportedLngs: ['en', 'ru'],
|
||||||
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences', 'tours', 'users'],
|
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences', 'tours', 'users', 'audit'],
|
||||||
defaultNS: 'common',
|
defaultNS: 'common',
|
||||||
resources: {
|
resources: {
|
||||||
en: {
|
en: {
|
||||||
@@ -51,6 +53,7 @@ void i18n.use(initReactI18next).init({
|
|||||||
influences: enInfluences,
|
influences: enInfluences,
|
||||||
tours: enTours,
|
tours: enTours,
|
||||||
users: enUsers,
|
users: enUsers,
|
||||||
|
audit: enAudit,
|
||||||
},
|
},
|
||||||
ru: {
|
ru: {
|
||||||
common: ruCommon,
|
common: ruCommon,
|
||||||
@@ -65,6 +68,7 @@ void i18n.use(initReactI18next).init({
|
|||||||
influences: ruInfluences,
|
influences: ruInfluences,
|
||||||
tours: ruTours,
|
tours: ruTours,
|
||||||
users: ruUsers,
|
users: ruUsers,
|
||||||
|
audit: ruAudit,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
interpolation: { escapeValue: false },
|
interpolation: { escapeValue: false },
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -18,6 +18,9 @@
|
|||||||
"tours": "Tours",
|
"tours": "Tours",
|
||||||
"toursEditor": "Tour editor",
|
"toursEditor": "Tour editor",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
|
"audit": "Activity",
|
||||||
|
"layoutHorizontal": "Classic timeline →",
|
||||||
|
"layoutVertical": "↑ Vertical timeline",
|
||||||
"openingTourGallery": "Opening guided tour…",
|
"openingTourGallery": "Opening guided tour…",
|
||||||
"tourEmpty": "This tour has no paintings yet.",
|
"tourEmpty": "This tour has no paintings yet.",
|
||||||
"tourLoadFailed": "Failed to load the tour.",
|
"tourLoadFailed": "Failed to load the tour.",
|
||||||
|
|||||||
@@ -0,0 +1,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": "Далее"
|
||||||
|
}
|
||||||
@@ -18,6 +18,9 @@
|
|||||||
"tours": "Экскурсии",
|
"tours": "Экскурсии",
|
||||||
"toursEditor": "Редактор экскурсий",
|
"toursEditor": "Редактор экскурсий",
|
||||||
"users": "Пользователи",
|
"users": "Пользователи",
|
||||||
|
"audit": "Активность",
|
||||||
|
"layoutHorizontal": "Классическая шкала →",
|
||||||
|
"layoutVertical": "↑ Вертикальная шкала",
|
||||||
"openingTourGallery": "Открытие экскурсии…",
|
"openingTourGallery": "Открытие экскурсии…",
|
||||||
"tourEmpty": "В этой экскурсии пока нет картин.",
|
"tourEmpty": "В этой экскурсии пока нет картин.",
|
||||||
"tourLoadFailed": "Не удалось загрузить экскурсию.",
|
"tourLoadFailed": "Не удалось загрузить экскурсию.",
|
||||||
|
|||||||
@@ -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,45 @@
|
|||||||
overflow: hidden;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
.gallery-session-suspended {
|
.gallery-session-suspended {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import Timeline from '../components/Timeline';
|
import Timeline from '../components/Timeline';
|
||||||
import TimelineEventGuides from '../components/TimelineEventGuides';
|
import TimelineEventGuides from '../components/TimelineEventGuides';
|
||||||
import MovementBands from '../components/MovementBands';
|
import MovementBands from '../components/MovementBands';
|
||||||
|
import VerticalTimeline from '../components/VerticalTimeline';
|
||||||
|
import VerticalMovementBands from '../components/VerticalMovementBands';
|
||||||
import VirtualGallery from '../components/VirtualGallery';
|
import VirtualGallery from '../components/VirtualGallery';
|
||||||
import PaintingDetailView from '../components/PaintingDetail';
|
import PaintingDetailView from '../components/PaintingDetail';
|
||||||
import ArtistBio from '../components/ArtistBio';
|
import ArtistBio from '../components/ArtistBio';
|
||||||
@@ -11,6 +13,7 @@ import TranslationsPage from '../pages/TranslationsPage';
|
|||||||
import InfluencesPage from '../pages/InfluencesPage';
|
import InfluencesPage from '../pages/InfluencesPage';
|
||||||
import ToursPage from '../pages/ToursPage';
|
import ToursPage from '../pages/ToursPage';
|
||||||
import UsersPage from '../pages/UsersPage';
|
import UsersPage from '../pages/UsersPage';
|
||||||
|
import AuditPage from '../pages/AuditPage';
|
||||||
import CuratorLoginModal from '../components/CuratorLoginModal';
|
import CuratorLoginModal from '../components/CuratorLoginModal';
|
||||||
import ArtistFilterModal from '../components/ArtistFilterModal';
|
import ArtistFilterModal from '../components/ArtistFilterModal';
|
||||||
import ToursPopup from '../components/ToursPopup';
|
import ToursPopup from '../components/ToursPopup';
|
||||||
@@ -26,6 +29,7 @@ import '../pages/TranslationsPage.css';
|
|||||||
import '../pages/InfluencesPage.css';
|
import '../pages/InfluencesPage.css';
|
||||||
import '../pages/ToursPage.css';
|
import '../pages/ToursPage.css';
|
||||||
import '../pages/UsersPage.css';
|
import '../pages/UsersPage.css';
|
||||||
|
import '../pages/AuditPage.css';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
||||||
import type {
|
import type {
|
||||||
@@ -45,11 +49,13 @@ import './HomePage.css';
|
|||||||
|
|
||||||
type View =
|
type View =
|
||||||
| { type: 'timeline' }
|
| { type: 'timeline' }
|
||||||
|
| { type: 'timeline-vertical' }
|
||||||
| { type: 'checkup' }
|
| { type: 'checkup' }
|
||||||
| { type: 'translations' }
|
| { type: 'translations' }
|
||||||
| { type: 'influences' }
|
| { type: 'influences' }
|
||||||
| { type: 'tours' }
|
| { type: 'tours' }
|
||||||
| { type: 'users' }
|
| { type: 'users' }
|
||||||
|
| { type: 'audit' }
|
||||||
| { type: 'gallery'; artistId: number; data: ArtistDetail }
|
| { type: 'gallery'; artistId: number; data: ArtistDetail }
|
||||||
| { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail }
|
| { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail }
|
||||||
| { type: 'tour-gallery'; tourId: number; data: TourGalleryDetail }
|
| { type: 'tour-gallery'; tourId: number; data: TourGalleryDetail }
|
||||||
@@ -61,7 +67,7 @@ type GallerySession =
|
|||||||
| { kind: 'movement'; movementId: number; data: MovementGalleryDetail }
|
| { kind: 'movement'; movementId: number; data: MovementGalleryDetail }
|
||||||
| { kind: 'tour'; tourId: number; data: TourGalleryDetail };
|
| { kind: 'tour'; tourId: number; data: TourGalleryDetail };
|
||||||
|
|
||||||
type CuratorLoginRedirect = 'checkup' | 'translations' | 'influences' | 'tours' | 'users' | null;
|
type CuratorLoginRedirect = 'checkup' | 'translations' | 'influences' | 'tours' | 'users' | 'audit' | null;
|
||||||
|
|
||||||
function patchPaintingInMovementDetail(
|
function patchPaintingInMovementDetail(
|
||||||
detail: MovementGalleryDetail,
|
detail: MovementGalleryDetail,
|
||||||
@@ -153,7 +159,7 @@ function catalogNavigateTarget(
|
|||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const { t } = useTranslation('home');
|
const { t } = useTranslation('home');
|
||||||
const { isCurator, username, login, logout, can } = useAuth();
|
const { isCurator, isAdmin, username, login, logout, can } = useAuth();
|
||||||
const canImages = can('images');
|
const canImages = can('images');
|
||||||
const canCheckup = can('checkup');
|
const canCheckup = can('checkup');
|
||||||
const canNotes = can('curator_notes');
|
const canNotes = can('curator_notes');
|
||||||
@@ -204,7 +210,7 @@ export default function HomePage() {
|
|||||||
setGallerySession({ kind: 'movement', movementId: view.movementId, data: view.data });
|
setGallerySession({ kind: 'movement', movementId: view.movementId, data: view.data });
|
||||||
} else if (view.type === 'tour-gallery') {
|
} else if (view.type === 'tour-gallery') {
|
||||||
setGallerySession({ kind: 'tour', tourId: view.tourId, data: view.data });
|
setGallerySession({ kind: 'tour', tourId: view.tourId, data: view.data });
|
||||||
} else if (view.type === 'timeline') {
|
} else if (view.type === 'timeline' || view.type === 'timeline-vertical') {
|
||||||
setGallerySession(null);
|
setGallerySession(null);
|
||||||
}
|
}
|
||||||
}, [view]);
|
}, [view]);
|
||||||
@@ -268,6 +274,17 @@ export default function HomePage() {
|
|||||||
setView({ type: 'timeline' });
|
setView({ type: 'timeline' });
|
||||||
}, [bounds.min, bounds.max]);
|
}, [bounds.min, bounds.max]);
|
||||||
|
|
||||||
|
const openHorizontalTimeline = () => {
|
||||||
|
setView({ type: 'timeline' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const openVerticalTimeline = () => {
|
||||||
|
setView({ type: 'timeline-vertical' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTimelineHome = view.type === 'timeline' || view.type === 'timeline-vertical';
|
||||||
|
const isVerticalTimeline = view.type === 'timeline-vertical';
|
||||||
|
|
||||||
const toggleDebugMode = () => {
|
const toggleDebugMode = () => {
|
||||||
setDebugMode((prev) => {
|
setDebugMode((prev) => {
|
||||||
const next = !prev;
|
const next = !prev;
|
||||||
@@ -299,6 +316,8 @@ export default function HomePage() {
|
|||||||
setView({ type: 'tours' });
|
setView({ type: 'tours' });
|
||||||
} else if (loginRedirect === 'users') {
|
} else if (loginRedirect === 'users') {
|
||||||
setView({ type: 'users' });
|
setView({ type: 'users' });
|
||||||
|
} else if (loginRedirect === 'audit') {
|
||||||
|
setView({ type: 'audit' });
|
||||||
}
|
}
|
||||||
setLoginRedirect(null);
|
setLoginRedirect(null);
|
||||||
};
|
};
|
||||||
@@ -312,7 +331,8 @@ export default function HomePage() {
|
|||||||
view.type === 'translations' ||
|
view.type === 'translations' ||
|
||||||
view.type === 'influences' ||
|
view.type === 'influences' ||
|
||||||
view.type === 'tours' ||
|
view.type === 'tours' ||
|
||||||
view.type === 'users'
|
view.type === 'users' ||
|
||||||
|
view.type === 'audit'
|
||||||
) {
|
) {
|
||||||
goToTimelineHome();
|
goToTimelineHome();
|
||||||
}
|
}
|
||||||
@@ -358,6 +378,14 @@ export default function HomePage() {
|
|||||||
setView({ type: 'users' });
|
setView({ type: 'users' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openAudit = () => {
|
||||||
|
if (!isAdmin) {
|
||||||
|
openCuratorLogin('audit');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setView({ type: 'audit' });
|
||||||
|
};
|
||||||
|
|
||||||
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
|
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
|
||||||
const data = await api.getPainting(paintingId);
|
const data = await api.getPainting(paintingId);
|
||||||
const patch: Partial<Painting> = {
|
const patch: Partial<Painting> = {
|
||||||
@@ -1104,6 +1132,25 @@ export default function HomePage() {
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{view.type === 'audit' && (
|
||||||
|
isAdmin ? (
|
||||||
|
<AuditPage onBack={goToTimelineHome} />
|
||||||
|
) : (
|
||||||
|
<div className="curator-login-gate">
|
||||||
|
<h2>{t('curatorRequiredTitle')}</h2>
|
||||||
|
<p>{t('curatorRequiredBody')}</p>
|
||||||
|
<div className="curator-login-gate-actions">
|
||||||
|
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('audit')}>
|
||||||
|
{t('curatorLogin')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
|
||||||
|
{t('backToGalleryBtn')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
{view.type === 'translations' && (
|
{view.type === 'translations' && (
|
||||||
canTranslations ? (
|
canTranslations ? (
|
||||||
<TranslationsPage onBack={goToTimelineHome} />
|
<TranslationsPage onBack={goToTimelineHome} />
|
||||||
@@ -1171,9 +1218,30 @@ export default function HomePage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{view.type === 'timeline' && (
|
{isTimelineHome && (
|
||||||
<div className="home-page">
|
<div className="home-page">
|
||||||
<header className="site-header">
|
<header className="site-header">
|
||||||
|
<div className="site-layout-switch">
|
||||||
|
{isVerticalTimeline ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="site-layout-link"
|
||||||
|
onClick={openHorizontalTimeline}
|
||||||
|
title="Switch to classic left-to-right timeline"
|
||||||
|
>
|
||||||
|
{t('layoutHorizontal')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="site-layout-link"
|
||||||
|
onClick={openVerticalTimeline}
|
||||||
|
title="Switch to bottom-up vertical timeline"
|
||||||
|
>
|
||||||
|
{t('layoutVertical')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="site-dev-tools">
|
<div className="site-dev-tools">
|
||||||
{isCurator ? (
|
{isCurator ? (
|
||||||
<>
|
<>
|
||||||
@@ -1253,6 +1321,16 @@ export default function HomePage() {
|
|||||||
{t('users')}
|
{t('users')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="checkup-link-btn"
|
||||||
|
onClick={openAudit}
|
||||||
|
title="Curator activity audit reports"
|
||||||
|
>
|
||||||
|
{t('audit')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="curator-logout-btn"
|
className="curator-logout-btn"
|
||||||
@@ -1291,7 +1369,7 @@ export default function HomePage() {
|
|||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="home-timeline-stack">
|
<div className={`home-timeline-stack${isVerticalTimeline ? ' home-timeline-stack-vertical' : ''}`}>
|
||||||
{loading && (
|
{loading && (
|
||||||
<GalleryLoadingMarker overlay message="Loading art history…" />
|
<GalleryLoadingMarker overlay message="Loading art history…" />
|
||||||
)}
|
)}
|
||||||
@@ -1302,6 +1380,34 @@ export default function HomePage() {
|
|||||||
<GalleryLoadingMarker banner message="Loading portraits…" />
|
<GalleryLoadingMarker banner message="Loading portraits…" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isVerticalTimeline ? (
|
||||||
|
<>
|
||||||
|
<VerticalTimeline
|
||||||
|
eras={timelineData.eras}
|
||||||
|
viewStart={viewStart}
|
||||||
|
viewEnd={viewEnd}
|
||||||
|
onViewChange={handleViewChange}
|
||||||
|
absoluteMin={bounds.min}
|
||||||
|
absoluteMax={bounds.max}
|
||||||
|
lifespanHighlight={hoveredLifespan}
|
||||||
|
/>
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
{!loading && (
|
||||||
|
<div className="home-movements-section-vertical">
|
||||||
|
<VerticalMovementBands
|
||||||
|
movements={timelineData.movements}
|
||||||
|
viewStart={viewStart}
|
||||||
|
viewEnd={viewEnd}
|
||||||
|
absoluteMin={bounds.min}
|
||||||
|
absoluteMax={bounds.max}
|
||||||
|
onViewChange={handleViewChange}
|
||||||
|
onMovementClick={handleMovementClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Timeline
|
<Timeline
|
||||||
eras={timelineData.eras}
|
eras={timelineData.eras}
|
||||||
viewStart={viewStart}
|
viewStart={viewStart}
|
||||||
@@ -1335,6 +1441,8 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export type SurfaceTextureKind =
|
|||||||
| 'limestone'
|
| 'limestone'
|
||||||
| 'sandstone'
|
| 'sandstone'
|
||||||
| 'rough-stone'
|
| 'rough-stone'
|
||||||
|
| 'gothic-ashlar'
|
||||||
|
| 'gothic-vault'
|
||||||
|
| 'gothic-stone-floor'
|
||||||
| 'basalt'
|
| 'basalt'
|
||||||
| 'stucco-cream'
|
| 'stucco-cream'
|
||||||
| 'stucco-terracotta'
|
| 'stucco-terracotta'
|
||||||
@@ -247,6 +250,52 @@ function marbleVeined(ctx: CanvasRenderingContext2D, size: number, base: string,
|
|||||||
noiseOverlay(ctx, size, 0.035, seed + 1);
|
noiseOverlay(ctx, size, 0.035, seed + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-centred (pointed) arch path from the springing line up to the apex.
|
||||||
|
* `springY` is the bottom of the curve, `apexY` the crown — canvas y grows down.
|
||||||
|
*/
|
||||||
|
function pointedArchPath(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
cx: number,
|
||||||
|
halfW: number,
|
||||||
|
springY: number,
|
||||||
|
apexY: number
|
||||||
|
) {
|
||||||
|
const shoulder = apexY + (springY - apexY) * 0.28;
|
||||||
|
ctx.moveTo(cx - halfW, springY);
|
||||||
|
ctx.quadraticCurveTo(cx - halfW, shoulder, cx, apexY);
|
||||||
|
ctx.quadraticCurveTo(cx + halfW, shoulder, cx + halfW, springY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coursed ashlar masonry — staggered blocks with cut joints and per-block tone. */
|
||||||
|
function ashlarCourses(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
size: number,
|
||||||
|
base: string,
|
||||||
|
seed: number,
|
||||||
|
courses = 12
|
||||||
|
) {
|
||||||
|
fill(ctx, size, base);
|
||||||
|
const [br, bg, bb] = hexToRgb(base);
|
||||||
|
const ch = size / courses;
|
||||||
|
const bw = size / 6;
|
||||||
|
for (let row = 0; row < courses; row++) {
|
||||||
|
const offset = row % 2 ? bw / 2 : 0;
|
||||||
|
for (let col = -1; col < 7; col++) {
|
||||||
|
const rand = seeded(seed + row * 11 + col * 3);
|
||||||
|
const t = (rand() - 0.5) * 22;
|
||||||
|
ctx.fillStyle = rgb(br + t, bg + t, bb + t * 0.9);
|
||||||
|
ctx.fillRect(col * bw + offset, row * ch, bw - 1.5, ch - 1.5);
|
||||||
|
}
|
||||||
|
// Recessed bed joint with a lit lower lip
|
||||||
|
ctx.fillStyle = 'rgba(60,52,40,0.35)';
|
||||||
|
ctx.fillRect(0, row * ch + ch - 1.5, size, 1.5);
|
||||||
|
ctx.fillStyle = 'rgba(255,250,235,0.16)';
|
||||||
|
ctx.fillRect(0, row * ch, size, 1);
|
||||||
|
}
|
||||||
|
noiseOverlay(ctx, size, 0.05, seed + 1);
|
||||||
|
}
|
||||||
|
|
||||||
/** Veined marble confined to one slab rect — for revetment panels and inlay. */
|
/** Veined marble confined to one slab rect — for revetment panels and inlay. */
|
||||||
function marbleSlab(
|
function marbleSlab(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
@@ -572,6 +621,235 @@ function paintSurface(kind: SurfaceTextureKind, size: number): ImageData {
|
|||||||
fill(ctx, size, '#3a3834');
|
fill(ctx, size, '#3a3834');
|
||||||
noiseOverlay(ctx, size, 0.15, 107);
|
noiseOverlay(ctx, size, 0.15, 107);
|
||||||
break;
|
break;
|
||||||
|
case 'gothic-ashlar': {
|
||||||
|
// One tile spans a full wall height: plinth, tall blind arcade, string
|
||||||
|
// course, triforium gallery, cornice — so the wall reads as architecture
|
||||||
|
// rather than a flat stone panel. Relief comes through the normal map.
|
||||||
|
ashlarCourses(ctx, size, '#c9bda6', 210, 13);
|
||||||
|
|
||||||
|
const stringCourse = (y: number, h: number) => {
|
||||||
|
ctx.fillStyle = 'rgba(250,244,228,0.5)';
|
||||||
|
ctx.fillRect(0, y, size, h * 0.45);
|
||||||
|
ctx.fillStyle = 'rgba(74,64,50,0.45)';
|
||||||
|
ctx.fillRect(0, y + h * 0.45, size, h * 0.55);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Plinth and cornice
|
||||||
|
ctx.fillStyle = 'rgba(60,52,40,0.22)';
|
||||||
|
ctx.fillRect(0, size * 0.9, size, size * 0.1);
|
||||||
|
stringCourse(size * 0.885, size * 0.022);
|
||||||
|
stringCourse(size * 0.055, size * 0.028);
|
||||||
|
stringCourse(size * 0.375, size * 0.024);
|
||||||
|
|
||||||
|
// Tall blind arcade — 3 bays across the tile
|
||||||
|
const bays = 3;
|
||||||
|
const bayW = size / bays;
|
||||||
|
for (let i = 0; i < bays; i++) {
|
||||||
|
const cx = bayW * (i + 0.5);
|
||||||
|
const halfW = bayW * 0.36;
|
||||||
|
const springY = size * 0.66;
|
||||||
|
const apexY = size * 0.43;
|
||||||
|
|
||||||
|
// Recessed panel behind the arch
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
pointedArchPath(ctx, cx, halfW, springY, apexY);
|
||||||
|
ctx.lineTo(cx + halfW, size * 0.885);
|
||||||
|
ctx.lineTo(cx - halfW, size * 0.885);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.clip();
|
||||||
|
ctx.fillStyle = 'rgba(52,44,34,0.34)';
|
||||||
|
ctx.fillRect(cx - halfW, apexY, halfW * 2, size);
|
||||||
|
const shade = ctx.createLinearGradient(cx - halfW, 0, cx + halfW, 0);
|
||||||
|
shade.addColorStop(0, 'rgba(20,16,12,0.4)');
|
||||||
|
shade.addColorStop(0.45, 'rgba(20,16,12,0)');
|
||||||
|
shade.addColorStop(1, 'rgba(20,16,12,0.28)');
|
||||||
|
ctx.fillStyle = shade;
|
||||||
|
ctx.fillRect(cx - halfW, apexY, halfW * 2, size);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Arch moulding: lit outer roll, dark soffit
|
||||||
|
ctx.beginPath();
|
||||||
|
pointedArchPath(ctx, cx, halfW + size * 0.016, springY, apexY - size * 0.02);
|
||||||
|
ctx.strokeStyle = 'rgba(252,246,230,0.62)';
|
||||||
|
ctx.lineWidth = size * 0.016;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
pointedArchPath(ctx, cx, halfW, springY, apexY);
|
||||||
|
ctx.strokeStyle = 'rgba(58,48,36,0.5)';
|
||||||
|
ctx.lineWidth = size * 0.008;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Colonnette shafts carrying the arch
|
||||||
|
for (const sx of [cx - halfW, cx + halfW]) {
|
||||||
|
ctx.fillStyle = 'rgba(250,244,228,0.5)';
|
||||||
|
ctx.fillRect(sx - size * 0.011, springY, size * 0.014, size * 0.225);
|
||||||
|
ctx.fillStyle = 'rgba(58,48,36,0.42)';
|
||||||
|
ctx.fillRect(sx + size * 0.003, springY, size * 0.005, size * 0.225);
|
||||||
|
// Moulded capital and base
|
||||||
|
ctx.fillStyle = 'rgba(252,246,230,0.62)';
|
||||||
|
ctx.fillRect(sx - size * 0.019, springY - size * 0.016, size * 0.038, size * 0.016);
|
||||||
|
ctx.fillRect(sx - size * 0.017, size * 0.868, size * 0.034, size * 0.017);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Triforium gallery — paired small arches per bay
|
||||||
|
for (let i = 0; i < bays; i++) {
|
||||||
|
const bayCx = bayW * (i + 0.5);
|
||||||
|
for (const sub of [-1, 1]) {
|
||||||
|
const cx = bayCx + sub * bayW * 0.19;
|
||||||
|
const halfW = bayW * 0.13;
|
||||||
|
const springY = size * 0.29;
|
||||||
|
const apexY = size * 0.135;
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
pointedArchPath(ctx, cx, halfW, springY, apexY);
|
||||||
|
ctx.lineTo(cx + halfW, size * 0.345);
|
||||||
|
ctx.lineTo(cx - halfW, size * 0.345);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.clip();
|
||||||
|
ctx.fillStyle = 'rgba(28,24,18,0.55)';
|
||||||
|
ctx.fillRect(cx - halfW, apexY, halfW * 2, size * 0.3);
|
||||||
|
ctx.restore();
|
||||||
|
ctx.beginPath();
|
||||||
|
pointedArchPath(ctx, cx, halfW + size * 0.009, springY, apexY - size * 0.012);
|
||||||
|
ctx.strokeStyle = 'rgba(252,246,230,0.58)';
|
||||||
|
ctx.lineWidth = size * 0.009;
|
||||||
|
ctx.stroke();
|
||||||
|
for (const sx of [cx - halfW, cx + halfW]) {
|
||||||
|
ctx.fillStyle = 'rgba(250,244,228,0.5)';
|
||||||
|
ctx.fillRect(sx - size * 0.006, springY, size * 0.008, size * 0.055);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
noiseOverlay(ctx, size, 0.035, 211);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'gothic-vault': {
|
||||||
|
// Quadripartite rib vault with tiercerons, seen from below: webbing
|
||||||
|
// panels lift toward a gilded central boss.
|
||||||
|
fill(ctx, size, '#cec4ac');
|
||||||
|
const c = size / 2;
|
||||||
|
const glow = ctx.createRadialGradient(c, c, size * 0.05, c, c, size * 0.62);
|
||||||
|
glow.addColorStop(0, 'rgba(255,250,236,0.55)');
|
||||||
|
glow.addColorStop(1, 'rgba(38,32,25,0.72)');
|
||||||
|
ctx.fillStyle = glow;
|
||||||
|
ctx.fillRect(0, 0, size, size);
|
||||||
|
for (let y = 0; y < size; y += 4) {
|
||||||
|
for (let x = 0; x < size; x += 4) {
|
||||||
|
const n = (fbm(x / 90, y / 90, 221) - 0.5) * 14;
|
||||||
|
ctx.fillStyle = `rgba(${210 + n | 0},${200 + n | 0},${176 + n | 0},0.35)`;
|
||||||
|
ctx.fillRect(x, y, 4, 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rib = (x0: number, y0: number, x1: number, y1: number, w: number) => {
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.strokeStyle = 'rgba(48,40,30,0.5)';
|
||||||
|
ctx.lineWidth = w * 1.55;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x0, y0);
|
||||||
|
ctx.lineTo(x1, y1);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.strokeStyle = 'rgba(236,228,206,0.95)';
|
||||||
|
ctx.lineWidth = w;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x0, y0);
|
||||||
|
ctx.lineTo(x1, y1);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.strokeStyle = 'rgba(255,252,242,0.75)';
|
||||||
|
ctx.lineWidth = w * 0.32;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x0, y0);
|
||||||
|
ctx.lineTo(x1, y1);
|
||||||
|
ctx.stroke();
|
||||||
|
};
|
||||||
|
|
||||||
|
const major = size * 0.044;
|
||||||
|
const minor = size * 0.026;
|
||||||
|
// Transverse and wall ribs around the bay
|
||||||
|
rib(0, 0, size, 0, major);
|
||||||
|
rib(0, size, size, size, major);
|
||||||
|
rib(0, 0, 0, size, major);
|
||||||
|
rib(size, 0, size, size, major);
|
||||||
|
// Diagonal cross ribs
|
||||||
|
rib(0, 0, size, size, major);
|
||||||
|
rib(size, 0, 0, size, major);
|
||||||
|
// Tiercerons springing to the edge midpoints
|
||||||
|
rib(0, 0, c, 0, minor);
|
||||||
|
rib(0, 0, 0, c, minor);
|
||||||
|
rib(size, 0, c, 0, minor);
|
||||||
|
rib(size, 0, size, c, minor);
|
||||||
|
rib(0, size, c, size, minor);
|
||||||
|
rib(0, size, 0, c, minor);
|
||||||
|
rib(size, size, c, size, minor);
|
||||||
|
rib(size, size, size, c, minor);
|
||||||
|
// Ridge ribs
|
||||||
|
rib(c, 0, c, size, minor);
|
||||||
|
rib(0, c, size, c, minor);
|
||||||
|
|
||||||
|
// Carved boss at the crown
|
||||||
|
ctx.fillStyle = 'rgba(46,38,28,0.55)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(c, c, size * 0.052, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#c9a94e';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(c, c, size * 0.042, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = 'rgba(255,246,216,0.75)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(c - size * 0.008, c - size * 0.008, size * 0.02, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
noiseOverlay(ctx, size, 0.03, 222);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'gothic-stone-floor': {
|
||||||
|
// Worn limestone flags with dark diamond cabochons at the slab corners.
|
||||||
|
fill(ctx, size, '#a89c86');
|
||||||
|
const n = 4;
|
||||||
|
const cell = size / n;
|
||||||
|
for (let row = 0; row < n; row++) {
|
||||||
|
for (let col = 0; col < n; col++) {
|
||||||
|
const x = col * cell;
|
||||||
|
const y = row * cell;
|
||||||
|
const rand = seeded(230 + row * n + col);
|
||||||
|
const t = (rand() - 0.5) * 26;
|
||||||
|
ctx.fillStyle = rgb(178 + t, 168 + t, 146 + t);
|
||||||
|
ctx.fillRect(x + 2, y + 2, cell - 4, cell - 4);
|
||||||
|
// Footfall polish toward the slab centre
|
||||||
|
const wear = ctx.createRadialGradient(
|
||||||
|
x + cell / 2, y + cell / 2, cell * 0.05,
|
||||||
|
x + cell / 2, y + cell / 2, cell * 0.55
|
||||||
|
);
|
||||||
|
wear.addColorStop(0, 'rgba(226,218,198,0.35)');
|
||||||
|
wear.addColorStop(1, 'rgba(226,218,198,0)');
|
||||||
|
ctx.fillStyle = wear;
|
||||||
|
ctx.fillRect(x + 2, y + 2, cell - 4, cell - 4);
|
||||||
|
ctx.strokeStyle = 'rgba(58,50,40,0.45)';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.strokeRect(x + 2, y + 2, cell - 4, cell - 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Dark cabochons where four slabs meet
|
||||||
|
for (let row = 1; row < n; row++) {
|
||||||
|
for (let col = 1; col < n; col++) {
|
||||||
|
const cx = col * cell;
|
||||||
|
const cy = row * cell;
|
||||||
|
const r = cell * 0.085;
|
||||||
|
ctx.fillStyle = '#3c3a3e';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx, cy - r);
|
||||||
|
ctx.lineTo(cx + r, cy);
|
||||||
|
ctx.lineTo(cx, cy + r);
|
||||||
|
ctx.lineTo(cx - r, cy);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
noiseOverlay(ctx, size, 0.05, 231);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'stucco-cream':
|
case 'stucco-cream':
|
||||||
stuccoSurface(ctx, size, '#f0e8d8', 108);
|
stuccoSurface(ctx, size, '#f0e8d8', 108);
|
||||||
break;
|
break;
|
||||||
@@ -842,6 +1120,11 @@ function normalStrengthFor(kind: SurfaceTextureKind): number {
|
|||||||
if (kind.includes('stucco') || kind.includes('plaster')) return 2.6;
|
if (kind.includes('stucco') || kind.includes('plaster')) return 2.6;
|
||||||
if (kind.includes('panel') || kind.includes('oak') || kind.includes('walnut')) return 2.8;
|
if (kind.includes('panel') || kind.includes('oak') || kind.includes('walnut')) return 2.8;
|
||||||
if (kind === 'brick' || kind === 'rough-stone') return 3.2;
|
if (kind === 'brick' || kind === 'rough-stone') return 3.2;
|
||||||
|
// Arcading and rib mouldings are drawn as light/dark bands — lean on the
|
||||||
|
// derived normals so they read as carved relief, not painted-on lines.
|
||||||
|
if (kind === 'gothic-ashlar') return 4.2;
|
||||||
|
if (kind === 'gothic-vault') return 3.6;
|
||||||
|
if (kind === 'gothic-stone-floor') return 2.4;
|
||||||
return 2.5;
|
return 2.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -853,6 +1136,9 @@ const ROUGHNESS: Partial<Record<SurfaceTextureKind, number>> = {
|
|||||||
'marble-opus-sectile': 0.2,
|
'marble-opus-sectile': 0.2,
|
||||||
'marble-revetment': 0.34,
|
'marble-revetment': 0.34,
|
||||||
'coffered-wood': 0.72,
|
'coffered-wood': 0.72,
|
||||||
|
'gothic-ashlar': 0.84,
|
||||||
|
'gothic-vault': 0.9,
|
||||||
|
'gothic-stone-floor': 0.56,
|
||||||
'gilded-stucco': 0.22,
|
'gilded-stucco': 0.22,
|
||||||
'velvet-crimson': 0.92,
|
'velvet-crimson': 0.92,
|
||||||
'velvet-navy': 0.92,
|
'velvet-navy': 0.92,
|
||||||
@@ -894,6 +1180,10 @@ const METERS: Partial<Record<SurfaceTextureKind, number>> = {
|
|||||||
'marble-opus-sectile': 5.0,
|
'marble-opus-sectile': 5.0,
|
||||||
'marble-revetment': 4.4,
|
'marble-revetment': 4.4,
|
||||||
'coffered-wood': 4.2,
|
'coffered-wood': 4.2,
|
||||||
|
// Matches WALL_HEIGHT so one tile = one full storey of arcading.
|
||||||
|
'gothic-ashlar': 4.2,
|
||||||
|
'gothic-vault': 5.5,
|
||||||
|
'gothic-stone-floor': 3.2,
|
||||||
flagstone: 3.0,
|
flagstone: 3.0,
|
||||||
'mosaic-byzantine': 1.8,
|
'mosaic-byzantine': 1.8,
|
||||||
'mosaic-roman': 2.0,
|
'mosaic-roman': 2.0,
|
||||||
@@ -919,6 +1209,9 @@ const NORMAL_SCALE: Partial<Record<SurfaceTextureKind, number>> = {
|
|||||||
'marble-opus-sectile': 0.55,
|
'marble-opus-sectile': 0.55,
|
||||||
'marble-revetment': 0.6,
|
'marble-revetment': 0.6,
|
||||||
'coffered-wood': 1.0,
|
'coffered-wood': 1.0,
|
||||||
|
'gothic-ashlar': 1.0,
|
||||||
|
'gothic-vault': 0.85,
|
||||||
|
'gothic-stone-floor': 0.45,
|
||||||
'velvet-crimson': 0.5,
|
'velvet-crimson': 0.5,
|
||||||
'velvet-navy': 0.5,
|
'velvet-navy': 0.5,
|
||||||
'velvet-emerald': 0.5,
|
'velvet-emerald': 0.5,
|
||||||
|
|||||||
|
After Width: | Height: | Size: 13 MiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 42 KiB |
@@ -19,7 +19,47 @@ const { printCliResult } = require('./lib/cli-result');
|
|||||||
const { Client } = pg;
|
const { Client } = pg;
|
||||||
|
|
||||||
// Prod restore keeps these tables untouched (no TRUNCATE, no INSERT from dev backup).
|
// Prod restore keeps these tables untouched (no TRUNCATE, no INSERT from dev backup).
|
||||||
const PROD_RESTORE_SKIP_TABLES = new Set(['curator_audit_log']);
|
// Staff accounts, sessions, and audit history stay env-local — never overwrite from gallery_dev.
|
||||||
|
const PROD_RESTORE_SKIP_TABLES = new Set(['users', 'session', 'curator_audit_log']);
|
||||||
|
|
||||||
|
function quoteIdent(name) {
|
||||||
|
return `"${String(name).replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Align serial/identity sequences with MAX(column) after explicit-id INSERTs. */
|
||||||
|
async function syncSerialSequences(client) {
|
||||||
|
const { rows } = await client.query(
|
||||||
|
`SELECT
|
||||||
|
c.relname AS table_name,
|
||||||
|
a.attname AS column_name,
|
||||||
|
pg_get_serial_sequence(format('%I.%I', n.nspname, c.relname), a.attname) AS seq_name
|
||||||
|
FROM pg_class c
|
||||||
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
|
||||||
|
JOIN pg_attrdef d ON d.adrelid = c.oid AND d.adnum = a.attnum
|
||||||
|
WHERE c.relkind = 'r'
|
||||||
|
AND n.nspname = 'public'
|
||||||
|
AND pg_get_expr(d.adbin, d.adrelid) LIKE 'nextval(%'`
|
||||||
|
);
|
||||||
|
|
||||||
|
let synced = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.seq_name) continue;
|
||||||
|
await client.query(
|
||||||
|
`SELECT setval(
|
||||||
|
$1::regclass,
|
||||||
|
GREATEST(
|
||||||
|
1,
|
||||||
|
COALESCE((SELECT MAX(${quoteIdent(row.column_name)}) FROM ${quoteIdent(row.table_name)}), 1)
|
||||||
|
),
|
||||||
|
true
|
||||||
|
)`,
|
||||||
|
[row.seq_name]
|
||||||
|
);
|
||||||
|
synced += 1;
|
||||||
|
}
|
||||||
|
return synced;
|
||||||
|
}
|
||||||
|
|
||||||
function getInsertTableName(statement) {
|
function getInsertTableName(statement) {
|
||||||
const match = statement.match(/^INSERT INTO "([^"]+)"/i)
|
const match = statement.match(/^INSERT INTO "([^"]+)"/i)
|
||||||
@@ -239,6 +279,12 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backups insert explicit primary keys; without this, serial nextval() can
|
||||||
|
// collide with existing ids (e.g. creating a user fails as "already exists").
|
||||||
|
console.log('Syncing serial sequences to MAX(id)...');
|
||||||
|
const synced = await syncSerialSequences(client);
|
||||||
|
console.log(` synced ${synced} sequence(s)`);
|
||||||
|
|
||||||
await client.end();
|
await client.end();
|
||||||
console.log(`Restore complete: ${restored} statements into "${dbName}".`);
|
console.log(`Restore complete: ${restored} statements into "${dbName}".`);
|
||||||
if (skippedInserts > 0) {
|
if (skippedInserts > 0) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const { requirePermission } = require('./middleware/auth');
|
|||||||
const { logCuratorAction } = require('./audit-log');
|
const { logCuratorAction } = require('./audit-log');
|
||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
const usersRoutes = require('./routes/users');
|
const usersRoutes = require('./routes/users');
|
||||||
|
const auditRoutes = require('./routes/audit');
|
||||||
const { ensurePaintingImages, preloadArtistImagesLocal, preloadMovementImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, deletePainting, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, enrichPaintingRow, enrichArtistRow, IMAGE_DIR } = require('./image-service');
|
const { ensurePaintingImages, preloadArtistImagesLocal, preloadMovementImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, deletePainting, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, enrichPaintingRow, enrichArtistRow, IMAGE_DIR } = require('./image-service');
|
||||||
const { getVersionInfo } = require('./version-info');
|
const { getVersionInfo } = require('./version-info');
|
||||||
const { searchCatalog } = require('./search-service');
|
const { searchCatalog } = require('./search-service');
|
||||||
@@ -46,6 +47,7 @@ app.use(express.json({ limit: '20mb' }));
|
|||||||
app.use(createSessionMiddleware());
|
app.use(createSessionMiddleware());
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/users', usersRoutes);
|
app.use('/api/users', usersRoutes);
|
||||||
|
app.use('/api/audit', auditRoutes);
|
||||||
app.use('/api/translations', translationRoutes);
|
app.use('/api/translations', translationRoutes);
|
||||||
app.use('/api/influences', influenceRoutes);
|
app.use('/api/influences', influenceRoutes);
|
||||||
app.use('/api/tours', tourRoutes);
|
app.use('/api/tours', tourRoutes);
|
||||||
|
|||||||
@@ -79,6 +79,33 @@ function requirePermission(permission) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Active staff with role admin only. */
|
||||||
|
async function requireAdmin(req, res, next) {
|
||||||
|
const userId = req.session?.userId;
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(401).json({ error: 'Curator login required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await loadStaffUser(userId);
|
||||||
|
if (!user || !user.is_active) {
|
||||||
|
req.session.destroy(() => {});
|
||||||
|
return res.status(401).json({ error: 'Curator login required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
attachStaff(req, user);
|
||||||
|
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ error: 'Admin access required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Auth middleware error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Authentication failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function staffAuthPayload(user) {
|
function staffAuthPayload(user) {
|
||||||
return {
|
return {
|
||||||
role: user.role,
|
role: user.role,
|
||||||
@@ -90,6 +117,7 @@ function staffAuthPayload(user) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
requireCurator,
|
requireCurator,
|
||||||
requirePermission,
|
requirePermission,
|
||||||
|
requireAdmin,
|
||||||
loadStaffUser,
|
loadStaffUser,
|
||||||
staffAuthPayload,
|
staffAuthPayload,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const pool = require('../db');
|
||||||
|
const { requireAdmin } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(requireAdmin);
|
||||||
|
|
||||||
|
const MAX_LIMIT = 200;
|
||||||
|
const DEFAULT_LIMIT = 50;
|
||||||
|
|
||||||
|
function parseOptionalInt(raw) {
|
||||||
|
if (raw == null || raw === '') return null;
|
||||||
|
const n = parseInt(String(raw), 10);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalDate(raw) {
|
||||||
|
if (raw == null || raw === '') return null;
|
||||||
|
const d = new Date(String(raw));
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFilters(query) {
|
||||||
|
const clauses = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
const userId = parseOptionalInt(query.user_id);
|
||||||
|
if (userId != null) {
|
||||||
|
params.push(userId);
|
||||||
|
clauses.push(`l.user_id = $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const username =
|
||||||
|
typeof query.username === 'string' && query.username.trim()
|
||||||
|
? query.username.trim()
|
||||||
|
: null;
|
||||||
|
if (username) {
|
||||||
|
params.push(username);
|
||||||
|
clauses.push(`u.username = $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const action =
|
||||||
|
typeof query.action === 'string' && query.action.trim() ? query.action.trim() : null;
|
||||||
|
if (action) {
|
||||||
|
params.push(action);
|
||||||
|
clauses.push(`l.action = $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceType =
|
||||||
|
typeof query.resource_type === 'string' && query.resource_type.trim()
|
||||||
|
? query.resource_type.trim()
|
||||||
|
: null;
|
||||||
|
if (resourceType) {
|
||||||
|
params.push(resourceType);
|
||||||
|
clauses.push(`l.resource_type = $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceId = parseOptionalInt(query.resource_id);
|
||||||
|
if (resourceId != null) {
|
||||||
|
params.push(resourceId);
|
||||||
|
clauses.push(`l.resource_id = $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = parseOptionalDate(query.from);
|
||||||
|
if (from) {
|
||||||
|
params.push(from.toISOString());
|
||||||
|
clauses.push(`l.created_at >= $${params.length}::timestamptz`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const to = parseOptionalDate(query.to);
|
||||||
|
if (to) {
|
||||||
|
params.push(to.toISOString());
|
||||||
|
clauses.push(`l.created_at <= $${params.length}::timestamptz`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = typeof query.q === 'string' && query.q.trim() ? query.q.trim() : null;
|
||||||
|
if (q) {
|
||||||
|
params.push(`%${q}%`);
|
||||||
|
const idx = params.length;
|
||||||
|
clauses.push(`(
|
||||||
|
l.action ILIKE $${idx}
|
||||||
|
OR l.resource_type ILIKE $${idx}
|
||||||
|
OR u.username ILIKE $${idx}
|
||||||
|
OR COALESCE(l.details::text, '') ILIKE $${idx}
|
||||||
|
OR COALESCE(l.ip_address, '') ILIKE $${idx}
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
where: clauses.length ? `WHERE ${clauses.join(' AND ')}` : '',
|
||||||
|
params,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESOURCE_LABEL_SQL = `
|
||||||
|
CASE l.resource_type
|
||||||
|
WHEN 'painting' THEN (SELECT title FROM paintings WHERE id = l.resource_id)
|
||||||
|
WHEN 'artist' THEN (SELECT name FROM artists WHERE id = l.resource_id)
|
||||||
|
WHEN 'user' THEN (SELECT username FROM users WHERE id = l.resource_id)
|
||||||
|
WHEN 'tour' THEN (SELECT title FROM tours WHERE id = l.resource_id)
|
||||||
|
WHEN 'movement' THEN (SELECT name FROM art_movements WHERE id = l.resource_id)
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
`;
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { where, params } = buildFilters(req.query);
|
||||||
|
let limit = parseOptionalInt(req.query.limit) ?? DEFAULT_LIMIT;
|
||||||
|
let offset = parseOptionalInt(req.query.offset) ?? 0;
|
||||||
|
limit = Math.min(MAX_LIMIT, Math.max(1, limit));
|
||||||
|
offset = Math.max(0, offset);
|
||||||
|
|
||||||
|
const countResult = await pool.query(
|
||||||
|
`SELECT COUNT(*)::int AS total
|
||||||
|
FROM curator_audit_log l
|
||||||
|
JOIN users u ON u.id = l.user_id
|
||||||
|
${where}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
const listParams = [...params, limit, offset];
|
||||||
|
const limitIdx = params.length + 1;
|
||||||
|
const offsetIdx = params.length + 2;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
l.id,
|
||||||
|
l.created_at,
|
||||||
|
l.user_id,
|
||||||
|
u.username,
|
||||||
|
u.role AS user_role,
|
||||||
|
l.action,
|
||||||
|
l.resource_type,
|
||||||
|
l.resource_id,
|
||||||
|
l.details,
|
||||||
|
l.ip_address,
|
||||||
|
(${RESOURCE_LABEL_SQL}) AS resource_label
|
||||||
|
FROM curator_audit_log l
|
||||||
|
JOIN users u ON u.id = l.user_id
|
||||||
|
${where}
|
||||||
|
ORDER BY l.created_at DESC, l.id DESC
|
||||||
|
LIMIT $${limitIdx} OFFSET $${offsetIdx}`,
|
||||||
|
listParams
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
database: process.env.DB_NAME || null,
|
||||||
|
total: countResult.rows[0].total,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
entries: rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
created_at: row.created_at,
|
||||||
|
user_id: row.user_id,
|
||||||
|
username: row.username,
|
||||||
|
user_role: row.user_role,
|
||||||
|
action: row.action,
|
||||||
|
resource_type: row.resource_type,
|
||||||
|
resource_id: row.resource_id,
|
||||||
|
resource_label: row.resource_label,
|
||||||
|
details: row.details,
|
||||||
|
ip_address: row.ip_address,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Audit list error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Failed to load audit log' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/summary', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { where, params } = buildFilters(req.query);
|
||||||
|
|
||||||
|
const [byUser, byAction, byResource, totals] = await Promise.all([
|
||||||
|
pool.query(
|
||||||
|
`SELECT u.id AS user_id, u.username, u.role, COUNT(*)::int AS count
|
||||||
|
FROM curator_audit_log l
|
||||||
|
JOIN users u ON u.id = l.user_id
|
||||||
|
${where}
|
||||||
|
GROUP BY u.id, u.username, u.role
|
||||||
|
ORDER BY count DESC, u.username ASC`,
|
||||||
|
params
|
||||||
|
),
|
||||||
|
pool.query(
|
||||||
|
`SELECT l.action, COUNT(*)::int AS count
|
||||||
|
FROM curator_audit_log l
|
||||||
|
JOIN users u ON u.id = l.user_id
|
||||||
|
${where}
|
||||||
|
GROUP BY l.action
|
||||||
|
ORDER BY count DESC, l.action ASC`,
|
||||||
|
params
|
||||||
|
),
|
||||||
|
pool.query(
|
||||||
|
`SELECT l.resource_type, COUNT(*)::int AS count
|
||||||
|
FROM curator_audit_log l
|
||||||
|
JOIN users u ON u.id = l.user_id
|
||||||
|
${where}
|
||||||
|
GROUP BY l.resource_type
|
||||||
|
ORDER BY count DESC, l.resource_type ASC`,
|
||||||
|
params
|
||||||
|
),
|
||||||
|
pool.query(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*)::int AS total,
|
||||||
|
COUNT(*) FILTER (WHERE l.created_at >= NOW() - INTERVAL '24 hours')::int AS last_24h,
|
||||||
|
COUNT(*) FILTER (WHERE l.created_at >= NOW() - INTERVAL '7 days')::int AS last_7d,
|
||||||
|
MIN(l.created_at) AS oldest,
|
||||||
|
MAX(l.created_at) AS newest
|
||||||
|
FROM curator_audit_log l
|
||||||
|
JOIN users u ON u.id = l.user_id
|
||||||
|
${where}`,
|
||||||
|
params
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const t = totals.rows[0] || {};
|
||||||
|
res.json({
|
||||||
|
database: process.env.DB_NAME || null,
|
||||||
|
total: t.total || 0,
|
||||||
|
last_24h: t.last_24h || 0,
|
||||||
|
last_7d: t.last_7d || 0,
|
||||||
|
oldest: t.oldest || null,
|
||||||
|
newest: t.newest || null,
|
||||||
|
by_user: byUser.rows,
|
||||||
|
by_action: byAction.rows,
|
||||||
|
by_resource_type: byResource.rows,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Audit summary error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Failed to load audit summary' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/meta', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const [users, actions, resourceTypes] = await Promise.all([
|
||||||
|
pool.query(
|
||||||
|
`SELECT DISTINCT u.id, u.username, u.role
|
||||||
|
FROM curator_audit_log l
|
||||||
|
JOIN users u ON u.id = l.user_id
|
||||||
|
ORDER BY u.username ASC`
|
||||||
|
),
|
||||||
|
pool.query(
|
||||||
|
`SELECT DISTINCT action FROM curator_audit_log ORDER BY action ASC`
|
||||||
|
),
|
||||||
|
pool.query(
|
||||||
|
`SELECT DISTINCT resource_type FROM curator_audit_log ORDER BY resource_type ASC`
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
database: process.env.DB_NAME || null,
|
||||||
|
users: users.rows,
|
||||||
|
actions: actions.rows.map((r) => r.action),
|
||||||
|
resource_types: resourceTypes.rows.map((r) => r.resource_type),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Audit meta error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Failed to load audit filters' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -38,6 +38,17 @@ async function clearUserSessions(userId) {
|
|||||||
await pool.query(`DELETE FROM session WHERE (sess->>'userId')::int = $1`, [userId]);
|
await pool.query(`DELETE FROM session WHERE (sess->>'userId')::int = $1`, [userId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Keep users_id_seq ahead of existing rows (restore inserts explicit ids). */
|
||||||
|
async function syncUsersIdSequence() {
|
||||||
|
await pool.query(
|
||||||
|
`SELECT setval(
|
||||||
|
pg_get_serial_sequence('users', 'id'),
|
||||||
|
GREATEST(1, COALESCE((SELECT MAX(id) FROM users), 1)),
|
||||||
|
true
|
||||||
|
)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
router.use(requirePermission('users'));
|
router.use(requirePermission('users'));
|
||||||
|
|
||||||
router.get('/', async (_req, res) => {
|
router.get('/', async (_req, res) => {
|
||||||
@@ -76,6 +87,16 @@ router.post('/', async (req, res) => {
|
|||||||
const passwordHash = await bcrypt.hash(password, 10);
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
const storedPermissions = role === 'admin' ? ALL_PERMISSIONS : permissions;
|
const storedPermissions = role === 'admin' ? ALL_PERMISSIONS : permissions;
|
||||||
|
|
||||||
|
const { rows: taken } = await pool.query(
|
||||||
|
`SELECT username FROM users WHERE LOWER(username) = LOWER($1) LIMIT 1`,
|
||||||
|
[username]
|
||||||
|
);
|
||||||
|
if (taken[0]) {
|
||||||
|
return res.status(409).json({ error: 'Username already exists' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncUsersIdSequence();
|
||||||
|
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`INSERT INTO users (username, password_hash, role, permissions, is_active)
|
`INSERT INTO users (username, password_hash, role, permissions, is_active)
|
||||||
VALUES ($1, $2, $3, $4::text[], true)
|
VALUES ($1, $2, $3, $4::text[], true)
|
||||||
@@ -95,6 +116,12 @@ router.post('/', async (req, res) => {
|
|||||||
res.status(201).json({ user: mapUser(rows[0]) });
|
res.status(201).json({ user: mapUser(rows[0]) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.code === '23505') {
|
if (err.code === '23505') {
|
||||||
|
if (err.constraint === 'users_pkey') {
|
||||||
|
console.error('Users create PK conflict (sequence lag):', err.detail || err.message);
|
||||||
|
return res.status(409).json({
|
||||||
|
error: 'Could not allocate user id — retry create (sequence was out of sync)',
|
||||||
|
});
|
||||||
|
}
|
||||||
return res.status(409).json({ error: 'Username already exists' });
|
return res.status(409).json({ error: 'Username already exists' });
|
||||||
}
|
}
|
||||||
console.error('Users create error:', err.message);
|
console.error('Users create error:', err.message);
|
||||||
|
|||||||