Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4088d7d57b | ||
|
|
f20f811f21 | ||
|
|
cfee69c9a6 | ||
|
|
8a68e98258 | ||
|
|
8c823a6dfe | ||
|
|
dc0ac81081 | ||
|
|
f9b0fb0496 |
@@ -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** |
|
||||
|
||||
|
||||
@@ -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 environment’s API uses its own DB (`gallery_dev` vs `gallery_prod`); **`users`**, **`session`**, and audit history are not synced by harmonize/`devtoprod:db:restore`.
|
||||
|
||||
Example query in pgAdmin:
|
||||
|
||||
```sql
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 artist’s **movement colour** into cream plaster tones |
|
||||
| Frame finish | **Unchecked** works: black moulding; **Reviewed** (`checkup_checked`): bright gold moulding at **double width** |
|
||||
| Influence lamps | A golden picture light appears **above frames** whose work has any influence-graph edge (`has_influence_links` from the API); 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 **1–26** → authored keys **27–52**, with Northern/High Renaissance swapped) so Byzantine gets the basilica, not Gothic stone |
|
||||
| Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco, **marble revetment** (framed book-matched panels), **Cosmatesque paving** (`marble-opus-sectile`), **coffered timber** ceilings — plus **single-colour painted walls** (`painted-lime`, `painted-oil-matte`, `painted-oil-satin`, `painted-emulsion`, `painted-flat`) tinted per movement for Renaissance salons through modern white cubes |
|
||||
| Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco, **marble revetment** (framed book-matched panels), **Cosmatesque paving** (`marble-opus-sectile`), **coffered timber** ceilings, **blind-arcaded ashlar** (`gothic-ashlar`) and **ribbed vaulting** (`gothic-vault`) — plus **single-colour painted walls** (`painted-lime`, `painted-oil-matte`, `painted-oil-satin`, `painted-emulsion`, `painted-flat`) tinted per movement for Renaissance salons through modern white cubes |
|
||||
| Architecture in texture | Period halls that need arcades, vaults, or panelling draw that **relief into the texture** rather than adding geometry: `gothic-ashlar` paints a full storey (plinth → blind arcade → string course → triforium → cornice) into one tile whose `metersPerRepeat` equals `WALL_HEIGHT`, so it maps **once vertically** onto the wall; `gothic-vault` paints a quadripartite bay with tiercerons and a boss. Light/dark banding plus a high `normalStrengthFor()` makes it read as carved stone, keeping the mesh and light budget flat |
|
||||
| Door-flanking panels | Wall segments beside exit doors and passages take the hall's own wall texture (`GalleryWall` accepts `kind`/`tint`) instead of rendering as flat single-colour blocks next to textured walls |
|
||||
| Textures | Wall/floor/ceiling maps applied via `useTexturedMaterial`; movement **tints** drive painted-wall hue |
|
||||
| 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) |
|
||||
|
||||
@@ -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 movement’s look, edit its entry in `movement-interior-styles.ts` and rebuild the client.
|
||||
|
||||
|
||||
@@ -244,10 +244,11 @@ The restore loads rows in two ways automatically:
|
||||
|
||||
Multi-line values (e.g. artist bios with embedded newlines) are parsed as whole statements, so long text restores correctly.
|
||||
|
||||
**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;`
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"title": "Curator activity",
|
||||
"back": "← Back",
|
||||
"loadFailed": "Failed to load audit log",
|
||||
"loading": "Loading…",
|
||||
"empty": "No curator actions match these filters.",
|
||||
"databaseUnknown": "Reading the audit log for this environment’s database.",
|
||||
"databaseDev": "Dev database: {{name}}",
|
||||
"databaseProd": "Production database: {{name}}",
|
||||
"databaseNamed": "Database: {{name}}",
|
||||
"statTotal": "Total (filtered)",
|
||||
"stat24h": "Last 24 hours",
|
||||
"stat7d": "Last 7 days",
|
||||
"statNewest": "Most recent",
|
||||
"filterCurator": "Curator",
|
||||
"filterAction": "Action",
|
||||
"filterResource": "Resource type",
|
||||
"filterResourceId": "Resource id",
|
||||
"filterFrom": "From",
|
||||
"filterTo": "To",
|
||||
"filterSearch": "Search",
|
||||
"filterSearchPlaceholder": "Action, user, IP, details…",
|
||||
"allCurators": "All curators",
|
||||
"allActions": "All actions",
|
||||
"allResources": "All resources",
|
||||
"apply": "Apply filters",
|
||||
"clear": "Clear",
|
||||
"byCurator": "By curator",
|
||||
"byAction": "By action",
|
||||
"showing": "Showing {{count}} of {{total}}",
|
||||
"page": "Page {{page}} / {{pages}}",
|
||||
"colWhen": "Date & time",
|
||||
"colCurator": "Curator",
|
||||
"colAction": "Action",
|
||||
"colResource": "Resource",
|
||||
"colDetails": "Details",
|
||||
"colIp": "IP",
|
||||
"prev": "Previous",
|
||||
"next": "Next"
|
||||
}
|
||||
@@ -18,6 +18,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.",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"title": "Действия кураторов",
|
||||
"back": "← Назад",
|
||||
"loadFailed": "Не удалось загрузить журнал действий",
|
||||
"loading": "Загрузка…",
|
||||
"empty": "Нет действий кураторов по этим фильтрам.",
|
||||
"databaseUnknown": "Журнал читается из базы данных текущего окружения.",
|
||||
"databaseDev": "База разработки: {{name}}",
|
||||
"databaseProd": "Продакшен-база: {{name}}",
|
||||
"databaseNamed": "База данных: {{name}}",
|
||||
"statTotal": "Всего (фильтр)",
|
||||
"stat24h": "За 24 часа",
|
||||
"stat7d": "За 7 дней",
|
||||
"statNewest": "Последнее",
|
||||
"filterCurator": "Куратор",
|
||||
"filterAction": "Действие",
|
||||
"filterResource": "Тип ресурса",
|
||||
"filterResourceId": "ID ресурса",
|
||||
"filterFrom": "С",
|
||||
"filterTo": "По",
|
||||
"filterSearch": "Поиск",
|
||||
"filterSearchPlaceholder": "Действие, пользователь, IP, детали…",
|
||||
"allCurators": "Все кураторы",
|
||||
"allActions": "Все действия",
|
||||
"allResources": "Все ресурсы",
|
||||
"apply": "Применить",
|
||||
"clear": "Сбросить",
|
||||
"byCurator": "По кураторам",
|
||||
"byAction": "По действиям",
|
||||
"showing": "Показано {{count}} из {{total}}",
|
||||
"page": "Стр. {{page}} / {{pages}}",
|
||||
"colWhen": "Дата и время",
|
||||
"colCurator": "Куратор",
|
||||
"colAction": "Действие",
|
||||
"colResource": "Ресурс",
|
||||
"colDetails": "Детали",
|
||||
"colIp": "IP",
|
||||
"prev": "Назад",
|
||||
"next": "Далее"
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
"tours": "Экскурсии",
|
||||
"toursEditor": "Редактор экскурсий",
|
||||
"users": "Пользователи",
|
||||
"audit": "Активность",
|
||||
"openingTourGallery": "Открытие экскурсии…",
|
||||
"tourEmpty": "В этой экскурсии пока нет картин.",
|
||||
"tourLoadFailed": "Не удалось загрузить экскурсию.",
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
.audit-page {
|
||||
padding: 1rem 1.5rem 2rem;
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
color: #f5f0e8;
|
||||
}
|
||||
|
||||
.audit-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.audit-header-text h1 {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.audit-env {
|
||||
margin: 0.25rem 0 0;
|
||||
color: rgba(245, 240, 232, 0.72);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.audit-back {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: inherit;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.audit-error {
|
||||
color: #f5a5a5;
|
||||
}
|
||||
|
||||
.audit-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.audit-stat {
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 0.9rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.audit-stat-label {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: rgba(245, 240, 232, 0.65);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.audit-stat-value {
|
||||
font-size: 1.45rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.audit-stat-value-sm {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.audit-filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.65rem 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 8px;
|
||||
padding: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.audit-filters label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.audit-filters input,
|
||||
.audit-filters select,
|
||||
.audit-filter-actions button,
|
||||
.audit-pager button,
|
||||
.audit-chip {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.audit-filters input,
|
||||
.audit-filters select {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.55rem;
|
||||
}
|
||||
|
||||
.audit-filter-search {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.audit-filter-actions {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.audit-filter-actions button,
|
||||
.audit-pager button {
|
||||
background: rgba(201, 169, 110, 0.2);
|
||||
border: 1px solid rgba(201, 169, 110, 0.45);
|
||||
border-radius: 6px;
|
||||
padding: 0.45rem 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.audit-filter-actions button:disabled,
|
||||
.audit-pager button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.audit-secondary {
|
||||
background: transparent !important;
|
||||
border-color: rgba(255, 255, 255, 0.28) !important;
|
||||
}
|
||||
|
||||
.audit-breakdowns {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.audit-breakdown {
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 0.9rem;
|
||||
}
|
||||
|
||||
.audit-breakdown h2 {
|
||||
margin: 0 0 0.55rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.audit-breakdown ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.audit-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 999px;
|
||||
padding: 0.28rem 0.65rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.audit-chip span {
|
||||
color: rgba(201, 169, 110, 0.95);
|
||||
}
|
||||
|
||||
.audit-table-wrap {
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.audit-table-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.55rem;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(245, 240, 232, 0.7);
|
||||
}
|
||||
|
||||
.audit-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.audit-table th,
|
||||
.audit-table td {
|
||||
padding: 0.5rem 0.55rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.audit-table th {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: rgba(245, 240, 232, 0.65);
|
||||
}
|
||||
|
||||
.audit-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.audit-table tbody tr:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.audit-row-open {
|
||||
background: rgba(201, 169, 110, 0.08);
|
||||
}
|
||||
|
||||
.audit-when {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.audit-user {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.audit-role {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(245, 240, 232, 0.55);
|
||||
}
|
||||
|
||||
.audit-resource {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.audit-resource-label {
|
||||
color: rgba(201, 169, 110, 0.95);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.audit-details-cell {
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: rgba(245, 240, 232, 0.7);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.audit-details-row td {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.audit-details-row pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(245, 240, 232, 0.88);
|
||||
}
|
||||
|
||||
.audit-empty {
|
||||
color: rgba(245, 240, 232, 0.65);
|
||||
}
|
||||
|
||||
.audit-pager {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.audit-stats,
|
||||
.audit-filters,
|
||||
.audit-breakdowns {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.audit-filter-search {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.audit-stats,
|
||||
.audit-filters,
|
||||
.audit-breakdowns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.audit-filter-search {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import { useCallback, useEffect, useMemo, useState, Fragment, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
api,
|
||||
type AuditLogEntry,
|
||||
type AuditMeta,
|
||||
type AuditQuery,
|
||||
type AuditSummary,
|
||||
} from '../api/client';
|
||||
import './AuditPage.css';
|
||||
|
||||
interface Props {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
function formatWhen(iso: string) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function detailsPreview(details: Record<string, unknown> | null): string {
|
||||
if (!details || Object.keys(details).length === 0) return '—';
|
||||
try {
|
||||
const raw = JSON.stringify(details);
|
||||
return raw.length > 120 ? `${raw.slice(0, 117)}…` : raw;
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
function emptyFilters() {
|
||||
return {
|
||||
user_id: '' as string,
|
||||
action: '',
|
||||
resource_type: '',
|
||||
resource_id: '',
|
||||
from: '',
|
||||
to: '',
|
||||
q: '',
|
||||
};
|
||||
}
|
||||
|
||||
export default function AuditPage({ onBack }: Props) {
|
||||
const { t } = useTranslation('audit');
|
||||
const [meta, setMeta] = useState<AuditMeta | null>(null);
|
||||
const [summary, setSummary] = useState<AuditSummary | null>(null);
|
||||
const [entries, setEntries] = useState<AuditLogEntry[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [database, setDatabase] = useState<string | null>(null);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [filters, setFilters] = useState(emptyFilters);
|
||||
const [applied, setApplied] = useState(emptyFilters);
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const queryFromFilters = useCallback((f: ReturnType<typeof emptyFilters>, pageOffset: number): AuditQuery => {
|
||||
const q: AuditQuery = { limit: PAGE_SIZE, offset: pageOffset };
|
||||
if (f.user_id) q.user_id = Number(f.user_id);
|
||||
if (f.action) q.action = f.action;
|
||||
if (f.resource_type) q.resource_type = f.resource_type;
|
||||
if (f.resource_id.trim()) {
|
||||
const id = Number(f.resource_id);
|
||||
if (Number.isFinite(id)) q.resource_id = id;
|
||||
}
|
||||
if (f.from) q.from = new Date(f.from).toISOString();
|
||||
if (f.to) {
|
||||
// Inclusive end-of-day when only a date is provided
|
||||
const end = new Date(f.to);
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(f.to)) {
|
||||
end.setHours(23, 59, 59, 999);
|
||||
}
|
||||
q.to = end.toISOString();
|
||||
}
|
||||
if (f.q.trim()) q.q = f.q.trim();
|
||||
return q;
|
||||
}, []);
|
||||
|
||||
const load = useCallback(
|
||||
async (f: ReturnType<typeof emptyFilters>, pageOffset: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const q = queryFromFilters(f, pageOffset);
|
||||
const [list, sum] = await Promise.all([api.listAuditLog(q), api.getAuditSummary(q)]);
|
||||
setEntries(list.entries);
|
||||
setTotal(list.total);
|
||||
setDatabase(list.database ?? sum.database);
|
||||
setSummary(sum);
|
||||
setOffset(pageOffset);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[queryFromFilters, t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const m = await api.getAuditMeta();
|
||||
setMeta(m);
|
||||
setDatabase(m.database);
|
||||
} catch {
|
||||
// Meta is optional for first paint; list will surface auth errors.
|
||||
}
|
||||
await load(emptyFilters(), 0);
|
||||
})();
|
||||
}, [load]);
|
||||
|
||||
const applyFilters = (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
setApplied(filters);
|
||||
setExpandedId(null);
|
||||
void load(filters, 0);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
const cleared = emptyFilters();
|
||||
setFilters(cleared);
|
||||
setApplied(cleared);
|
||||
setExpandedId(null);
|
||||
void load(cleared, 0);
|
||||
};
|
||||
|
||||
const page = Math.floor(offset / PAGE_SIZE) + 1;
|
||||
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
const envLabel = useMemo(() => {
|
||||
if (!database) return t('databaseUnknown');
|
||||
if (database.includes('prod')) return t('databaseProd', { name: database });
|
||||
if (database.includes('dev')) return t('databaseDev', { name: database });
|
||||
return t('databaseNamed', { name: database });
|
||||
}, [database, t]);
|
||||
|
||||
return (
|
||||
<div className="audit-page">
|
||||
<header className="audit-header">
|
||||
<button type="button" className="audit-back" onClick={onBack}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<div className="audit-header-text">
|
||||
<h1>{t('title')}</h1>
|
||||
<p className="audit-env">{envLabel}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <p className="audit-error">{error}</p>}
|
||||
|
||||
{summary && (
|
||||
<section className="audit-stats">
|
||||
<div className="audit-stat">
|
||||
<span className="audit-stat-label">{t('statTotal')}</span>
|
||||
<span className="audit-stat-value">{summary.total}</span>
|
||||
</div>
|
||||
<div className="audit-stat">
|
||||
<span className="audit-stat-label">{t('stat24h')}</span>
|
||||
<span className="audit-stat-value">{summary.last_24h}</span>
|
||||
</div>
|
||||
<div className="audit-stat">
|
||||
<span className="audit-stat-label">{t('stat7d')}</span>
|
||||
<span className="audit-stat-value">{summary.last_7d}</span>
|
||||
</div>
|
||||
<div className="audit-stat">
|
||||
<span className="audit-stat-label">{t('statNewest')}</span>
|
||||
<span className="audit-stat-value audit-stat-value-sm">
|
||||
{summary.newest ? formatWhen(summary.newest) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<form className="audit-filters" onSubmit={applyFilters}>
|
||||
<label>
|
||||
{t('filterCurator')}
|
||||
<select
|
||||
value={filters.user_id}
|
||||
onChange={(e) => setFilters((p) => ({ ...p, user_id: e.target.value }))}
|
||||
>
|
||||
<option value="">{t('allCurators')}</option>
|
||||
{(meta?.users ?? []).map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.username} ({u.role})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t('filterAction')}
|
||||
<select
|
||||
value={filters.action}
|
||||
onChange={(e) => setFilters((p) => ({ ...p, action: e.target.value }))}
|
||||
>
|
||||
<option value="">{t('allActions')}</option>
|
||||
{(meta?.actions ?? []).map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{a}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t('filterResource')}
|
||||
<select
|
||||
value={filters.resource_type}
|
||||
onChange={(e) => setFilters((p) => ({ ...p, resource_type: e.target.value }))}
|
||||
>
|
||||
<option value="">{t('allResources')}</option>
|
||||
{(meta?.resource_types ?? []).map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t('filterResourceId')}
|
||||
<input
|
||||
value={filters.resource_id}
|
||||
onChange={(e) => setFilters((p) => ({ ...p, resource_id: e.target.value }))}
|
||||
inputMode="numeric"
|
||||
placeholder="e.g. 42"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('filterFrom')}
|
||||
<input
|
||||
type="date"
|
||||
value={filters.from}
|
||||
onChange={(e) => setFilters((p) => ({ ...p, from: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('filterTo')}
|
||||
<input
|
||||
type="date"
|
||||
value={filters.to}
|
||||
onChange={(e) => setFilters((p) => ({ ...p, to: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="audit-filter-search">
|
||||
{t('filterSearch')}
|
||||
<input
|
||||
value={filters.q}
|
||||
onChange={(e) => setFilters((p) => ({ ...p, q: e.target.value }))}
|
||||
placeholder={t('filterSearchPlaceholder')}
|
||||
/>
|
||||
</label>
|
||||
<div className="audit-filter-actions">
|
||||
<button type="submit">{t('apply')}</button>
|
||||
<button type="button" className="audit-secondary" onClick={clearFilters}>
|
||||
{t('clear')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{summary && (summary.by_user.length > 0 || summary.by_action.length > 0) && (
|
||||
<section className="audit-breakdowns">
|
||||
<div className="audit-breakdown">
|
||||
<h2>{t('byCurator')}</h2>
|
||||
<ul>
|
||||
{summary.by_user.map((row) => (
|
||||
<li key={row.user_id}>
|
||||
<button
|
||||
type="button"
|
||||
className="audit-chip"
|
||||
onClick={() => {
|
||||
const next = { ...applied, user_id: String(row.user_id) };
|
||||
setFilters(next);
|
||||
setApplied(next);
|
||||
void load(next, 0);
|
||||
}}
|
||||
>
|
||||
<strong>{row.username}</strong>
|
||||
<span>{row.count}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="audit-breakdown">
|
||||
<h2>{t('byAction')}</h2>
|
||||
<ul>
|
||||
{summary.by_action.slice(0, 12).map((row) => (
|
||||
<li key={row.action}>
|
||||
<button
|
||||
type="button"
|
||||
className="audit-chip"
|
||||
onClick={() => {
|
||||
const next = { ...applied, action: row.action };
|
||||
setFilters(next);
|
||||
setApplied(next);
|
||||
void load(next, 0);
|
||||
}}
|
||||
>
|
||||
<strong>{row.action}</strong>
|
||||
<span>{row.count}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="audit-table-wrap">
|
||||
<div className="audit-table-meta">
|
||||
<span>{t('showing', { count: entries.length, total })}</span>
|
||||
<span>
|
||||
{t('page', { page, pages: pageCount })}
|
||||
</span>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p>{t('loading')}</p>
|
||||
) : entries.length === 0 ? (
|
||||
<p className="audit-empty">{t('empty')}</p>
|
||||
) : (
|
||||
<table className="audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('colWhen')}</th>
|
||||
<th>{t('colCurator')}</th>
|
||||
<th>{t('colAction')}</th>
|
||||
<th>{t('colResource')}</th>
|
||||
<th>{t('colDetails')}</th>
|
||||
<th>{t('colIp')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => {
|
||||
const open = expandedId === entry.id;
|
||||
return (
|
||||
<Fragment key={entry.id}>
|
||||
<tr
|
||||
className={open ? 'audit-row-open' : undefined}
|
||||
onClick={() => setExpandedId(open ? null : entry.id)}
|
||||
>
|
||||
<td className="audit-when">{formatWhen(entry.created_at)}</td>
|
||||
<td>
|
||||
<div className="audit-user">
|
||||
<strong>{entry.username}</strong>
|
||||
<span className="audit-role">{entry.user_role}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<code>{entry.action}</code>
|
||||
</td>
|
||||
<td>
|
||||
<div className="audit-resource">
|
||||
<span>
|
||||
{entry.resource_type}
|
||||
{entry.resource_id != null ? ` #${entry.resource_id}` : ''}
|
||||
</span>
|
||||
{entry.resource_label && (
|
||||
<span className="audit-resource-label">{entry.resource_label}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="audit-details-cell">{detailsPreview(entry.details)}</td>
|
||||
<td>{entry.ip_address || '—'}</td>
|
||||
</tr>
|
||||
{open && (
|
||||
<tr className="audit-details-row">
|
||||
<td colSpan={6}>
|
||||
<pre>{JSON.stringify(entry.details ?? {}, null, 2)}</pre>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<div className="audit-pager">
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading || offset <= 0}
|
||||
onClick={() => void load(applied, Math.max(0, offset - PAGE_SIZE))}
|
||||
>
|
||||
{t('prev')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading || offset + PAGE_SIZE >= total}
|
||||
onClick={() => void load(applied, offset + PAGE_SIZE)}
|
||||
>
|
||||
{t('next')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
|
After Width: | Height: | Size: 13 MiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 42 KiB |
@@ -19,7 +19,47 @@ const { printCliResult } = require('./lib/cli-result');
|
||||
const { Client } = pg;
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
const express = require('express');
|
||||
const pool = require('../db');
|
||||
const { requireAdmin } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(requireAdmin);
|
||||
|
||||
const MAX_LIMIT = 200;
|
||||
const DEFAULT_LIMIT = 50;
|
||||
|
||||
function parseOptionalInt(raw) {
|
||||
if (raw == null || raw === '') return null;
|
||||
const n = parseInt(String(raw), 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function parseOptionalDate(raw) {
|
||||
if (raw == null || raw === '') return null;
|
||||
const d = new Date(String(raw));
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function buildFilters(query) {
|
||||
const clauses = [];
|
||||
const params = [];
|
||||
|
||||
const userId = parseOptionalInt(query.user_id);
|
||||
if (userId != null) {
|
||||
params.push(userId);
|
||||
clauses.push(`l.user_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const username =
|
||||
typeof query.username === 'string' && query.username.trim()
|
||||
? query.username.trim()
|
||||
: null;
|
||||
if (username) {
|
||||
params.push(username);
|
||||
clauses.push(`u.username = $${params.length}`);
|
||||
}
|
||||
|
||||
const action =
|
||||
typeof query.action === 'string' && query.action.trim() ? query.action.trim() : null;
|
||||
if (action) {
|
||||
params.push(action);
|
||||
clauses.push(`l.action = $${params.length}`);
|
||||
}
|
||||
|
||||
const resourceType =
|
||||
typeof query.resource_type === 'string' && query.resource_type.trim()
|
||||
? query.resource_type.trim()
|
||||
: null;
|
||||
if (resourceType) {
|
||||
params.push(resourceType);
|
||||
clauses.push(`l.resource_type = $${params.length}`);
|
||||
}
|
||||
|
||||
const resourceId = parseOptionalInt(query.resource_id);
|
||||
if (resourceId != null) {
|
||||
params.push(resourceId);
|
||||
clauses.push(`l.resource_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const from = parseOptionalDate(query.from);
|
||||
if (from) {
|
||||
params.push(from.toISOString());
|
||||
clauses.push(`l.created_at >= $${params.length}::timestamptz`);
|
||||
}
|
||||
|
||||
const to = parseOptionalDate(query.to);
|
||||
if (to) {
|
||||
params.push(to.toISOString());
|
||||
clauses.push(`l.created_at <= $${params.length}::timestamptz`);
|
||||
}
|
||||
|
||||
const q = typeof query.q === 'string' && query.q.trim() ? query.q.trim() : null;
|
||||
if (q) {
|
||||
params.push(`%${q}%`);
|
||||
const idx = params.length;
|
||||
clauses.push(`(
|
||||
l.action ILIKE $${idx}
|
||||
OR l.resource_type ILIKE $${idx}
|
||||
OR u.username ILIKE $${idx}
|
||||
OR COALESCE(l.details::text, '') ILIKE $${idx}
|
||||
OR COALESCE(l.ip_address, '') ILIKE $${idx}
|
||||
)`);
|
||||
}
|
||||
|
||||
return {
|
||||
where: clauses.length ? `WHERE ${clauses.join(' AND ')}` : '',
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
const RESOURCE_LABEL_SQL = `
|
||||
CASE l.resource_type
|
||||
WHEN 'painting' THEN (SELECT title FROM paintings WHERE id = l.resource_id)
|
||||
WHEN 'artist' THEN (SELECT name FROM artists WHERE id = l.resource_id)
|
||||
WHEN 'user' THEN (SELECT username FROM users WHERE id = l.resource_id)
|
||||
WHEN 'tour' THEN (SELECT title FROM tours WHERE id = l.resource_id)
|
||||
WHEN 'movement' THEN (SELECT name FROM art_movements WHERE id = l.resource_id)
|
||||
ELSE NULL
|
||||
END
|
||||
`;
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { where, params } = buildFilters(req.query);
|
||||
let limit = parseOptionalInt(req.query.limit) ?? DEFAULT_LIMIT;
|
||||
let offset = parseOptionalInt(req.query.offset) ?? 0;
|
||||
limit = Math.min(MAX_LIMIT, Math.max(1, limit));
|
||||
offset = Math.max(0, offset);
|
||||
|
||||
const countResult = await pool.query(
|
||||
`SELECT COUNT(*)::int AS total
|
||||
FROM curator_audit_log l
|
||||
JOIN users u ON u.id = l.user_id
|
||||
${where}`,
|
||||
params
|
||||
);
|
||||
|
||||
const listParams = [...params, limit, offset];
|
||||
const limitIdx = params.length + 1;
|
||||
const offsetIdx = params.length + 2;
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT
|
||||
l.id,
|
||||
l.created_at,
|
||||
l.user_id,
|
||||
u.username,
|
||||
u.role AS user_role,
|
||||
l.action,
|
||||
l.resource_type,
|
||||
l.resource_id,
|
||||
l.details,
|
||||
l.ip_address,
|
||||
(${RESOURCE_LABEL_SQL}) AS resource_label
|
||||
FROM curator_audit_log l
|
||||
JOIN users u ON u.id = l.user_id
|
||||
${where}
|
||||
ORDER BY l.created_at DESC, l.id DESC
|
||||
LIMIT $${limitIdx} OFFSET $${offsetIdx}`,
|
||||
listParams
|
||||
);
|
||||
|
||||
res.json({
|
||||
database: process.env.DB_NAME || null,
|
||||
total: countResult.rows[0].total,
|
||||
limit,
|
||||
offset,
|
||||
entries: rows.map((row) => ({
|
||||
id: row.id,
|
||||
created_at: row.created_at,
|
||||
user_id: row.user_id,
|
||||
username: row.username,
|
||||
user_role: row.user_role,
|
||||
action: row.action,
|
||||
resource_type: row.resource_type,
|
||||
resource_id: row.resource_id,
|
||||
resource_label: row.resource_label,
|
||||
details: row.details,
|
||||
ip_address: row.ip_address,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Audit list error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to load audit log' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/summary', async (req, res) => {
|
||||
try {
|
||||
const { where, params } = buildFilters(req.query);
|
||||
|
||||
const [byUser, byAction, byResource, totals] = await Promise.all([
|
||||
pool.query(
|
||||
`SELECT u.id AS user_id, u.username, u.role, COUNT(*)::int AS count
|
||||
FROM curator_audit_log l
|
||||
JOIN users u ON u.id = l.user_id
|
||||
${where}
|
||||
GROUP BY u.id, u.username, u.role
|
||||
ORDER BY count DESC, u.username ASC`,
|
||||
params
|
||||
),
|
||||
pool.query(
|
||||
`SELECT l.action, COUNT(*)::int AS count
|
||||
FROM curator_audit_log l
|
||||
JOIN users u ON u.id = l.user_id
|
||||
${where}
|
||||
GROUP BY l.action
|
||||
ORDER BY count DESC, l.action ASC`,
|
||||
params
|
||||
),
|
||||
pool.query(
|
||||
`SELECT l.resource_type, COUNT(*)::int AS count
|
||||
FROM curator_audit_log l
|
||||
JOIN users u ON u.id = l.user_id
|
||||
${where}
|
||||
GROUP BY l.resource_type
|
||||
ORDER BY count DESC, l.resource_type ASC`,
|
||||
params
|
||||
),
|
||||
pool.query(
|
||||
`SELECT
|
||||
COUNT(*)::int AS total,
|
||||
COUNT(*) FILTER (WHERE l.created_at >= NOW() - INTERVAL '24 hours')::int AS last_24h,
|
||||
COUNT(*) FILTER (WHERE l.created_at >= NOW() - INTERVAL '7 days')::int AS last_7d,
|
||||
MIN(l.created_at) AS oldest,
|
||||
MAX(l.created_at) AS newest
|
||||
FROM curator_audit_log l
|
||||
JOIN users u ON u.id = l.user_id
|
||||
${where}`,
|
||||
params
|
||||
),
|
||||
]);
|
||||
|
||||
const t = totals.rows[0] || {};
|
||||
res.json({
|
||||
database: process.env.DB_NAME || null,
|
||||
total: t.total || 0,
|
||||
last_24h: t.last_24h || 0,
|
||||
last_7d: t.last_7d || 0,
|
||||
oldest: t.oldest || null,
|
||||
newest: t.newest || null,
|
||||
by_user: byUser.rows,
|
||||
by_action: byAction.rows,
|
||||
by_resource_type: byResource.rows,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Audit summary error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to load audit summary' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/meta', async (_req, res) => {
|
||||
try {
|
||||
const [users, actions, resourceTypes] = await Promise.all([
|
||||
pool.query(
|
||||
`SELECT DISTINCT u.id, u.username, u.role
|
||||
FROM curator_audit_log l
|
||||
JOIN users u ON u.id = l.user_id
|
||||
ORDER BY u.username ASC`
|
||||
),
|
||||
pool.query(
|
||||
`SELECT DISTINCT action FROM curator_audit_log ORDER BY action ASC`
|
||||
),
|
||||
pool.query(
|
||||
`SELECT DISTINCT resource_type FROM curator_audit_log ORDER BY resource_type ASC`
|
||||
),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
database: process.env.DB_NAME || null,
|
||||
users: users.rows,
|
||||
actions: actions.rows.map((r) => r.action),
|
||||
resource_types: resourceTypes.rows.map((r) => r.resource_type),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Audit meta error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to load audit filters' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -38,6 +38,17 @@ async function clearUserSessions(userId) {
|
||||
await pool.query(`DELETE FROM session WHERE (sess->>'userId')::int = $1`, [userId]);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
|
||||