Author SHA1 Message Date
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
36 changed files with 3328 additions and 100 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)
+11 -6
View File
@@ -53,6 +53,8 @@ Gallery/
│ │ ├── 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
@@ -299,7 +301,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,11 +338,13 @@ 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 |
@@ -452,7 +456,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 +464,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 +481,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':
+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"
}
+1
View File
@@ -18,6 +18,7 @@
"tours": "Tours",
"toursEditor": "Tour editor",
"users": "Users",
"audit": "Activity",
"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": "Далее"
}
+1
View File
@@ -18,6 +18,7 @@
"tours": "Экскурсии",
"toursEditor": "Редактор экскурсий",
"users": "Пользователи",
"audit": "Активность",
"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>
);
}
+46 -3
View File
@@ -11,6 +11,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 +27,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 {
@@ -50,6 +52,7 @@ type View =
| { 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 +64,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 +156,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');
@@ -299,6 +302,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 +317,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 +364,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 +1118,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} />
@@ -1253,6 +1286,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"
@@ -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);