Author SHA1 Message Date
Danila KhodjaefandCursor 971c1e8dd8 Add vertical bottom-up timeline with center-out streams and smooth layout transitions.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 21:31:30 +03:00
Danila KhodjaefandCursor 4088d7d57b Document influence lamp shared-rail height in API.md.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 22:42:33 +03:00
Danila KhodjaefandCursor f20f811f21 Align influence lamps 40cm above the tallest hall frame.
Compute the highest allocated frame top per hall and place every influence lamp on that shared rail so markers line up across different canvas sizes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 22:42:23 +03:00
Danila KhodjaefandCursor cfee69c9a6 Keep prod users local and fix post-restore id sequences.
Prod restore skips users/session/audit, syncs serial sequences after load, and user create re-aligns users_id_seq so new accounts are not misreported as duplicates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:26:52 +03:00
Danila KhodjaefandCursor 8a68e98258 Turn influence lamps 180 degrees in gallery halls.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 18:03:07 +03:00
Danila Khodjaef 8c823a6dfe Release: weekly deploy 2026-08-03 22:50:07 +03:00
Danila KhodjaefandCursor dc0ac81081 Add admin Activity page for curator audit reports.
Admins can filter and review curator_audit_log (date/time, actor, action, resource, details) via /api/audit against the environment DB.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 22:20:09 +03:00
Danila KhodjaefandCursor f9b0fb0496 Rework Gothic hall as a stone nave interior.
Add gothic ashlar/vault/floor textures, compound piers with transverse ribs, proper lancet arches, textured door-flanking panels, and lightScale for shaft-lit naves. Docs updated in basics and data-and-images.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 17:16:54 +03:00
41 changed files with 5023 additions and 141 deletions
+16 -3
View File
@@ -80,7 +80,7 @@ Destroys the session cookie.
| `tours` | Tour admin CRUD |
| `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)
@@ -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`.
### Audit log (admin)
Admin-only reports over `curator_audit_log`. Responses include `database` (`process.env.DB_NAME`) so the UI shows whether you are reading **dev** or **prod**.
| Method | Path | Notes |
|--------|------|-------|
| `GET` | `/api/audit` | Paginated entries + resource labels. Query: `user_id`, `username`, `action`, `resource_type`, `resource_id`, `from`, `to`, `q`, `limit`, `offset` |
| `GET` | `/api/audit/summary` | Totals (all / 24h / 7d), breakdowns by user / action / resource_type. Same filters as list |
| `GET` | `/api/audit/meta` | Distinct users, actions, resource types for filter dropdowns |
Each list entry includes `created_at`, `username`, `user_role`, `action`, `resource_type`, `resource_id`, `resource_label`, `details`, `ip_address`.
### Staff-gated routes
| Route | Permission | Audit action (mutations only) |
@@ -115,10 +127,11 @@ Only **admins** can create or promote **admin** accounts. Cannot deactivate/demo
| `/api/influences/*` | `influences` | `influence.*` |
| Tour admin (`/api/tours/admin`, POST/PATCH/DELETE, stops) | `tours` | `tour.*` |
| `/api/users/*` | `users` | `user.*` |
| `/api/audit/*` | admin role | — (read) |
**Public** (no login): all catalog `GET` routes, `POST /api/artists/:id/preload-images`, `POST /api/movements/:id/preload-images` (local file linking for 3D halls), `/images`, SPA static.
Staff mutations are recorded in `curator_audit_log` with `user_id` (see [DB_structure.md](DB_structure.md)).
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 |
|-------|---------|
| `has_influence_links` | `true` when the work appears in any influence row — 3D gallery shows a golden lamp above the frame |
| `has_influence_links` | `true` when the work appears in any influence row — 3D gallery shows a golden lamp on a shared rail (40 cm above the tallest frame in the hall) |
| `checkup_checked` | Reviewed in checkup / debug workflow (gold frame in 3D when true) |
| `checkup_fixed` | Image replaced via **Fix it** |
+2
View File
@@ -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`.
Admins browse this table in the app (**Activity** / `GET /api/audit*`). Each environments 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:
```sql
+7 -5
View File
@@ -95,7 +95,7 @@ CURATOR_USERNAME=curator
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**).
@@ -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 |
| 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
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 prod:db:backup` | Prod backup (reads `infra/docker/.env.prod`) |
| `npm run dev:db:restore -- --file <path>` | Restore backup into **dev** (truncates tables first; prompts `yes`) |
| `npm run devtoprod:db:restore -- --file <path>` | Restore into **prod** (requires confirmation) |
| `npm run devtoprod:db:restore -- --file <path>` | Restore catalog into **prod** (skips `users` / `session` / `curator_audit_log`; syncs serial sequences; requires confirmation) |
| `npm run harmonize` | Bidirectional catalog DB + image merge by `updated_at` / file mtime — [harmonize-dev-prod.md](harmonize-dev-prod.md) |
| `npm run harmonize:schema` | Apply dev migrations to prod schema only (dev → prod) |
| `npm run harmonize:db` / `harmonize:images` | DB or image merge only (`harmonize:images` also merges artists/paintings checkup flags + image paths, then regenerates thumbs on both sides) |
+2 -2
View File
@@ -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)
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)
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`
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)
+27 -14
View File
@@ -6,8 +6,8 @@ Interactive virtual museum spanning art history: zoomable timeline with event gu
The app is organised as a **drill-down hierarchy**:
1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries.
2. **Movement flow** — art movements as curved SVG streams on the same year axis; documented predecessor→successor branches; portrait thumbnails placed along each stream.
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 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).
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**).
@@ -45,14 +45,19 @@ Gallery/
│ │ ├── components/ArtistBio.tsx # Biography + portrait debug panel
│ │ ├── components/DebugSearchResultsModal.tsx # “More” search picker (20 results)
│ │ ├── components/Timeline.tsx # Era bar, year ticks, event markers
│ │ ├── components/TimelineEventGuides.tsx # Event vertical guides into movement flow
│ │ ├── components/Timeline.tsx # Classic horizontal era bar
│ │ ├── components/VerticalTimeline.tsx # Bottom-up vertical era rail
│ │ ├── components/MovementBands.tsx # Movement flow (SVG streams + branches)
│ │ ├── components/VerticalMovementBands.tsx # Bottom-up movement streams
│ │ ├── components/TimelineEventGuides.tsx # Event vertical guides into movement flow
│ │ ├── components/CatalogSearchBar.tsx # Timeline header catalog search
│ │ ├── components/PaintingAnnotations.tsx # Art-history notes on painting detail
│ │ ├── pages/CheckupPage.tsx # Image audit table
│ │ ├── pages/TranslationsPage.tsx # Russian translation review
│ │ ├── pages/InfluencesPage.tsx # Influence links CRUD + import wizard
│ │ ├── pages/ToursPage.tsx # Guided tour editor
│ │ ├── pages/UsersPage.tsx # Staff accounts
│ │ ├── pages/AuditPage.tsx # Admin curator activity reports
│ │ ├── components/ToursPopup.tsx # Public published-tours modal
│ │ ├── i18n/ # react-i18next bootstrap
│ │ └── locales/{en,ru}/ # UI chrome strings
@@ -168,14 +173,19 @@ Implementation: `goToTimelineHome()` in `HomePage.tsx` — do not use the browse
## 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 |
|------|-----------|---------|
| Era bar | `Timeline.tsx` | Historical eras, major event markers, click-to-zoom |
| Movement flow | `MovementBands.tsx` | Curved streams per movement, lineage branches, artist portraits |
| Era bar / rail | `Timeline.tsx` / `VerticalTimeline.tsx` | Historical eras, major event markers, click-to-zoom |
| Movement flow | `MovementBands.tsx` / `VerticalMovementBands.tsx` | 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)
@@ -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`) |
| Wall tint | Gallery walls blend the artists **movement colour** into cream plaster tones |
| Frame finish | **Unchecked** works: black moulding; **Reviewed** (`checkup_checked`): bright gold moulding at **double width** |
| Influence lamps | A golden picture light appears **above frames** whose work has any influence-graph edge (`has_influence_links` from the API); fixture is mounted **upside down** 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` |
| 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 |
@@ -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) |
| 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 **126** → authored keys **2752**, 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 |
| 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 |
| 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** |
| 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 |
| 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 |
| **`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.
@@ -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`).
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)
@@ -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) |
| **Tour editor** | Home header → **Tour editor** (`tours`) | Create/publish guided tours and stop text — [tours.md](tours.md) |
| **Users** | Home header → **Users** (`users` / admin) | Create staff accounts, roles, permissions, reset passwords, disable accounts |
| **Activity** | Home header → **Activity** (admin only) | Curator audit reports: filters, summary, dated action log from `curator_audit_log` (env DB) |
| **Tours** | Home header → **Tours** (everyone) | Open published tours in a 3D hall — [tours.md](tours.md) |
| **Logout** | Home header (staff) | Ends session; hides staff tools |
| **Debug panel** | Painting detail or artist bio (bottom-left, when debug mode on) | Search preview + action buttons (six on painting detail, five on artist bio) |
+3 -1
View File
@@ -278,7 +278,9 @@ To add or fix a branch, edit `MOVEMENT_LINEAGE` in that file and rebuild the cli
## Movement gallery interiors (frontend)
Separate from movement lineage layout, `client/src/data/movement-interior-styles.ts` defines a **unique 3D interior** for each seeded art movement (26 styles): wall/floor/ceiling textures, trim colours, window style, and architectural details (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 movements look, edit its entry in `movement-interior-styles.ts` and rebuild the client.
+4 -3
View File
@@ -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.
**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.
- The `session` table is truncated, so any active prod curator sessions are logged out.
- **`users`**, **`session`**, and **`curator_audit_log`** are **not** truncated or loaded from the backup. Prod staff accounts, passwords, active sessions, and audit history stay as they are on `gallery_prod`.
- Catalog / content tables (`artists`, `paintings`, tours, translations, etc.) are fully replaced by the dev dump.
- After load, serial sequences are reset to `MAX(id)` so new rows (including staff users) do not collide with restored ids.
- The target is guarded: the restore refuses to run unless the database name ends with `_prod` and only reads `infra/docker/.env.prod`.
> **Optional** — to use the faster single-pass load, have the postgres superuser run this once in pgAdmin (role-global, covers dev and prod): `GRANT SET ON PARAMETER session_replication_role TO gallery;`
+1 -1
View File
@@ -117,7 +117,7 @@ npm run infra:db:split-dev-prod
CURATOR_PASSWORD=your-secure-password
```
Omit `SESSION_COOKIE_SECURE` so cookies follow the request scheme (`TRUST_PROXY` + HTTPS → Secure). Set `true`/`false` to force. `npm run dev:migrate` creates auth tables/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:
+1 -1
View File
@@ -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 prodto:dev:images` | Copy prod images → dev repo |
| `npm run prodto:dev:db` | Clone `gallery_prod``gallery_dev` |
| `npm run dev:db:backup` / `devtoprod:db:restore` | Dev backup / promote DB to prod |
| `npm run dev:db:backup` / `devtoprod:db:restore` | Dev backup / promote catalog DB to prod (prod restore skips staff/session/audit; syncs sequences) |
| `npm run harmonize` | Bidirectional catalog + image merge (last-write-wins) — [harmonize-dev-prod.md](harmonize-dev-prod.md) |
| `npm run prod:build` | Build production SPA into `client/dist` |
| `npm run dev:start` | API + static SPA on `HOST`:`PORT` (uses root `.env`) |
+79
View File
@@ -77,6 +77,77 @@ export interface StaffUser {
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> {
const res = await fetch(url, { ...fetchCredentials, ...init });
if (!res.ok) throw new Error(`API error: ${res.status}`);
@@ -407,6 +478,14 @@ export interface PaintingCheckupData {
export const api = {
listUsers: () => fetchJson<{ users: StaffUser[]; permissions: StaffPermission[] }>(`${API}/users`),
listAuditLog: (params?: AuditQuery) =>
fetchJson<AuditLogList>(`${API}/audit${auditQueryString(params)}`),
getAuditSummary: (params?: AuditQuery) =>
fetchJson<AuditSummary>(`${API}/audit/summary${auditQueryString(params)}`),
getAuditMeta: () => fetchJson<AuditMeta>(`${API}/audit/meta`),
createUser: (body: {
username: string;
password: string;
+49 -6
View File
@@ -1,5 +1,4 @@
import { useMemo } from 'react';
import * as THREE from 'three';
import type { GalleryWindowSpec, GalleryWindowStyle } from '../data/movement-interior-styles';
const WALL_HEIGHT = 4.2;
@@ -69,10 +68,56 @@ function WindowFrame({
</mesh>
{arch && style === 'gothic-lancet' && (
<mesh position={[0, height / 2 + 0.15, -depth / 2 + 0.02]}>
<coneGeometry args={[width / 2 + frameW, 0.5, 4]} />
<meshStandardMaterial color={trimColor} roughness={0.4} metalness={0.35} />
<>
{/* Two-centred arch head built from short chords */}
{Array.from({ length: 14 }, (_, i) => {
const segs = 14;
const rise = width * 0.62;
const pt = (t: number): [number, number] => {
const x = (t - 0.5) * (width + frameW * 2);
const y = Math.pow(Math.cos((t - 0.5) * Math.PI), 0.72) * rise;
return [x, y];
};
const [x0, y0] = pt(i / segs);
const [x1, y1] = pt((i + 1) / segs);
const len = Math.hypot(x1 - x0, y1 - y0);
return (
<mesh
key={i}
position={[(x0 + x1) / 2, height / 2 + (y0 + y1) / 2, -depth / 2 + 0.02]}
rotation={[0, 0, Math.atan2(y1 - y0, x1 - x0)]}
>
<boxGeometry args={[len * 1.15, frameW * 1.4, depth]} />
<meshStandardMaterial color={trimColor} roughness={0.55} metalness={0.15} />
</mesh>
);
})}
{/* Glazed spandrel filling the arch head */}
<mesh position={[0, height / 2 + width * 0.16, 0.01]}>
<planeGeometry args={[width * 0.72, width * 0.5]} />
<meshStandardMaterial
color="#9fc4ef"
emissive="#9fc4ef"
emissiveIntensity={0.6}
toneMapped={false}
transparent
opacity={0.8}
/>
</mesh>
{/* Mullions and transoms */}
{[-width / 4, width / 4].map((ox) => (
<mesh key={ox} position={[ox, 0, -depth / 2 + 0.03]}>
<boxGeometry args={[0.045, height, depth]} />
<meshStandardMaterial color={trimColor} roughness={0.55} metalness={0.15} />
</mesh>
))}
{[height / 3, -height / 6].map((oy) => (
<mesh key={oy} position={[0, oy, -depth / 2 + 0.03]}>
<boxGeometry args={[width, 0.035, depth]} />
<meshStandardMaterial color={trimColor} roughness={0.55} metalness={0.15} />
</mesh>
))}
</>
)}
{style === 'roman-arch' && (
@@ -146,8 +191,6 @@ function SingleWindow({
spec: GalleryWindowSpec;
trimColor: string;
}) {
const glassColor = useMemo(() => new THREE.Color(spec.lightColor), [spec.lightColor]);
return (
<group>
<WindowFrame style={spec.style} width={spec.width} height={spec.height} trimColor={trimColor} />
+3 -4
View File
@@ -32,6 +32,8 @@ interface MovementLayout {
/** Band thickness in CSS pixels (proportional to influence_link_count). */
strokePx: 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. */
labelFitsInBand: boolean;
}
@@ -1953,14 +1955,13 @@ export default function MovementBands({
};
}, [visibleMovements.length]);
const { layouts, layoutHeight, branches, childIdsByParent, streamStrokePx, portraitSizePx, streamCurveOffset } =
const { layouts, layoutHeight, branches, streamStrokePx, portraitSizePx, streamCurveOffset } =
useMemo(() => {
if (visibleMovements.length === 0) {
return {
layouts: [] as MovementLayout[],
layoutHeight: DEFAULT_CANVAS_HEIGHT,
branches: [] as BranchSegment[],
childIdsByParent: new Map<number, number[]>(),
streamStrokePx: MAX_STREAM_STROKE_PX,
portraitSizePx: 78,
streamCurveOffset: 14,
@@ -2148,7 +2149,6 @@ export default function MovementBands({
layouts: [...layoutById.values()].sort((a, b) => a.depth - b.depth),
layoutHeight,
branches: branchList,
childIdsByParent,
streamStrokePx,
portraitSizePx,
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 drawBranches = flowVisual?.branches ?? branches;
const drawCurveOffset = flowVisual?.streamCurveOffset ?? streamCurveOffset;
+80 -27
View File
@@ -100,41 +100,94 @@ function BaroqueDetails({ width, depth, halfW, halfD, trim }: Props & { trim: st
);
}
function MedievalDetails({ halfW, halfD }: Pick<Props, 'halfW' | 'halfD'>) {
const torchPositions = useMemo(
() =>
[
[-halfW + 0.2, -halfD * 0.5],
[halfW - 0.2, -halfD * 0.5],
[-halfW + 0.2, halfD * 0.3],
[halfW - 0.2, halfD * 0.3],
] as [number, number][],
[halfW, halfD]
);
function GothicDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
// Compound piers along the side walls, spaced by bay, each rising into the
// vault springer. Shafts stay flush against the wall (0.16 m proud) so they
// never intrude on the ~0.7 m frame hang margin.
const bays = useMemo(() => {
const spacing = 4.2;
const count = Math.max(2, Math.min(9, Math.floor(depth / spacing)));
const step = depth / (count + 1);
return Array.from({ length: count }, (_, i) => -halfD + step * (i + 1));
}, [depth, halfD]);
const shaftHeight = 3.5;
return (
<group>
{torchPositions.map(([x, z], i) => (
<group key={i} position={[x, 2.2, z]}>
<mesh>
<boxGeometry args={[0.08, 0.35, 0.12]} />
<meshStandardMaterial color="#3a3028" roughness={0.9} />
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<group key={side} position={[side * (halfW - 0.09), 0, z]}>
{/* Compound pier: a heavier centre shaft flanked by colonnettes */}
<mesh position={[0, shaftHeight / 2, 0]}>
<cylinderGeometry args={[0.1, 0.115, shaftHeight, 12]} />
<meshStandardMaterial color="#a89e88" roughness={0.78} metalness={0.04} />
</mesh>
<pointLight position={[0, 0.15, 0.08]} intensity={0.65} distance={5} color="#ff9830" />
<mesh position={[0, 0.2, 0.06]}>
<sphereGeometry args={[0.06, 8, 8]} />
<meshStandardMaterial color="#ffb040" emissive="#ff8010" emissiveIntensity={0.8} toneMapped={false} />
{[-0.17, 0.17].map((dz) => (
<mesh key={dz} position={[0, shaftHeight / 2, dz]}>
<cylinderGeometry args={[0.052, 0.06, shaftHeight, 10]} />
<meshStandardMaterial color="#9e9482" roughness={0.82} metalness={0.03} />
</mesh>
))}
{/* Moulded base and foliate capital */}
<mesh position={[0, 0.1, 0]}>
<boxGeometry args={[0.28, 0.2, 0.52]} />
<meshStandardMaterial color="#978d79" roughness={0.85} />
</mesh>
<mesh position={[0, shaftHeight + 0.12, 0]}>
<boxGeometry args={[0.3, 0.24, 0.56]} />
<meshStandardMaterial color="#b3a892" roughness={0.7} metalness={0.05} />
</mesh>
{/* Vault springer resting on the capital */}
<mesh position={[0, shaftHeight + 0.36, 0]} rotation={[0, 0, Math.PI / 4]}>
<boxGeometry args={[0.17, 0.17, 0.46]} />
<meshStandardMaterial color={trim} roughness={0.72} metalness={0.06} />
</mesh>
</group>
))}
{/* Rough stone courses */}
{[-halfD + 0.08, halfD - 0.08].map((z, i) => (
<mesh key={i} position={[0, 1.5, z]}>
<boxGeometry args={[0.04, 3, 0.04]} />
<meshStandardMaterial color="#6a6458" roughness={0.98} />
{/* Transverse rib arching across the nave between opposite piers */}
<group position={[0, shaftHeight + 0.42, z]}>
{Array.from({ length: 9 }, (_, s) => {
const segs = 9;
const t0 = s / segs;
const t1 = (s + 1) / segs;
const xAt = (t: number) => -halfW + 0.09 + t * (halfW - 0.09) * 2;
const yAt = (t: number) => Math.sin(Math.PI * t) * 0.34;
const x0 = xAt(t0);
const x1 = xAt(t1);
const y0 = yAt(t0);
const y1 = yAt(t1);
const len = Math.hypot(x1 - x0, y1 - y0);
return (
<mesh
key={s}
position={[(x0 + x1) / 2, (y0 + y1) / 2, 0]}
rotation={[0, 0, Math.atan2(y1 - y0, x1 - x0)]}
>
<boxGeometry args={[len * 1.06, 0.13, 0.16]} />
<meshStandardMaterial color="#b0a58f" roughness={0.74} metalness={0.05} />
</mesh>
);
})}
</group>
</group>
))}
{/* 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>
))}
{/* 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>
);
}
@@ -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} />;
case 'baroque':
return <BaroqueDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
case 'medieval':
return <MedievalDetails halfW={halfW} halfD={halfD} />;
case 'gothic':
return <GothicDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
case 'byzantine':
return <ByzantineDetails halfW={halfW} halfD={halfD} trim={trim} />;
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>
);
}
+228
View File
@@ -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;
}
}
+415
View File
@@ -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>
);
}
+76 -15
View File
@@ -14,7 +14,7 @@ import type {
} from '../types';
import { galleryImageUrlCandidates, imageUrl, api } from '../api/client';
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 { useTexturedMaterial } from '../hooks/useTexturedMaterial';
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. */
const INFLUENCE_LAMP_ABOVE_FRAME = 0.46;
/** Clearance above the tallest frame in the hall for influence lamps (metres). */
const INFLUENCE_LAMP_ABOVE_HIGHEST_M = 0.4;
type WallSide = 'back' | 'left' | 'right';
@@ -304,6 +304,20 @@ function frameOuterH(canvasH: number, reviewed: boolean): number {
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) {
const count = paintings.length;
if (count === 0) return { slots: [], spanNeeded: span };
@@ -803,22 +817,24 @@ function usePaintingTexture(urls: string[] | string | null) {
}
function InfluencePictureLamp({
frameHeight,
matBorder,
frameWorldY,
lampWorldY,
frameDepth,
highlighted,
}: {
frameHeight: number;
matBorder: number;
/** Painting group world Y — local lamp offset = lampWorldY frameWorldY. */
frameWorldY: number;
/** Shared hall rail: 40 cm above the tallest frame. */
lampWorldY: number;
frameDepth: number;
highlighted: boolean;
}) {
const mountY = frameHeight / 2 + matBorder + INFLUENCE_LAMP_ABOVE_FRAME;
const mountY = lampWorldY - frameWorldY;
const glow = highlighted ? 1.6 : 1.15;
const scale = 1.35;
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}>
<boxGeometry args={[0.11, 0.05, 0.05]} />
<meshStandardMaterial color="#2f2f2f" metalness={0.9} roughness={0.2} />
@@ -918,6 +934,7 @@ function PaintingFrame({
wallSide,
imageRevision,
caption,
influenceLampWorldY,
onClick,
}: {
painting: Painting;
@@ -928,6 +945,7 @@ function PaintingFrame({
wallSide: WallSide;
imageRevision?: number;
caption?: string;
influenceLampWorldY: number;
onClick: () => void;
}) {
const [hovered, setHovered] = useState(false);
@@ -1018,8 +1036,8 @@ function PaintingFrame({
{hasInfluenceLinks && (
<InfluencePictureLamp
frameHeight={height}
matBorder={matBorder}
frameWorldY={position[1]}
lampWorldY={influenceLampWorldY}
frameDepth={frameDepth}
highlighted={hovered}
/>
@@ -1059,13 +1077,31 @@ function GalleryWall({
size,
rotation = [0, 0, 0],
color = '#ebe4d8',
kind,
tint,
}: {
position: [number, number, number];
size: [number, number];
rotation?: [number, number, number];
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;
// 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 (
<mesh position={position} rotation={rotation} renderOrder={0}>
<boxGeometry args={[w, h, WALL_THICKNESS]} />
@@ -1487,6 +1523,10 @@ function ArtistHall({
const hallLightScale = interiorStyle?.lightScale ?? 1;
const wallRoughness = interiorStyle ? 0.75 : 0.92;
const wallMetalness = interiorStyle ? 0.08 : 0.06;
const influenceLampWorldY = useMemo(
() => highestPaintingTopY(segments) + INFLUENCE_LAMP_ABOVE_HIGHEST_M,
[segments]
);
const wallMaterial = (color: string) => (
<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]}
size={[(width - DOOR_WIDTH) / 2, 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}
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}
/>
<HallPassage
position={[0, 0, halfD - WALL_THICKNESS / 2 - 0.02]}
@@ -1584,12 +1630,20 @@ function ArtistHall({
/>
</>
) : 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={[0, DOOR_HEIGHT + (WALL_HEIGHT - DOOR_HEIGHT) / 2, halfD]} size={[DOOR_WIDTH, WALL_HEIGHT - DOOR_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}
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
position={[0, 0, halfD - WALL_THICKNESS / 2 - 0.02]}
active={nearExit}
@@ -1608,16 +1662,22 @@ function ArtistHall({
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={[(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}
/>
<group position={[0, 0, -halfD + WALL_THICKNESS / 2 + 0.02]} rotation={[0, Math.PI, 0]}>
<ExitPortal
@@ -1714,6 +1774,7 @@ function ArtistHall({
wallSide={seg.slots[i].side}
imageRevision={imageRevisions?.[painting.id]}
caption={showCaptions ? paintingWallCaption(painting) : undefined}
influenceLampWorldY={influenceLampWorldY}
onClick={() => onPaintingClick(painting.id)}
/>
))}
+22 -18
View File
@@ -54,7 +54,7 @@ export interface MovementInteriorStyle {
trackLights: number;
/** Scales the shared hall/scene lights. <1 for deliberately dim period interiors. */
lightScale: number;
details: 'palazzo' | 'baroque' | 'medieval' | 'byzantine' | 'neoclassical' | 'salon' | 'modern' | 'classical' | 'museum' | 'industrial' | 'atelier';
details: 'palazzo' | 'baroque' | 'gothic' | 'byzantine' | 'neoclassical' | 'salon' | 'modern' | 'classical' | 'museum' | 'industrial' | 'atelier';
}
function windows(...specs: GalleryWindowSpec[]): GalleryWindowSpec[] {
@@ -144,26 +144,30 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
),
29: mk(
'gothic-cathedral',
'Gothic hall',
'Stone vault · tall lancet windows · flagstone floor',
{ wall: 'rough-stone', ceiling: 'basalt', floor: 'flagstone' },
{ wall: '#8a8478', ceiling: '#3a3630', floor: '#6a6458', trim: '#5c5648' },
'Gothic nave',
'Blind-arcaded ashlar · ribbed stone vault · worn flagstones',
{ wall: 'gothic-ashlar', ceiling: 'gothic-vault', floor: 'gothic-stone-floor' },
{ wall: '#8d8471', ceiling: '#6d6555', floor: '#6f6759', trim: '#7d7159' },
{
details: 'medieval',
titleColor: '#e8dcc8',
ambient: 0.72,
warmLight: '#e8d8c0',
sunLight: '#d0e8ff',
// Keep atmosphere dark, but not pure void (walls must stay readable).
fog: '#1a1816',
background: '#141210',
details: 'gothic',
titleColor: '#efe4cc',
ambient: 0.6,
warmLight: '#ffe6bc',
sunLight: '#dbe8ff',
// Cool shadowed stone, but never a pure void walls must stay readable.
fog: '#1b1a18',
background: '#131211',
windows: windows(
{ wall: 'back', x: -2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.5 },
{ wall: 'back', x: 2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.5 },
{ wall: 'left', x: 0, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 2.8 },
{ wall: 'right', x: 0, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 2.8 }
{ wall: 'back', x: -2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.8 },
{ wall: 'back', x: 2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.8 },
{ wall: 'left', x: -4.5, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.2 },
{ wall: 'left', x: 4.5, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.2 },
{ wall: 'right', x: -4.5, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.2 },
{ wall: 'right', x: 4.5, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.2 }
),
trackLights: 0.85,
trackLights: 0.5,
// Naves are lit by window shafts against shadowed stone, not flooded.
lightScale: 0.26,
}
),
30: mk(
+5 -1
View File
@@ -14,6 +14,7 @@ import enTranslations from '../locales/en/translations.json';
import enInfluences from '../locales/en/influences.json';
import enTours from '../locales/en/tours.json';
import enUsers from '../locales/en/users.json';
import enAudit from '../locales/en/audit.json';
import ruCommon from '../locales/ru/common.json';
import ruHome from '../locales/ru/home.json';
@@ -27,6 +28,7 @@ import ruTranslations from '../locales/ru/translations.json';
import ruInfluences from '../locales/ru/influences.json';
import ruTours from '../locales/ru/tours.json';
import ruUsers from '../locales/ru/users.json';
import ruAudit from '../locales/ru/audit.json';
const initialLocale = readStoredLocale();
writeStoredLocale(initialLocale);
@@ -35,7 +37,7 @@ void i18n.use(initReactI18next).init({
lng: initialLocale,
fallbackLng: 'en',
supportedLngs: ['en', 'ru'],
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences', 'tours', 'users'],
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences', 'tours', 'users', 'audit'],
defaultNS: 'common',
resources: {
en: {
@@ -51,6 +53,7 @@ void i18n.use(initReactI18next).init({
influences: enInfluences,
tours: enTours,
users: enUsers,
audit: enAudit,
},
ru: {
common: ruCommon,
@@ -65,6 +68,7 @@ void i18n.use(initReactI18next).init({
influences: ruInfluences,
tours: ruTours,
users: ruUsers,
audit: ruAudit,
},
},
interpolation: { escapeValue: false },
+40
View File
@@ -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 environments 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"
}
+3
View File
@@ -18,6 +18,9 @@
"tours": "Tours",
"toursEditor": "Tour editor",
"users": "Users",
"audit": "Activity",
"layoutHorizontal": "Classic timeline →",
"layoutVertical": "↑ Vertical timeline",
"openingTourGallery": "Opening guided tour…",
"tourEmpty": "This tour has no paintings yet.",
"tourLoadFailed": "Failed to load the tour.",
+40
View File
@@ -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": "Далее"
}
+3
View File
@@ -18,6 +18,9 @@
"tours": "Экскурсии",
"toursEditor": "Редактор экскурсий",
"users": "Пользователи",
"audit": "Активность",
"layoutHorizontal": "Классическая шкала →",
"layoutVertical": "↑ Вертикальная шкала",
"openingTourGallery": "Открытие экскурсии…",
"tourEmpty": "В этой экскурсии пока нет картин.",
"tourLoadFailed": "Не удалось загрузить экскурсию.",
+310
View File
@@ -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;
}
}
+407
View File
@@ -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>
);
}
+39
View File
@@ -24,6 +24,45 @@
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 {
position: fixed;
inset: 0;
+114 -6
View File
@@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next';
import Timeline from '../components/Timeline';
import TimelineEventGuides from '../components/TimelineEventGuides';
import MovementBands from '../components/MovementBands';
import VerticalTimeline from '../components/VerticalTimeline';
import VerticalMovementBands from '../components/VerticalMovementBands';
import VirtualGallery from '../components/VirtualGallery';
import PaintingDetailView from '../components/PaintingDetail';
import ArtistBio from '../components/ArtistBio';
@@ -11,6 +13,7 @@ import TranslationsPage from '../pages/TranslationsPage';
import InfluencesPage from '../pages/InfluencesPage';
import ToursPage from '../pages/ToursPage';
import UsersPage from '../pages/UsersPage';
import AuditPage from '../pages/AuditPage';
import CuratorLoginModal from '../components/CuratorLoginModal';
import ArtistFilterModal from '../components/ArtistFilterModal';
import ToursPopup from '../components/ToursPopup';
@@ -26,6 +29,7 @@ import '../pages/TranslationsPage.css';
import '../pages/InfluencesPage.css';
import '../pages/ToursPage.css';
import '../pages/UsersPage.css';
import '../pages/AuditPage.css';
import { useAuth } from '../context/AuthContext';
import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
import type {
@@ -45,11 +49,13 @@ import './HomePage.css';
type View =
| { type: 'timeline' }
| { type: 'timeline-vertical' }
| { type: 'checkup' }
| { type: 'translations' }
| { type: 'influences' }
| { type: 'tours' }
| { type: 'users' }
| { type: 'audit' }
| { type: 'gallery'; artistId: number; data: ArtistDetail }
| { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail }
| { type: 'tour-gallery'; tourId: number; data: TourGalleryDetail }
@@ -61,7 +67,7 @@ type GallerySession =
| { kind: 'movement'; movementId: number; data: MovementGalleryDetail }
| { 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(
detail: MovementGalleryDetail,
@@ -153,7 +159,7 @@ function catalogNavigateTarget(
export default function HomePage() {
const { t } = useTranslation('home');
const { isCurator, username, login, logout, can } = useAuth();
const { isCurator, isAdmin, username, login, logout, can } = useAuth();
const canImages = can('images');
const canCheckup = can('checkup');
const canNotes = can('curator_notes');
@@ -204,7 +210,7 @@ export default function HomePage() {
setGallerySession({ kind: 'movement', movementId: view.movementId, data: view.data });
} else if (view.type === 'tour-gallery') {
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);
}
}, [view]);
@@ -268,6 +274,17 @@ export default function HomePage() {
setView({ type: 'timeline' });
}, [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 = () => {
setDebugMode((prev) => {
const next = !prev;
@@ -299,6 +316,8 @@ export default function HomePage() {
setView({ type: 'tours' });
} else if (loginRedirect === 'users') {
setView({ type: 'users' });
} else if (loginRedirect === 'audit') {
setView({ type: 'audit' });
}
setLoginRedirect(null);
};
@@ -312,7 +331,8 @@ export default function HomePage() {
view.type === 'translations' ||
view.type === 'influences' ||
view.type === 'tours' ||
view.type === 'users'
view.type === 'users' ||
view.type === 'audit'
) {
goToTimelineHome();
}
@@ -358,6 +378,14 @@ export default function HomePage() {
setView({ type: 'users' });
};
const openAudit = () => {
if (!isAdmin) {
openCuratorLogin('audit');
return;
}
setView({ type: 'audit' });
};
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
const data = await api.getPainting(paintingId);
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' && (
canTranslations ? (
<TranslationsPage onBack={goToTimelineHome} />
@@ -1171,9 +1218,30 @@ export default function HomePage() {
/>
)}
{view.type === 'timeline' && (
{isTimelineHome && (
<div className="home-page">
<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">
{isCurator ? (
<>
@@ -1253,6 +1321,16 @@ export default function HomePage() {
{t('users')}
</button>
)}
{isAdmin && (
<button
type="button"
className="checkup-link-btn"
onClick={openAudit}
title="Curator activity audit reports"
>
{t('audit')}
</button>
)}
<button
type="button"
className="curator-logout-btn"
@@ -1291,7 +1369,7 @@ export default function HomePage() {
/>
</header>
<div className="home-timeline-stack">
<div className={`home-timeline-stack${isVerticalTimeline ? ' home-timeline-stack-vertical' : ''}`}>
{loading && (
<GalleryLoadingMarker overlay message="Loading art history…" />
)}
@@ -1302,6 +1380,34 @@ export default function HomePage() {
<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
eras={timelineData.eras}
viewStart={viewStart}
@@ -1335,6 +1441,8 @@ export default function HomePage() {
</div>
</>
)}
</>
)}
</div>
</div>
)}
@@ -14,6 +14,9 @@ export type SurfaceTextureKind =
| 'limestone'
| 'sandstone'
| 'rough-stone'
| 'gothic-ashlar'
| 'gothic-vault'
| 'gothic-stone-floor'
| 'basalt'
| 'stucco-cream'
| 'stucco-terracotta'
@@ -247,6 +250,52 @@ function marbleVeined(ctx: CanvasRenderingContext2D, size: number, base: string,
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. */
function marbleSlab(
ctx: CanvasRenderingContext2D,
@@ -572,6 +621,235 @@ function paintSurface(kind: SurfaceTextureKind, size: number): ImageData {
fill(ctx, size, '#3a3834');
noiseOverlay(ctx, size, 0.15, 107);
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':
stuccoSurface(ctx, size, '#f0e8d8', 108);
break;
@@ -842,6 +1120,11 @@ function normalStrengthFor(kind: SurfaceTextureKind): number {
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 === '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;
}
@@ -853,6 +1136,9 @@ const ROUGHNESS: Partial<Record<SurfaceTextureKind, number>> = {
'marble-opus-sectile': 0.2,
'marble-revetment': 0.34,
'coffered-wood': 0.72,
'gothic-ashlar': 0.84,
'gothic-vault': 0.9,
'gothic-stone-floor': 0.56,
'gilded-stucco': 0.22,
'velvet-crimson': 0.92,
'velvet-navy': 0.92,
@@ -894,6 +1180,10 @@ const METERS: Partial<Record<SurfaceTextureKind, number>> = {
'marble-opus-sectile': 5.0,
'marble-revetment': 4.4,
'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,
'mosaic-byzantine': 1.8,
'mosaic-roman': 2.0,
@@ -919,6 +1209,9 @@ const NORMAL_SCALE: Partial<Record<SurfaceTextureKind, number>> = {
'marble-opus-sectile': 0.55,
'marble-revetment': 0.6,
'coffered-wood': 1.0,
'gothic-ashlar': 1.0,
'gothic-vault': 0.85,
'gothic-stone-floor': 0.45,
'velvet-crimson': 0.5,
'velvet-navy': 0.5,
'velvet-emerald': 0.5,
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+1456
View File
File diff suppressed because it is too large Load Diff
+47 -1
View File
@@ -19,7 +19,47 @@ const { printCliResult } = require('./lib/cli-result');
const { Client } = pg;
// 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) {
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();
console.log(`Restore complete: ${restored} statements into "${dbName}".`);
if (skippedInserts > 0) {
+2
View File
@@ -12,6 +12,7 @@ const { requirePermission } = require('./middleware/auth');
const { logCuratorAction } = require('./audit-log');
const authRoutes = require('./routes/auth');
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 { getVersionInfo } = require('./version-info');
const { searchCatalog } = require('./search-service');
@@ -46,6 +47,7 @@ app.use(express.json({ limit: '20mb' }));
app.use(createSessionMiddleware());
app.use('/api/auth', authRoutes);
app.use('/api/users', usersRoutes);
app.use('/api/audit', auditRoutes);
app.use('/api/translations', translationRoutes);
app.use('/api/influences', influenceRoutes);
app.use('/api/tours', tourRoutes);
+28
View File
@@ -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) {
return {
role: user.role,
@@ -90,6 +117,7 @@ function staffAuthPayload(user) {
module.exports = {
requireCurator,
requirePermission,
requireAdmin,
loadStaffUser,
staffAuthPayload,
};
+266
View File
@@ -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;
+27
View File
@@ -38,6 +38,17 @@ async function clearUserSessions(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.get('/', async (_req, res) => {
@@ -76,6 +87,16 @@ router.post('/', async (req, res) => {
const passwordHash = await bcrypt.hash(password, 10);
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(
`INSERT INTO users (username, password_hash, role, permissions, is_active)
VALUES ($1, $2, $3, $4::text[], true)
@@ -95,6 +116,12 @@ router.post('/', async (req, res) => {
res.status(201).json({ user: mapUser(rows[0]) });
} catch (err) {
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' });
}
console.error('Users create error:', err.message);