Add curator Movement links config with proof URLs and admin-gated delete votes.
Persist lineage in PostgreSQL, seed documented edges, and keep timeline/tree consumers API-backed with a static fallback. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
2b4cdc5a9f
commit
1b760faf0d
36 files changed
+2051
-43
No files matched your search
+21
-1
@@ -125,11 +125,14 @@ Each list entry includes `created_at`, `username`, `user_role`, `action`, `resou
|
|||||||
| `POST /api/artists/:id/upload-portrait` | `images` | `artist.upload_portrait` |
|
| `POST /api/artists/:id/upload-portrait` | `images` | `artist.upload_portrait` |
|
||||||
| `/api/translations/*` | `translations` | `translation.*` |
|
| `/api/translations/*` | `translations` | `translation.*` |
|
||||||
| `/api/influences/*` | `influences` | `influence.*` |
|
| `/api/influences/*` | `influences` | `influence.*` |
|
||||||
|
| `POST` `/api/movement-links` | `influences` | `movement_link.create` |
|
||||||
|
| `POST`/`DELETE` `/api/movement-links` votes | `influences` | `movement_link.delete_vote*` |
|
||||||
|
| `DELETE` `/api/movement-links/:id` | admin role | `movement_link.delete` |
|
||||||
| Tour admin (`/api/tours/admin`, POST/PATCH/DELETE, stops) | `tours` | `tour.*` |
|
| Tour admin (`/api/tours/admin`, POST/PATCH/DELETE, stops) | `tours` | `tour.*` |
|
||||||
| `/api/users/*` | `users` | `user.*` |
|
| `/api/users/*` | `users` | `user.*` |
|
||||||
| `/api/audit/*` | admin role | — (read) |
|
| `/api/audit/*` | admin role | — (read) |
|
||||||
|
|
||||||
**Public** (no login): all catalog `GET` routes, `POST /api/artists/:id/preload-images`, `POST /api/movements/:id/preload-images` (local file linking for 3D halls), `/images`, SPA static.
|
**Public** (no login): all catalog `GET` routes, `GET /api/movement-links`, `POST /api/artists/:id/preload-images`, `POST /api/movements/:id/preload-images` (local file linking for 3D halls), `/images`, SPA static.
|
||||||
|
|
||||||
Staff mutations are recorded in `curator_audit_log` with `user_id` (see [DB_structure.md](DB_structure.md)). Browse via admin **Activity** UI or `/api/audit`.
|
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`.
|
||||||
|
|
||||||
@@ -261,6 +264,23 @@ Public painting detail still exposes read-only `influencedBy` / `influenced` (un
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Movement lineage links
|
||||||
|
|
||||||
|
Timeline / Tree of Art predecessor→successor edges. Base path: `/api/movement-links`. See [data-and-images.md](data-and-images.md#movement-lineage-timeline--tree-of-art).
|
||||||
|
|
||||||
|
| Method | Path | Auth | Purpose |
|
||||||
|
|--------|------|------|---------|
|
||||||
|
| `GET` | `/api/movement-links` | public | List links (`q`, pagination); seeds defaults when empty; includes `delete_vote_count` |
|
||||||
|
| `POST` | `/api/movement-links` | curator (`influences`) | Create `{ sourceMovementId, targetMovementId, proofUrl, proofLabel?, linkYearApprox?, notes? }` — **proofUrl required** |
|
||||||
|
| `GET` | `/api/movement-links/:id/delete-votes` | curator (`influences`) | List delete votes (username + reason) |
|
||||||
|
| `POST` | `/api/movement-links/:id/delete-votes` | curator (`influences`) | Vote to delete `{ reason }` (min 10 characters) |
|
||||||
|
| `DELETE` | `/api/movement-links/:id/delete-votes/me` | curator (`influences`) | Withdraw own delete vote |
|
||||||
|
| `DELETE` | `/api/movement-links/:id` | **admin** | Hard delete (curators vote instead) |
|
||||||
|
|
||||||
|
Audit: `movement_link.create` / `movement_link.delete_vote` / `movement_link.delete_vote_withdraw` / `movement_link.delete`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Tours
|
## Tours
|
||||||
|
|
||||||
Base path: `/api/tours`. Full guide: [tours.md](tours.md).
|
Base path: `/api/tours`. Full guide: [tours.md](tours.md).
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ erDiagram
|
|||||||
paintings ||--o{ painting_influence_sources : influenced_by
|
paintings ||--o{ painting_influence_sources : influenced_by
|
||||||
artists ||--o{ painting_influence_sources : artist_source
|
artists ||--o{ painting_influence_sources : artist_source
|
||||||
art_movements ||--o{ painting_influence_sources : movement_source
|
art_movements ||--o{ painting_influence_sources : movement_source
|
||||||
|
art_movements ||--o{ movement_lineage_links : source
|
||||||
|
art_movements ||--o{ movement_lineage_links : target
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tables
|
## Tables
|
||||||
@@ -213,6 +215,36 @@ Unique index on `(painting_id, source_type, source_painting_id, source_artist_id
|
|||||||
|
|
||||||
**Canonical influence store.** Used by all API influence queries: painting detail (`influencedBy`, `influenced`), `has_influence_links`, and artist hall navigation (predecessors / successors). Legacy `painting_influences` rows are backfilled here on migration; new curated painting edges are written to both tables by `update-influences`.
|
**Canonical influence store.** Used by all API influence queries: painting detail (`influencedBy`, `influenced`), `has_influence_links`, and artist hall navigation (predecessors / successors). Legacy `painting_influences` rows are backfilled here on migration; new curated painting edges are written to both tables by `update-influences`.
|
||||||
|
|
||||||
|
### `movement_lineage_links`
|
||||||
|
|
||||||
|
Art-movement predecessor → successor edges for the timeline flow and Tree of Art. Migration: `db/migrate-movement-links.sql`.
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `id` | SERIAL PK | |
|
||||||
|
| `source_movement_id` | FK → `art_movements` | Predecessor |
|
||||||
|
| `target_movement_id` | FK → `art_movements` | Successor (must differ from source) |
|
||||||
|
| `link_year_approx` | INTEGER | Approximate transition year |
|
||||||
|
| `proof_url` | TEXT NOT NULL | Required literature / museum / web citation URL |
|
||||||
|
| `proof_label` | TEXT | Short display label for the proof |
|
||||||
|
| `notes` | TEXT | Optional curator note |
|
||||||
|
| `created_by` | FK → `users` | Curator who added the row |
|
||||||
|
| `created_at` / `updated_at` | TIMESTAMPTZ | |
|
||||||
|
|
||||||
|
Unique on `(source_movement_id, target_movement_id)`.
|
||||||
|
|
||||||
|
### `movement_lineage_delete_votes`
|
||||||
|
|
||||||
|
Curator votes requesting deletion of a lineage link. Only **admins** may hard-delete the link. Migration: `db/migrate-movement-link-delete-votes.sql`.
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `id` | SERIAL PK | |
|
||||||
|
| `link_id` | FK → `movement_lineage_links` | Cascade on link delete |
|
||||||
|
| `user_id` | FK → `users` | One vote per user per link |
|
||||||
|
| `reason` | TEXT NOT NULL | Min 10 characters (trimmed) |
|
||||||
|
| `created_at` | TIMESTAMPTZ | |
|
||||||
|
|
||||||
### `users`
|
### `users`
|
||||||
|
|
||||||
Staff accounts (named logins). Anonymous site visitors do not have rows here. Migration: `db/migrate-auth.sql` + `db/migrate-user-roles.sql`.
|
Staff accounts (named logins). Anonymous site visitors do not have rows here. Migration: `db/migrate-auth.sql` + `db/migrate-user-roles.sql`.
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ CURATOR_USERNAME=curator
|
|||||||
CURATOR_PASSWORD=your-secure-password
|
CURATOR_PASSWORD=your-secure-password
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open the gallery → **Curator login** (top-right) → use tools allowed by your role/permissions (debug, Checkup, Translations, Influences, Tour editor, **Users**, **Activity**). Mutations are logged in `curator_audit_log` per user.
|
Then open the gallery → **Curator login** (top-right) → use tools allowed by your role/permissions (debug, Checkup, Translations, Influences, **Movement links** top-left, Tour editor, **Users**, **Activity**). Mutations are logged in `curator_audit_log` per user.
|
||||||
|
|
||||||
If login fails after changing `CURATOR_PASSWORD` in `.env`, run `npm run dev:reset-curator` (bootstrap only runs when `users` is empty; reset upserts the env account as **admin**).
|
If login fails after changing `CURATOR_PASSWORD` in `.env`, run `npm run dev:reset-curator` (bootstrap only runs when `users` is empty; reset upserts the env account as **admin**).
|
||||||
|
|
||||||
@@ -103,9 +103,9 @@ If login fails after changing `CURATOR_PASSWORD` in `.env`, run `npm run dev:res
|
|||||||
|
|
||||||
| Role | Access |
|
| Role | Access |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| Guest (`user`) | Timeline, movement flow, 3D halls, painting detail, bios |
|
| Guest (`user`) | Timeline, movement flow, 3D halls, painting detail, bios; can open **Movement links** login gate |
|
||||||
| Curator | Public browse + assigned permission flags (`images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`) |
|
| Curator | Public browse + assigned permission flags (`images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`). With `influences`: Influences + Movement links create/vote-to-delete |
|
||||||
| Admin | All curator tools + **Users** + **Activity** audit reports |
|
| Admin | All curator tools + **Users** + **Activity** + hard-delete movement links |
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -18,4 +18,5 @@ this file contains draft for future releases and features
|
|||||||
9. ~~vertical bottom-up timeline (alternative start page)~~ — done: `VerticalTimeline` + `VerticalMovementBands`, header layout switch — [basics.md](basics.md#timeline-and-movement-flow)
|
9. ~~vertical bottom-up timeline (alternative start page)~~ — done: `VerticalTimeline` + `VerticalMovementBands`, header layout switch — [basics.md](basics.md#timeline-and-movement-flow)
|
||||||
10. ~~Tree of Art timeline (lineage as a growing tree)~~ — done: `MovementTree` + `movementTree.ts`, shareable `?layout=tree` — [movement-tree.md](movement-tree.md)
|
10. ~~Tree of Art timeline (lineage as a growing tree)~~ — done: `MovementTree` + `movementTree.ts`, shareable `?layout=tree` — [movement-tree.md](movement-tree.md)
|
||||||
11. ~~UI interaction and component standards~~ — done: shared doc + Cursor rule for client screens — [ui-interaction-and-component-standards.md](ui-interaction-and-component-standards.md)
|
11. ~~UI interaction and component standards~~ — done: shared doc + Cursor rule for client screens — [ui-interaction-and-component-standards.md](ui-interaction-and-component-standards.md)
|
||||||
|
12. ~~Movement lineage curator config (links + proof URLs + delete votes)~~ — done: `movement_lineage_links` / `movement_lineage_delete_votes`, `MovementLinksPage`, admin-only hard delete — [data-and-images.md](data-and-images.md#movement-lineage-timeline--tree-of-art) / [API.md](API.md#movement-lineage-links)
|
||||||
|
|
||||||
@@ -56,6 +56,7 @@ Gallery/
|
|||||||
│ │ ├── pages/CheckupPage.tsx # Image audit table
|
│ │ ├── pages/CheckupPage.tsx # Image audit table
|
||||||
│ │ ├── pages/TranslationsPage.tsx # Russian translation review
|
│ │ ├── pages/TranslationsPage.tsx # Russian translation review
|
||||||
│ │ ├── pages/InfluencesPage.tsx # Influence links CRUD + import wizard
|
│ │ ├── pages/InfluencesPage.tsx # Influence links CRUD + import wizard
|
||||||
|
│ │ ├── pages/MovementLinksPage.tsx # Movement lineage CRUD + delete votes
|
||||||
│ │ ├── pages/ToursPage.tsx # Guided tour editor
|
│ │ ├── pages/ToursPage.tsx # Guided tour editor
|
||||||
│ │ ├── pages/UsersPage.tsx # Staff accounts
|
│ │ ├── pages/UsersPage.tsx # Staff accounts
|
||||||
│ │ ├── pages/AuditPage.tsx # Admin curator activity reports
|
│ │ ├── pages/AuditPage.tsx # Admin curator activity reports
|
||||||
@@ -63,7 +64,7 @@ Gallery/
|
|||||||
│ │ ├── i18n/ # react-i18next bootstrap
|
│ │ ├── i18n/ # react-i18next bootstrap
|
||||||
│ │ ├── locales/{en,ru}/ # UI chrome strings (incl. timeline layout + captions)
|
│ │ ├── locales/{en,ru}/ # UI chrome strings (incl. timeline layout + captions)
|
||||||
│ │ ├── data/historical-events.ts # Timeline event markers (UI)
|
│ │ ├── data/historical-events.ts # Timeline event markers (UI)
|
||||||
│ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI)
|
│ │ ├── data/movement-lineage.ts # Fallback movement predecessor links (UI)
|
||||||
│ │ ├── utils/parquetFloorTexture.ts # Procedural parquet floor
|
│ │ ├── utils/parquetFloorTexture.ts # Procedural parquet floor
|
||||||
│ │ ├── utils/debugMode.ts # Debug mode + “Show more” localStorage prefs
|
│ │ ├── utils/debugMode.ts # Debug mode + “Show more” localStorage prefs
|
||||||
│ │ ├── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
|
│ │ ├── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
|
||||||
@@ -263,7 +264,7 @@ Each visible movement is drawn as a **portrait-width curved stream** (~54 px str
|
|||||||
|
|
||||||
| Feature | Implementation |
|
| Feature | Implementation |
|
||||||
|---------|----------------|
|
|---------|----------------|
|
||||||
| Lineage layout | `client/src/data/movement-lineage.ts` — curated predecessor→successor pairs (Met / ArtStory / museum essays); multiple parents allowed |
|
| Lineage layout | `movement_lineage_links` (DB) via `GET /api/movement-links`; client fallback `movement-lineage.ts`; **Movement links** page (top-left header control; create/vote needs `influences`; hard delete is **admin-only**); multiple parents allowed |
|
||||||
| Vertical lanes | Movements share a horizontal lane only when one ends at least **10 years** before the next starts (in the current zoom); closer or overlapping spans stack into extra rows (`assignTemporalLanes`). Lane choice **minimizes vertical branch length**: children prefer parent lanes (same row when years allow), corridor clearing (up to 20 passes) evicts unrelated streams preferring **rows above** the parent/child strip, then re-attracts the child toward the parent |
|
| Vertical lanes | Movements share a horizontal lane only when one ends at least **10 years** before the next starts (in the current zoom); closer or overlapping spans stack into extra rows (`assignTemporalLanes`). Lane choice **minimizes vertical branch length**: children prefer parent lanes (same row when years allow), corridor clearing (up to 20 passes) evicts unrelated streams preferring **rows above** the parent/child strip, then re-attracts the child toward the parent |
|
||||||
| Branch connectors | Smooth curves from fan-out points along a parent stream to the **left edge (start)** of each child stream; origin X is always strictly left of the target (time-forward only — never right→left); stroke thickness matches the **target** (child) band height; color gradients from parent → child at constant opacity; a stream-shaped mask hides branch ink under movements so translucent overlaps do not brighten the bands; pan/zoom layout changes **animate** (streams + branches chase new geometry) so shifts stay trackable |
|
| Branch connectors | Smooth curves from fan-out points along a parent stream to the **left edge (start)** of each child stream; origin X is always strictly left of the target (time-forward only — never right→left); stroke thickness matches the **target** (child) band height; color gradients from parent → child at constant opacity; a stream-shaped mask hides branch ink under movements so translucent overlaps do not brighten the bands; pan/zoom layout changes **animate** (streams + branches chase new geometry) so shifts stay trackable |
|
||||||
| Band thickness | Stream height is **proportional to `influence_link_count`** (edges on paintings by artists in that movement): thicker bands for denser influence graphs; lane rows share vertical space weighted by the thickest band in each lane |
|
| Band thickness | Stream height is **proportional to `influence_link_count`** (edges on paintings by artists in that movement): thicker bands for denser influence graphs; lane rows share vertical space weighted by the thickest band in each lane |
|
||||||
@@ -296,7 +297,7 @@ Each artist appears as a **portrait circle** on their movement’s stream row:
|
|||||||
|
|
||||||
Only **mousedown** on portraits and movement labels stops propagation (so drag-to-pan does not start when clicking them). Hovering a portrait highlights the artist’s lifespan on the era bar and brightens their segment on the movement stream.
|
Only **mousedown** on portraits and movement labels stops propagation (so drag-to-pan does not start when clicking them). Hovering a portrait highlights the artist’s lifespan on the era bar and brightens their segment on the movement stream.
|
||||||
|
|
||||||
**Note:** Movement lineage is **frontend curation** for layout and labels — it is not stored in PostgreSQL. Painting influence links live in **`painting_influence_sources`** (paintings, artists, or movements as sources). The API reads that table for detail panels, hall navigation, and `has_influence_links`. The legacy **`painting_influences`** table is still written in parallel when curators add painting-to-painting edges but is not queried for display.
|
**Note:** Movement lineage lives in **`movement_lineage_links`** (source/target movements, approx year, required `proof_url`). Timeline / Tree of Art load it from `GET /api/movement-links` (static fallback in `movement-lineage.ts` if the API is empty). Curators with `influences` can add links and **vote to delete** (with reason); only **admins** hard-delete. Votes are stored in `movement_lineage_delete_votes`. Painting influence links live in **`painting_influence_sources`** (paintings, artists, or movements as sources). The API reads that table for detail panels, hall navigation, and `has_influence_links`. The legacy **`painting_influences`** table is still written in parallel when curators add painting-to-painting edges but is not queried for display.
|
||||||
|
|
||||||
## Virtual gallery (3D halls)
|
## Virtual gallery (3D halls)
|
||||||
|
|
||||||
@@ -459,7 +460,7 @@ Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens
|
|||||||
- **Timeline catalog** loads once from the API; pan/zoom is client-side only, with per-frame batching via `createViewChangeScheduler()`.
|
- **Timeline catalog** loads once from the API; pan/zoom is client-side only, with per-frame batching via `createViewChangeScheduler()`.
|
||||||
- **Movement flow interaction** uses simplified SVG and hides portrait DOM during active scroll/drag so zoom stays responsive over dense portrait fields.
|
- **Movement flow interaction** uses simplified SVG and hides portrait DOM during active scroll/drag so zoom stays responsive over dense portrait fields.
|
||||||
- **Movement filtering** on zoom shows movements whose span overlaps the visible year range and that have at least one catalogued artist; a movement (e.g. Byzantine or Gothic viewed at 311–1231 CE) still appears even when all its artists lived outside the current window.
|
- **Movement filtering** on zoom shows movements whose span overlaps the visible year range and that have at least one catalogued artist; a movement (e.g. Byzantine or Gothic viewed at 311–1231 CE) still appears even when all its artists lived outside the current window.
|
||||||
- **Movement lineage** (`movement-lineage.ts`) documents art-historical predecessor→successor links for the flow diagram; extend that file to add or correct branches.
|
- **Movement lineage** — top-left **Movement links** opens `MovementLinksPage` (login if needed). Create requires `influences` + proof URL; curators vote to delete with a reason; admins review votes and delete. Data: `movement_lineage_links` / `movement_lineage_delete_votes`; timeline uses `GET /api/movement-links` with fallback `movement-lineage.ts`. After code changes that affect the SPA on port `3451`, run `npm run build --prefix client` (that port serves `client/dist`).
|
||||||
- **One hall per artist** keeps navigation predictable: enter from the timeline or bio, leave via the single exit or back button.
|
- **One hall per artist** keeps navigation predictable: enter from the timeline or bio, leave via the single exit or back button.
|
||||||
- **Movement galleries** complement artist halls: full movement corpus in period-themed wings, entered from the flow diagram.
|
- **Movement galleries** complement artist halls: full movement corpus in period-themed wings, entered from the flow diagram.
|
||||||
- **Guided tours** add curator-ordered winged halls with stop text on painting detail — [tours.md](tours.md).
|
- **Guided tours** add curator-ordered winged halls with stop text on painting detail — [tours.md](tours.md).
|
||||||
|
|||||||
@@ -263,18 +263,23 @@ Discovered rows are stored with `confidence: discovered` and `discovered_via` (e
|
|||||||
|
|
||||||
Extend `art-influences-data.js` for high-quality curated chains; use discovery to suggest additional artist and movement links for manual review.
|
Extend `art-influences-data.js` for high-quality curated chains; use discovery to suggest additional artist and movement links for manual review.
|
||||||
|
|
||||||
## Movement lineage (frontend flow diagram)
|
## Movement lineage (timeline / Tree of Art)
|
||||||
|
|
||||||
Separate from the painting influence graph, `client/src/data/movement-lineage.ts` lists **art-movement** predecessor→successor pairs used only by `MovementBands.tsx` to position streams and draw branch connectors (e.g. Impressionism → Post-Impressionism → Fauvism / Cubism / Expressionism).
|
Separate from the painting influence graph, **`movement_lineage_links`** stores art-movement predecessor→successor pairs used by `MovementBands`, `VerticalMovementBands`, and `MovementTree` (e.g. Impressionism → Post-Impressionism → Fauvism / Cubism / Expressionism).
|
||||||
|
|
||||||
| Aspect | Detail |
|
| Aspect | Detail |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| Storage | TypeScript module in the client — **not** a database table |
|
| Storage | PostgreSQL `movement_lineage_links` (`db/migrate-movement-links.sql`) |
|
||||||
| Format | `[parentMovementName, childMovementName]` tuples; names must match `art_movements.name` from the seed |
|
| Required proof | `proof_url` must be a valid `http(s)` URL (museum essay, literature, web reference) |
|
||||||
| Multiple parents | Allowed (e.g. Post-Impressionism feeding several modern paths) |
|
| Optional | `link_year_approx`, `proof_label`, `notes` |
|
||||||
| Sources | Curator notes in the file reference Met essays, TheArtStory, and similar |
|
| Format | FK pairs to `art_movements`; multiple parents allowed |
|
||||||
|
| API | Public `GET /api/movement-links`; curator `POST` create + delete votes; **admin-only** hard `DELETE` |
|
||||||
|
| UI | Top-left **Movement links** (timeline header); also linked from Influences. Curators create + vote-to-delete (reason); admins review votes and hard-delete |
|
||||||
|
| Fallback | `client/src/data/movement-lineage.ts` if the API returns no rows |
|
||||||
|
| Defaults | Seeded on migrate / first GET from Met / TheArtStory / Christie's essays |
|
||||||
|
| Delete votes | `movement_lineage_delete_votes` (`db/migrate-movement-link-delete-votes.sql`); one vote per curator per link |
|
||||||
|
|
||||||
To add or fix a branch, edit `MOVEMENT_LINEAGE` in that file and rebuild the client. No migration or API change is required.
|
To add a branch, use **Movement links** (proof URL required). To remove one, curators vote with a reason; an admin deletes after review. No client rebuild is required for data changes; rebuild `client/dist` only when shipping SPA changes to the API-static port (`3451` / prod).
|
||||||
|
|
||||||
## Movement gallery interiors (frontend)
|
## Movement gallery interiors (frontend)
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,23 @@ Audit: `influence.create` / `update` / `delete` / `import` in `curator_audit_log
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Movement lineage links (related curator tool)
|
||||||
|
|
||||||
|
Top-left timeline control **Movement links** (also from Influences → **Movement links →**). Create/vote need `influences`; hard delete is **admin-only**. Tables `movement_lineage_links` + `movement_lineage_delete_votes`.
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| `GET` | `/api/movement-links` | Public list (timeline / tree); includes `delete_vote_count` |
|
||||||
|
| `POST` | `/api/movement-links` | Create (`sourceMovementId`, `targetMovementId`, `proofUrl` required) |
|
||||||
|
| `POST` | `/api/movement-links/:id/delete-votes` | Curator vote to delete (`reason` required, min 10 chars) |
|
||||||
|
| `GET` | `/api/movement-links/:id/delete-votes` | List votes (username + reason) for admin review |
|
||||||
|
| `DELETE` | `/api/movement-links/:id/delete-votes/me` | Withdraw own vote |
|
||||||
|
| `DELETE` | `/api/movement-links/:id` | **Admin only** — hard delete |
|
||||||
|
|
||||||
|
See [data-and-images.md](data-and-images.md#movement-lineage-timeline--tree-of-art).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## CLI still available
|
## CLI still available
|
||||||
|
|
||||||
- `npm run dev:update-influences` — curated [`scripts/art-influences-data.js`](../scripts/art-influences-data.js)
|
- `npm run dev:update-influences` — curated [`scripts/art-influences-data.js`](../scripts/art-influences-data.js)
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ All constants sit at the top of the two files and are safe to tune:
|
|||||||
| `FULL_VIEW_WIDTH_SHARE`, `ZOOM_SPREAD_EXPONENT`, `MAX_FIT_BOOST` | `MovementTree.tsx` | crown spread vs. zoom |
|
| `FULL_VIEW_WIDTH_SHARE`, `ZOOM_SPREAD_EXPONENT`, `MAX_FIT_BOOST` | `MovementTree.tsx` | crown spread vs. zoom |
|
||||||
| `MIN_LIMB_RISE_PX`, `MIN_JUNCTION_RISE_PX`, `MAX_THICKNESS_OF_LENGTH` | `MovementTree.tsx` | crown legibility at full zoom-out |
|
| `MIN_LIMB_RISE_PX`, `MIN_JUNCTION_RISE_PX`, `MAX_THICKNESS_OF_LENGTH` | `MovementTree.tsx` | crown legibility at full zoom-out |
|
||||||
|
|
||||||
New lineage edges only need adding to
|
New lineage edges: top-left **Movement links** (required proof URL) →
|
||||||
[`client/src/data/movement-lineage.ts`](../client/src/data/movement-lineage.ts) — the tree
|
`movement_lineage_links` / `GET /api/movement-links`. Curators vote to delete;
|
||||||
picks up parents, grafts, thickness and spacing from there automatically.
|
admins hard-delete. Static fallback remains in
|
||||||
|
[`client/src/data/movement-lineage.ts`](../client/src/data/movement-lineage.ts).
|
||||||
|
The tree picks up parents, grafts, thickness and spacing from the active lineage automatically.
|
||||||
@@ -72,7 +72,7 @@ If migration fails with permission errors, grant schema rights to the app user f
|
|||||||
|
|
||||||
`npm run dev:migrate` applies `db/migrate-auth.sql` (`users`, `curator_audit_log`, `session` tables). When the `users` table is empty and `CURATOR_USERNAME` / `CURATOR_PASSWORD` are set in `.env`, the first curator account is created automatically. To sync the password from `.env` later, run `npm run dev:reset-curator`.
|
`npm run dev:migrate` applies `db/migrate-auth.sql` (`users`, `curator_audit_log`, `session` tables). When the `users` table is empty and `CURATOR_USERNAME` / `CURATOR_PASSWORD` are set in `.env`, the first curator account is created automatically. To sync the password from `.env` later, run `npm run dev:reset-curator`.
|
||||||
|
|
||||||
After migrate, sign in from the site header (**Curator login**). Debug mode, Checkup, Translations, Influences, Tour editor, and all mutating debug APIs require an active curator session. Anonymous visitors browse the timeline, published Tours, and 3D halls without logging in.
|
After migrate, sign in from the site header (**Curator login**). Debug mode, Checkup, Translations, Influences, **Movement links** (top-left), Tour editor, and all mutating debug APIs require an active curator session (Movement links opens a login gate for guests). Anonymous visitors browse the timeline, published Tours, and 3D halls without logging in. `dev:migrate` also creates `movement_lineage_links` / `movement_lineage_delete_votes` and seeds default lineage when the table is empty.
|
||||||
|
|
||||||
See [API.md — Authentication](API.md#authentication) and [basics.md — Developer tools](basics.md#developer-tools-image-audit).
|
See [API.md — Authentication](API.md#authentication) and [basics.md — Developer tools](basics.md#developer-tools-image-audit).
|
||||||
|
|
||||||
|
|||||||
@@ -773,6 +773,81 @@ export const api = {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
listMovementLinks: (params: { q?: string; limit?: number; offset?: number } = {}) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.q) qs.set('q', params.q);
|
||||||
|
if (params.limit) qs.set('limit', String(params.limit));
|
||||||
|
if (params.offset) qs.set('offset', String(params.offset));
|
||||||
|
const q = qs.toString();
|
||||||
|
return fetchJson<{ items: MovementLineageLinkItem[]; total: number }>(
|
||||||
|
`${API}/movement-links${q ? `?${q}` : ''}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
createMovementLink: (payload: {
|
||||||
|
sourceMovementId: number;
|
||||||
|
targetMovementId: number;
|
||||||
|
proofUrl: string;
|
||||||
|
proofLabel?: string;
|
||||||
|
linkYearApprox?: number | null;
|
||||||
|
notes?: string;
|
||||||
|
}) =>
|
||||||
|
fetch(`${API}/movement-links`, {
|
||||||
|
...fetchCredentials,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}).then(async (res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `Create failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<{ item: MovementLineageLinkItem }>;
|
||||||
|
}),
|
||||||
|
|
||||||
|
listMovementLinkDeleteVotes: (id: number) =>
|
||||||
|
fetchJson<{ linkId: number; votes: MovementLineageDeleteVote[] }>(
|
||||||
|
`${API}/movement-links/${id}/delete-votes`,
|
||||||
|
),
|
||||||
|
|
||||||
|
voteDeleteMovementLink: (id: number, reason: string) =>
|
||||||
|
fetch(`${API}/movement-links/${id}/delete-votes`, {
|
||||||
|
...fetchCredentials,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ reason }),
|
||||||
|
}).then(async (res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `Vote failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<{ vote: MovementLineageDeleteVote }>;
|
||||||
|
}),
|
||||||
|
|
||||||
|
withdrawDeleteMovementLinkVote: (id: number) =>
|
||||||
|
fetch(`${API}/movement-links/${id}/delete-votes/me`, {
|
||||||
|
...fetchCredentials,
|
||||||
|
method: 'DELETE',
|
||||||
|
}).then(async (res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `Withdraw failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<{ ok: boolean; id: number; linkId: number }>;
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteMovementLink: (id: number) =>
|
||||||
|
fetch(`${API}/movement-links/${id}`, {
|
||||||
|
...fetchCredentials,
|
||||||
|
method: 'DELETE',
|
||||||
|
}).then(async (res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `Delete failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<{ ok: boolean; id: number }>;
|
||||||
|
}),
|
||||||
|
|
||||||
getInfluenceGraph: (params: { artistId?: number; paintingId?: number }) => {
|
getInfluenceGraph: (params: { artistId?: number; paintingId?: number }) => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (params.artistId) qs.set('artistId', String(params.artistId));
|
if (params.artistId) qs.set('artistId', String(params.artistId));
|
||||||
@@ -1032,6 +1107,33 @@ export interface InfluenceEdgeItem {
|
|||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MovementLineageLinkItem {
|
||||||
|
id: number;
|
||||||
|
source_movement_id: number;
|
||||||
|
target_movement_id: number;
|
||||||
|
link_year_approx: number | null;
|
||||||
|
proof_url: string;
|
||||||
|
proof_label: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
source_name: string;
|
||||||
|
source_start_year: number;
|
||||||
|
target_name: string;
|
||||||
|
target_start_year: number;
|
||||||
|
delete_vote_count?: number;
|
||||||
|
viewer_has_delete_vote?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MovementLineageDeleteVote {
|
||||||
|
id: number;
|
||||||
|
link_id: number;
|
||||||
|
user_id: number;
|
||||||
|
reason: string;
|
||||||
|
created_at: string;
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface InfluenceGraph {
|
export interface InfluenceGraph {
|
||||||
focus: { artistId: number; paintingId: number | null; label: string };
|
focus: { artistId: number; paintingId: number | null; label: string };
|
||||||
nodes: Array<{ id: string; type: string; label: string; focus?: boolean; artistId?: number; paintingId?: number; movementId?: number }>;
|
nodes: Array<{ id: string; type: string; label: string; focus?: boolean; artistId?: number; paintingId?: number; movementId?: number }>;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import type { ArtMovement, Artist } from '../types';
|
import type { ArtMovement, Artist } from '../types';
|
||||||
import { portraitThumbUrl } from '../api/client';
|
import { portraitThumbUrl } from '../api/client';
|
||||||
import { useQueuedImageSrc } from '../hooks/useQueuedImageSrc';
|
import { useQueuedImageSrc } from '../hooks/useQueuedImageSrc';
|
||||||
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
|
import { getMovementLineage } from '../data/movement-lineage';
|
||||||
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
||||||
import { parseHexColor, rgbToHsl, hslToHex, vividMovementColor } from '../utils/movementColor';
|
import { parseHexColor, rgbToHsl, hslToHex, vividMovementColor } from '../utils/movementColor';
|
||||||
import './MovementBands.css';
|
import './MovementBands.css';
|
||||||
@@ -269,7 +269,7 @@ function buildLineageParentMap(
|
|||||||
const visibleIds = new Set(visible.map((m) => m.id));
|
const visibleIds = new Set(visible.map((m) => m.id));
|
||||||
const childToParents = new Map<number, number[]>();
|
const childToParents = new Map<number, number[]>();
|
||||||
|
|
||||||
for (const [parentName, childName] of MOVEMENT_LINEAGE) {
|
for (const [parentName, childName] of getMovementLineage()) {
|
||||||
const parentId = nameToId.get(parentName);
|
const parentId = nameToId.get(parentName);
|
||||||
const childId = nameToId.get(childName);
|
const childId = nameToId.get(childName);
|
||||||
if (!parentId || !childId) continue;
|
if (!parentId || !childId) continue;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react';
|
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { ArtMovement } from '../types';
|
import type { ArtMovement } from '../types';
|
||||||
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
|
import { getMovementLineage } from '../data/movement-lineage';
|
||||||
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
||||||
import { vividMovementColor } from '../utils/movementColor';
|
import { vividMovementColor } from '../utils/movementColor';
|
||||||
import './VerticalMovementBands.css';
|
import './VerticalMovementBands.css';
|
||||||
@@ -59,7 +59,7 @@ function buildLineageParentMap(
|
|||||||
): Map<number, number[]> {
|
): Map<number, number[]> {
|
||||||
const parents = new Map<number, number[]>();
|
const parents = new Map<number, number[]>();
|
||||||
const visibleIds = new Set(visible.map((m) => m.id));
|
const visibleIds = new Set(visible.map((m) => m.id));
|
||||||
for (const [parentName, childName] of MOVEMENT_LINEAGE) {
|
for (const [parentName, childName] of getMovementLineage()) {
|
||||||
const parentId = nameToId.get(parentName);
|
const parentId = nameToId.get(parentName);
|
||||||
const childId = nameToId.get(childName);
|
const childId = nameToId.get(childName);
|
||||||
if (parentId == null || childId == null) continue;
|
if (parentId == null || childId == null) continue;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Documented art-historical movement lineage (predecessor → successor).
|
* Art-historical movement lineage (predecessor → successor).
|
||||||
* Sources: Met Museum essays, TheArtStory, Sotheby's movement guides.
|
* Defaults match museum essays; live data comes from GET /api/movement-links.
|
||||||
*
|
|
||||||
* Multiple parents are allowed (e.g. Post-Impressionism feeding several modern paths).
|
* Multiple parents are allowed (e.g. Post-Impressionism feeding several modern paths).
|
||||||
*/
|
*/
|
||||||
export const MOVEMENT_LINEAGE: ReadonlyArray<readonly [parent: string, child: string]> = [
|
|
||||||
|
export type MovementLineageEdge = readonly [parent: string, child: string];
|
||||||
|
|
||||||
|
export const FALLBACK_MOVEMENT_LINEAGE: ReadonlyArray<MovementLineageEdge> = [
|
||||||
// Classical & medieval
|
// Classical & medieval
|
||||||
['Ancient Greek & Roman', 'Byzantine'],
|
['Ancient Greek & Roman', 'Byzantine'],
|
||||||
['Byzantine', 'Gothic'],
|
['Byzantine', 'Gothic'],
|
||||||
@@ -21,17 +23,17 @@ export const MOVEMENT_LINEAGE: ReadonlyArray<readonly [parent: string, child: st
|
|||||||
['Rococo', 'Neoclassicism'],
|
['Rococo', 'Neoclassicism'],
|
||||||
['Neoclassicism', 'Romanticism'],
|
['Neoclassicism', 'Romanticism'],
|
||||||
['Romanticism', 'Realism'],
|
['Romanticism', 'Realism'],
|
||||||
['Romanticism', 'Impressionism'], // Turner, Delacroix → light and atmosphere
|
['Romanticism', 'Impressionism'],
|
||||||
['Realism', 'Impressionism'],
|
['Realism', 'Impressionism'],
|
||||||
|
|
||||||
// Modern origins (Met / ArtStory: Impressionism → Post-Impressionism → …)
|
// Modern origins
|
||||||
['Impressionism', 'Post-Impressionism'],
|
['Impressionism', 'Post-Impressionism'],
|
||||||
['Post-Impressionism', 'Symbolism'],
|
['Post-Impressionism', 'Symbolism'],
|
||||||
['Post-Impressionism', 'Fauvism'],
|
['Post-Impressionism', 'Fauvism'],
|
||||||
['Post-Impressionism', 'Expressionism'],
|
['Post-Impressionism', 'Expressionism'],
|
||||||
['Post-Impressionism', 'Cubism'], // Cézanne's structure → Picasso/Braque
|
['Post-Impressionism', 'Cubism'],
|
||||||
['Symbolism', 'Art Nouveau'],
|
['Symbolism', 'Art Nouveau'],
|
||||||
['Fauvism', 'Cubism'], // Braque, Matisse → geometric turn
|
['Fauvism', 'Cubism'],
|
||||||
['Cubism', 'Futurism'],
|
['Cubism', 'Futurism'],
|
||||||
['Cubism', 'Suprematism'],
|
['Cubism', 'Suprematism'],
|
||||||
['Cubism', 'Constructivism'],
|
['Cubism', 'Constructivism'],
|
||||||
@@ -43,10 +45,35 @@ export const MOVEMENT_LINEAGE: ReadonlyArray<readonly [parent: string, child: st
|
|||||||
['Abstract Expressionism', 'Pop Art'],
|
['Abstract Expressionism', 'Pop Art'],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let activeLineage: ReadonlyArray<MovementLineageEdge> = FALLBACK_MOVEMENT_LINEAGE;
|
||||||
|
|
||||||
|
/** Active edges for timeline / tree layout (API-backed when loaded). */
|
||||||
|
export function getMovementLineage(): ReadonlyArray<MovementLineageEdge> {
|
||||||
|
return activeLineage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply curator DB rows; empty list keeps the static fallback. */
|
||||||
|
export function setMovementLineageFromApi(
|
||||||
|
items: ReadonlyArray<{ source_name: string; target_name: string }>
|
||||||
|
): void {
|
||||||
|
if (!items.length) {
|
||||||
|
activeLineage = FALLBACK_MOVEMENT_LINEAGE;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeLineage = items.map((row) => [row.source_name, row.target_name] as const);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use getMovementLineage() — alias for older imports. */
|
||||||
|
export const MOVEMENT_LINEAGE = FALLBACK_MOVEMENT_LINEAGE;
|
||||||
|
|
||||||
export function lineageParentsByName(childName: string): string[] {
|
export function lineageParentsByName(childName: string): string[] {
|
||||||
return MOVEMENT_LINEAGE.filter(([, child]) => child === childName).map(([parent]) => parent);
|
return getMovementLineage()
|
||||||
|
.filter(([, child]) => child === childName)
|
||||||
|
.map(([parent]) => parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function lineageChildrenByName(parentName: string): string[] {
|
export function lineageChildrenByName(parentName: string): string[] {
|
||||||
return MOVEMENT_LINEAGE.filter(([parent]) => parent === parentName).map(([, child]) => child);
|
return getMovementLineage()
|
||||||
|
.filter(([parent]) => parent === parentName)
|
||||||
|
.map(([, child]) => child);
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ import enAnnotations from '../locales/en/annotations.json';
|
|||||||
import enDebug from '../locales/en/debug.json';
|
import enDebug from '../locales/en/debug.json';
|
||||||
import enTranslations from '../locales/en/translations.json';
|
import enTranslations from '../locales/en/translations.json';
|
||||||
import enInfluences from '../locales/en/influences.json';
|
import enInfluences from '../locales/en/influences.json';
|
||||||
|
import enMovementLinks from '../locales/en/movementLinks.json';
|
||||||
import enTours from '../locales/en/tours.json';
|
import enTours from '../locales/en/tours.json';
|
||||||
import enUsers from '../locales/en/users.json';
|
import enUsers from '../locales/en/users.json';
|
||||||
import enAudit from '../locales/en/audit.json';
|
import enAudit from '../locales/en/audit.json';
|
||||||
@@ -26,6 +27,7 @@ import ruAnnotations from '../locales/ru/annotations.json';
|
|||||||
import ruDebug from '../locales/ru/debug.json';
|
import ruDebug from '../locales/ru/debug.json';
|
||||||
import ruTranslations from '../locales/ru/translations.json';
|
import ruTranslations from '../locales/ru/translations.json';
|
||||||
import ruInfluences from '../locales/ru/influences.json';
|
import ruInfluences from '../locales/ru/influences.json';
|
||||||
|
import ruMovementLinks from '../locales/ru/movementLinks.json';
|
||||||
import ruTours from '../locales/ru/tours.json';
|
import ruTours from '../locales/ru/tours.json';
|
||||||
import ruUsers from '../locales/ru/users.json';
|
import ruUsers from '../locales/ru/users.json';
|
||||||
import ruAudit from '../locales/ru/audit.json';
|
import ruAudit from '../locales/ru/audit.json';
|
||||||
@@ -37,7 +39,7 @@ void i18n.use(initReactI18next).init({
|
|||||||
lng: initialLocale,
|
lng: initialLocale,
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
supportedLngs: ['en', 'ru'],
|
supportedLngs: ['en', 'ru'],
|
||||||
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences', 'tours', 'users', 'audit'],
|
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences', 'movementLinks', 'tours', 'users', 'audit'],
|
||||||
defaultNS: 'common',
|
defaultNS: 'common',
|
||||||
resources: {
|
resources: {
|
||||||
en: {
|
en: {
|
||||||
@@ -51,6 +53,7 @@ void i18n.use(initReactI18next).init({
|
|||||||
debug: enDebug,
|
debug: enDebug,
|
||||||
translations: enTranslations,
|
translations: enTranslations,
|
||||||
influences: enInfluences,
|
influences: enInfluences,
|
||||||
|
movementLinks: enMovementLinks,
|
||||||
tours: enTours,
|
tours: enTours,
|
||||||
users: enUsers,
|
users: enUsers,
|
||||||
audit: enAudit,
|
audit: enAudit,
|
||||||
@@ -66,6 +69,7 @@ void i18n.use(initReactI18next).init({
|
|||||||
debug: ruDebug,
|
debug: ruDebug,
|
||||||
translations: ruTranslations,
|
translations: ruTranslations,
|
||||||
influences: ruInfluences,
|
influences: ruInfluences,
|
||||||
|
movementLinks: ruMovementLinks,
|
||||||
tours: ruTours,
|
tours: ruTours,
|
||||||
users: ruUsers,
|
users: ruUsers,
|
||||||
audit: ruAudit,
|
audit: ruAudit,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"checkup": "Checkup",
|
"checkup": "Checkup",
|
||||||
"translations": "Translations",
|
"translations": "Translations",
|
||||||
"influences": "Influences",
|
"influences": "Influences",
|
||||||
|
"movementLinks": "Movement links",
|
||||||
"tours": "Tours",
|
"tours": "Tours",
|
||||||
"toursEditor": "Tour editor",
|
"toursEditor": "Tour editor",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": "Influence links",
|
"title": "Influence links",
|
||||||
"back": "← Back to gallery",
|
"back": "← Back to gallery",
|
||||||
|
"openMovementLinks": "Movement links →",
|
||||||
"tabList": "List",
|
"tabList": "List",
|
||||||
"tabImport": "Import",
|
"tabImport": "Import",
|
||||||
"tabGraph": "Graph",
|
"tabGraph": "Graph",
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"title": "Movement links",
|
||||||
|
"back": "← Back to gallery",
|
||||||
|
"intro": "Predecessor → successor edges used by the timeline flow and Tree of Art. Every link needs a proof URL (museum essay, literature, or other web reference).",
|
||||||
|
"introCurator": "Curators can vote to delete a link and must give a reason. Only an admin can perform the final delete after reviewing votes.",
|
||||||
|
"introAdmin": "As admin you can delete links directly. Review curator delete votes (reasons) before confirming.",
|
||||||
|
"loadFailed": "Failed to load movement links",
|
||||||
|
"saveFailed": "Failed to save movement link",
|
||||||
|
"deleteFailed": "Failed to delete movement link",
|
||||||
|
"voteFailed": "Failed to cast delete vote",
|
||||||
|
"withdrawFailed": "Failed to withdraw vote",
|
||||||
|
"searchPlaceholder": "Search source, target, proof…",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"loadingVotes": "Loading votes…",
|
||||||
|
"votesLoadFailed": "Could not load votes.",
|
||||||
|
"total": "{{count}} links",
|
||||||
|
"noLinks": "No movement links yet.",
|
||||||
|
"addLink": "Add link",
|
||||||
|
"cancelAdd": "Cancel",
|
||||||
|
"addTitle": "New movement link",
|
||||||
|
"source": "Source (from)",
|
||||||
|
"target": "Target (to)",
|
||||||
|
"pickMovement": "Select movement…",
|
||||||
|
"proofUrl": "Proof URL",
|
||||||
|
"proofLabel": "Proof label",
|
||||||
|
"proofLabelPlaceholder": "e.g. Met — Cubism",
|
||||||
|
"linkYear": "Link date ≈",
|
||||||
|
"linkYearPlaceholder": "e.g. 1907",
|
||||||
|
"notes": "Notes",
|
||||||
|
"saveLink": "Save link",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"addRequiresMovements": "Pick both source and target movements",
|
||||||
|
"proofRequired": "Proof URL is required",
|
||||||
|
"voteTitle": "Vote to delete",
|
||||||
|
"voteReason": "Reason",
|
||||||
|
"voteReasonPlaceholder": "Explain why this link should be removed (min. 10 characters)…",
|
||||||
|
"voteReasonRequired": "Reason must be at least 10 characters",
|
||||||
|
"submitVote": "Submit vote",
|
||||||
|
"cancelVote": "Cancel",
|
||||||
|
"voteDelete": "Vote to delete",
|
||||||
|
"withdrawVote": "Withdraw vote",
|
||||||
|
"voteCount": "{{count}} vote(s)",
|
||||||
|
"colSource": "Source",
|
||||||
|
"colTarget": "Target",
|
||||||
|
"colYear": "Link date ≈",
|
||||||
|
"colProof": "Reference / proof",
|
||||||
|
"colVotes": "Delete votes",
|
||||||
|
"colActions": "Actions",
|
||||||
|
"delete": "Delete",
|
||||||
|
"confirmDelete": "Delete this movement link? Curator vote reasons will be recorded in the audit log."
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"perm_checkup": "Checkup",
|
"perm_checkup": "Checkup",
|
||||||
"perm_curator_notes": "Curator notes",
|
"perm_curator_notes": "Curator notes",
|
||||||
"perm_translations": "Translations",
|
"perm_translations": "Translations",
|
||||||
"perm_influences": "Influences",
|
"perm_influences": "Influences & movement links",
|
||||||
"perm_tours": "Tours",
|
"perm_tours": "Tours",
|
||||||
"perm_users": "Users",
|
"perm_users": "Users",
|
||||||
"adminAllPerms": "Admins have all permissions automatically.",
|
"adminAllPerms": "Admins have all permissions automatically.",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"checkup": "Проверка",
|
"checkup": "Проверка",
|
||||||
"translations": "Переводы",
|
"translations": "Переводы",
|
||||||
"influences": "Влияния",
|
"influences": "Влияния",
|
||||||
|
"movementLinks": "Связи движений",
|
||||||
"tours": "Экскурсии",
|
"tours": "Экскурсии",
|
||||||
"toursEditor": "Редактор экскурсий",
|
"toursEditor": "Редактор экскурсий",
|
||||||
"users": "Пользователи",
|
"users": "Пользователи",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": "Связи влияния",
|
"title": "Связи влияния",
|
||||||
"back": "← Назад в галерею",
|
"back": "← Назад в галерею",
|
||||||
|
"openMovementLinks": "Связи движений →",
|
||||||
"tabList": "Список",
|
"tabList": "Список",
|
||||||
"tabImport": "Импорт",
|
"tabImport": "Импорт",
|
||||||
"tabGraph": "Граф",
|
"tabGraph": "Граф",
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"title": "Связи движений",
|
||||||
|
"back": "← Назад в галерею",
|
||||||
|
"intro": "Связи предшественник → преемник для ленты и «Дерева искусства». Для каждой связи нужна ссылка-доказательство (музейный очерк, литература или интернет).",
|
||||||
|
"introCurator": "Кураторы могут голосовать за удаление связи с указанием причины. Окончательное удаление выполняет только администратор после просмотра голосов.",
|
||||||
|
"introAdmin": "Как администратор вы можете удалять связи напрямую. Перед подтверждением просмотрите голоса кураторов и их причины.",
|
||||||
|
"loadFailed": "Не удалось загрузить связи движений",
|
||||||
|
"saveFailed": "Не удалось сохранить связь",
|
||||||
|
"deleteFailed": "Не удалось удалить связь",
|
||||||
|
"voteFailed": "Не удалось отправить голос",
|
||||||
|
"withdrawFailed": "Не удалось отозвать голос",
|
||||||
|
"searchPlaceholder": "Поиск: источник, цель, ссылка…",
|
||||||
|
"refresh": "Обновить",
|
||||||
|
"loading": "Загрузка…",
|
||||||
|
"loadingVotes": "Загрузка голосов…",
|
||||||
|
"votesLoadFailed": "Не удалось загрузить голоса.",
|
||||||
|
"total": "{{count}} связей",
|
||||||
|
"noLinks": "Связей движений пока нет.",
|
||||||
|
"addLink": "Добавить связь",
|
||||||
|
"cancelAdd": "Отмена",
|
||||||
|
"addTitle": "Новая связь движений",
|
||||||
|
"source": "Источник (от)",
|
||||||
|
"target": "Цель (к)",
|
||||||
|
"pickMovement": "Выберите движение…",
|
||||||
|
"proofUrl": "Ссылка-доказательство",
|
||||||
|
"proofLabel": "Подпись ссылки",
|
||||||
|
"proofLabelPlaceholder": "напр. Met — Cubism",
|
||||||
|
"linkYear": "Дата связи ≈",
|
||||||
|
"linkYearPlaceholder": "напр. 1907",
|
||||||
|
"notes": "Заметки",
|
||||||
|
"saveLink": "Сохранить",
|
||||||
|
"saving": "Сохранение…",
|
||||||
|
"addRequiresMovements": "Выберите исходное и целевое движение",
|
||||||
|
"proofRequired": "Ссылка-доказательство обязательна",
|
||||||
|
"voteTitle": "Голос за удаление",
|
||||||
|
"voteReason": "Причина",
|
||||||
|
"voteReasonPlaceholder": "Почему связь нужно удалить (мин. 10 символов)…",
|
||||||
|
"voteReasonRequired": "Причина должна быть не короче 10 символов",
|
||||||
|
"submitVote": "Отправить голос",
|
||||||
|
"cancelVote": "Отмена",
|
||||||
|
"voteDelete": "Голосовать за удаление",
|
||||||
|
"withdrawVote": "Отозвать голос",
|
||||||
|
"voteCount": "{{count}} голос(ов)",
|
||||||
|
"colSource": "Источник",
|
||||||
|
"colTarget": "Цель",
|
||||||
|
"colYear": "Дата ≈",
|
||||||
|
"colProof": "Ссылка / доказательство",
|
||||||
|
"colVotes": "Голоса за удаление",
|
||||||
|
"colActions": "Действия",
|
||||||
|
"delete": "Удалить",
|
||||||
|
"confirmDelete": "Удалить эту связь движений? Причины голосов кураторов будут записаны в журнал аудита."
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"perm_checkup": "Проверка",
|
"perm_checkup": "Проверка",
|
||||||
"perm_curator_notes": "Заметки куратора",
|
"perm_curator_notes": "Заметки куратора",
|
||||||
"perm_translations": "Переводы",
|
"perm_translations": "Переводы",
|
||||||
"perm_influences": "Влияния",
|
"perm_influences": "Влияния и связи движений",
|
||||||
"perm_tours": "Экскурсии",
|
"perm_tours": "Экскурсии",
|
||||||
"perm_users": "Пользователи",
|
"perm_users": "Пользователи",
|
||||||
"adminAllPerms": "У администраторов все права автоматически.",
|
"adminAllPerms": "У администраторов все права автоматически.",
|
||||||
|
|||||||
@@ -116,8 +116,12 @@
|
|||||||
top: 16px;
|
top: 16px;
|
||||||
right: 16px;
|
right: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
max-width: min(780px, calc(100vw - 24px));
|
||||||
|
z-index: 120;
|
||||||
}
|
}
|
||||||
|
|
||||||
.debug-mode-toggle,
|
.debug-mode-toggle,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import ArtistBio from '../components/ArtistBio';
|
|||||||
import CheckupPage from '../pages/CheckupPage';
|
import CheckupPage from '../pages/CheckupPage';
|
||||||
import TranslationsPage from '../pages/TranslationsPage';
|
import TranslationsPage from '../pages/TranslationsPage';
|
||||||
import InfluencesPage from '../pages/InfluencesPage';
|
import InfluencesPage from '../pages/InfluencesPage';
|
||||||
|
import MovementLinksPage from '../pages/MovementLinksPage';
|
||||||
import ToursPage from '../pages/ToursPage';
|
import ToursPage from '../pages/ToursPage';
|
||||||
import UsersPage from '../pages/UsersPage';
|
import UsersPage from '../pages/UsersPage';
|
||||||
import AuditPage from '../pages/AuditPage';
|
import AuditPage from '../pages/AuditPage';
|
||||||
@@ -28,11 +29,13 @@ import '../components/ToursPopup.css';
|
|||||||
import '../components/LocaleSwitcher.css';
|
import '../components/LocaleSwitcher.css';
|
||||||
import '../pages/TranslationsPage.css';
|
import '../pages/TranslationsPage.css';
|
||||||
import '../pages/InfluencesPage.css';
|
import '../pages/InfluencesPage.css';
|
||||||
|
import '../pages/MovementLinksPage.css';
|
||||||
import '../pages/ToursPage.css';
|
import '../pages/ToursPage.css';
|
||||||
import '../pages/UsersPage.css';
|
import '../pages/UsersPage.css';
|
||||||
import '../pages/AuditPage.css';
|
import '../pages/AuditPage.css';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
||||||
|
import { setMovementLineageFromApi } from '../data/movement-lineage';
|
||||||
import type {
|
import type {
|
||||||
TimelineData,
|
TimelineData,
|
||||||
Artist,
|
Artist,
|
||||||
@@ -55,6 +58,7 @@ type View =
|
|||||||
| { type: 'checkup' }
|
| { type: 'checkup' }
|
||||||
| { type: 'translations' }
|
| { type: 'translations' }
|
||||||
| { type: 'influences' }
|
| { type: 'influences' }
|
||||||
|
| { type: 'movement-links' }
|
||||||
| { type: 'tours' }
|
| { type: 'tours' }
|
||||||
| { type: 'users' }
|
| { type: 'users' }
|
||||||
| { type: 'audit' }
|
| { type: 'audit' }
|
||||||
@@ -69,7 +73,7 @@ type GallerySession =
|
|||||||
| { kind: 'movement'; movementId: number; data: MovementGalleryDetail }
|
| { kind: 'movement'; movementId: number; data: MovementGalleryDetail }
|
||||||
| { kind: 'tour'; tourId: number; data: TourGalleryDetail };
|
| { kind: 'tour'; tourId: number; data: TourGalleryDetail };
|
||||||
|
|
||||||
type CuratorLoginRedirect = 'checkup' | 'translations' | 'influences' | 'tours' | 'users' | 'audit' | null;
|
type CuratorLoginRedirect = 'checkup' | 'translations' | 'influences' | 'movement-links' | 'tours' | 'users' | 'audit' | null;
|
||||||
|
|
||||||
function patchPaintingInMovementDetail(
|
function patchPaintingInMovementDetail(
|
||||||
detail: MovementGalleryDetail,
|
detail: MovementGalleryDetail,
|
||||||
@@ -259,9 +263,14 @@ export default function HomePage() {
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const catalog = await api.getCatalogBootstrap();
|
const [catalog, lineage] = await Promise.all([
|
||||||
|
api.getCatalogBootstrap(),
|
||||||
|
api.listMovementLinks({ limit: 500 }).catch(() => ({ items: [] as Awaited<ReturnType<typeof api.listMovementLinks>>['items'] })),
|
||||||
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
|
setMovementLineageFromApi(lineage.items);
|
||||||
|
|
||||||
const min = catalog.bounds.min_year ?? -800;
|
const min = catalog.bounds.min_year ?? -800;
|
||||||
const max = catalog.bounds.max_year ?? 2025;
|
const max = catalog.bounds.max_year ?? 2025;
|
||||||
setBounds({ min, max });
|
setBounds({ min, max });
|
||||||
@@ -363,6 +372,8 @@ export default function HomePage() {
|
|||||||
setView({ type: 'translations' });
|
setView({ type: 'translations' });
|
||||||
} else if (loginRedirect === 'influences') {
|
} else if (loginRedirect === 'influences') {
|
||||||
setView({ type: 'influences' });
|
setView({ type: 'influences' });
|
||||||
|
} else if (loginRedirect === 'movement-links') {
|
||||||
|
setView({ type: 'movement-links' });
|
||||||
} else if (loginRedirect === 'tours') {
|
} else if (loginRedirect === 'tours') {
|
||||||
setView({ type: 'tours' });
|
setView({ type: 'tours' });
|
||||||
} else if (loginRedirect === 'users') {
|
} else if (loginRedirect === 'users') {
|
||||||
@@ -381,6 +392,7 @@ export default function HomePage() {
|
|||||||
view.type === 'checkup' ||
|
view.type === 'checkup' ||
|
||||||
view.type === 'translations' ||
|
view.type === 'translations' ||
|
||||||
view.type === 'influences' ||
|
view.type === 'influences' ||
|
||||||
|
view.type === 'movement-links' ||
|
||||||
view.type === 'tours' ||
|
view.type === 'tours' ||
|
||||||
view.type === 'users' ||
|
view.type === 'users' ||
|
||||||
view.type === 'audit'
|
view.type === 'audit'
|
||||||
@@ -413,6 +425,14 @@ export default function HomePage() {
|
|||||||
setView({ type: 'influences' });
|
setView({ type: 'influences' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openMovementLinks = () => {
|
||||||
|
if (!canInfluences) {
|
||||||
|
openCuratorLogin('movement-links');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setView({ type: 'movement-links' });
|
||||||
|
};
|
||||||
|
|
||||||
const openToursEditor = () => {
|
const openToursEditor = () => {
|
||||||
if (!canTours) {
|
if (!canTours) {
|
||||||
openCuratorLogin('tours');
|
openCuratorLogin('tours');
|
||||||
@@ -1128,7 +1148,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
{view.type === 'influences' && (
|
{view.type === 'influences' && (
|
||||||
canInfluences ? (
|
canInfluences ? (
|
||||||
<InfluencesPage onBack={goToTimelineHome} />
|
<InfluencesPage onBack={goToTimelineHome} onOpenMovementLinks={openMovementLinks} />
|
||||||
) : (
|
) : (
|
||||||
<div className="curator-login-gate">
|
<div className="curator-login-gate">
|
||||||
<h2>{t('curatorRequiredTitle')}</h2>
|
<h2>{t('curatorRequiredTitle')}</h2>
|
||||||
@@ -1145,6 +1165,25 @@ export default function HomePage() {
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{view.type === 'movement-links' && (
|
||||||
|
canInfluences ? (
|
||||||
|
<MovementLinksPage onBack={goToTimelineHome} movements={timelineData.movements} />
|
||||||
|
) : (
|
||||||
|
<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('movement-links')}>
|
||||||
|
{t('curatorLogin')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
|
||||||
|
{t('backToGalleryBtn')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
{view.type === 'tours' && (
|
{view.type === 'tours' && (
|
||||||
canTours ? (
|
canTours ? (
|
||||||
<ToursPage onBack={goToTimelineHome} />
|
<ToursPage onBack={goToTimelineHome} />
|
||||||
@@ -1273,6 +1312,14 @@ export default function HomePage() {
|
|||||||
<div className="home-page">
|
<div className="home-page">
|
||||||
<header className="site-header">
|
<header className="site-header">
|
||||||
<div className="site-layout-switch">
|
<div className="site-layout-switch">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="site-layout-link site-layout-link-feature"
|
||||||
|
onClick={openMovementLinks}
|
||||||
|
title="Manage art-movement predecessor → successor links (curator)"
|
||||||
|
>
|
||||||
|
{t('movementLinks')}
|
||||||
|
</button>
|
||||||
{!isTreeTimeline && (
|
{!isTreeTimeline && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type WizardStep = 'upload' | 'mapping' | 'preview' | 'done';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
onOpenMovementLinks?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROLE_OPTIONS = [
|
const ROLE_OPTIONS = [
|
||||||
@@ -29,7 +30,7 @@ const ROLE_OPTIONS = [
|
|||||||
'source_url',
|
'source_url',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export default function InfluencesPage({ onBack }: Props) {
|
export default function InfluencesPage({ onBack, onOpenMovementLinks }: Props) {
|
||||||
const { t } = useTranslation('influences');
|
const { t } = useTranslation('influences');
|
||||||
const [tab, setTab] = useState<Tab>('list');
|
const [tab, setTab] = useState<Tab>('list');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -335,6 +336,11 @@ export default function InfluencesPage({ onBack }: Props) {
|
|||||||
{t('back')}
|
{t('back')}
|
||||||
</button>
|
</button>
|
||||||
<h1>{t('title')}</h1>
|
<h1>{t('title')}</h1>
|
||||||
|
{onOpenMovementLinks && (
|
||||||
|
<button type="button" className="influences-back" onClick={onOpenMovementLinks}>
|
||||||
|
{t('openMovementLinks')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<nav className="influences-tabs">
|
<nav className="influences-tabs">
|
||||||
<button type="button" className={tab === 'list' ? 'active' : ''} onClick={() => setTab('list')}>
|
<button type="button" className={tab === 'list' ? 'active' : ''} onClick={() => setTab('list')}>
|
||||||
{t('tabList')}
|
{t('tabList')}
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
.movement-links-page {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.25rem 1.5rem 3rem;
|
||||||
|
color: #e8d5b5;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 650;
|
||||||
|
flex: 1;
|
||||||
|
color: #e8d5b5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-back,
|
||||||
|
.movement-links-add-btn,
|
||||||
|
.movement-links-refresh,
|
||||||
|
.movement-links-save,
|
||||||
|
.movement-links-delete {
|
||||||
|
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||||
|
background: rgba(15, 15, 26, 0.85);
|
||||||
|
color: #c9a96e;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-back:hover,
|
||||||
|
.movement-links-add-btn:hover,
|
||||||
|
.movement-links-refresh:hover,
|
||||||
|
.movement-links-save:hover {
|
||||||
|
border-color: #c9a96e;
|
||||||
|
color: #e8d5b5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-intro {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
color: rgba(232, 213, 181, 0.75);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
max-width: 52rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-intro-secondary {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
color: rgba(232, 213, 181, 0.55);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
max-width: 52rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-error {
|
||||||
|
background: rgba(139, 0, 0, 0.3);
|
||||||
|
border: 1px solid rgba(255, 170, 170, 0.4);
|
||||||
|
color: #ffaaaa;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-form {
|
||||||
|
border: 1px solid rgba(201, 169, 110, 0.28);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem 1.1rem 1.15rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
background: rgba(15, 15, 26, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-form h2 {
|
||||||
|
margin: 0 0 0.85rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem 1rem;
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-form-grid label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(232, 213, 181, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-span-2 {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-form-grid select,
|
||||||
|
.movement-links-form-grid input,
|
||||||
|
.movement-links-form-grid textarea,
|
||||||
|
.movement-links-vote-form textarea,
|
||||||
|
.movement-links-search {
|
||||||
|
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||||
|
background: rgba(10, 10, 20, 0.9);
|
||||||
|
color: #e8d5b5;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-vote-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(232, 213, 181, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-vote-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-vote-btn,
|
||||||
|
.movement-links-votes-toggle {
|
||||||
|
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||||
|
background: rgba(15, 15, 26, 0.85);
|
||||||
|
color: #c9a96e;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-vote-btn:hover,
|
||||||
|
.movement-links-votes-toggle:hover {
|
||||||
|
border-color: #c9a96e;
|
||||||
|
color: #e8d5b5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-row-flagged {
|
||||||
|
background: rgba(232, 160, 64, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-vote-list {
|
||||||
|
margin: 0.45rem 0 0;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
color: rgba(232, 213, 181, 0.7);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-actions {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-save {
|
||||||
|
background: rgba(232, 160, 64, 0.18);
|
||||||
|
border-color: #e8a040;
|
||||||
|
color: #e8d5b5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-save:disabled,
|
||||||
|
.movement-links-delete:disabled,
|
||||||
|
.movement-links-refresh:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-search {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-count {
|
||||||
|
color: rgba(232, 213, 181, 0.65);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-muted {
|
||||||
|
color: rgba(232, 213, 181, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid rgba(201, 169, 110, 0.28);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-table th,
|
||||||
|
.movement-links-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
border-bottom: 1px solid rgba(201, 169, 110, 0.18);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-table th {
|
||||||
|
background: rgba(15, 15, 26, 0.85);
|
||||||
|
color: #c9a96e;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-table tbody tr:nth-child(even) {
|
||||||
|
background: rgba(15, 15, 26, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-table a {
|
||||||
|
color: #e8a040;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-table a:hover {
|
||||||
|
color: #f0c070;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-year {
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-notes {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: rgba(232, 213, 181, 0.55);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-delete {
|
||||||
|
color: #ffaaaa;
|
||||||
|
border-color: rgba(255, 170, 170, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-delete:hover:not(:disabled) {
|
||||||
|
border-color: #ffaaaa;
|
||||||
|
color: #ffcccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.movement-links-form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-span-2 {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
type MovementLineageDeleteVote,
|
||||||
|
type MovementLineageLinkItem,
|
||||||
|
} from '../api/client';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { setMovementLineageFromApi } from '../data/movement-lineage';
|
||||||
|
import type { ArtMovement } from '../types';
|
||||||
|
import './MovementLinksPage.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onBack: () => void;
|
||||||
|
movements: ArtMovement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MovementLinksPage({ onBack, movements }: Props) {
|
||||||
|
const { t } = useTranslation('movementLinks');
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
const [items, setItems] = useState<MovementLineageLinkItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
|
||||||
|
const [sourceId, setSourceId] = useState<number | ''>('');
|
||||||
|
const [targetId, setTargetId] = useState<number | ''>('');
|
||||||
|
const [proofUrl, setProofUrl] = useState('');
|
||||||
|
const [proofLabel, setProofLabel] = useState('');
|
||||||
|
const [linkYear, setLinkYear] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
|
||||||
|
const [voteLinkId, setVoteLinkId] = useState<number | null>(null);
|
||||||
|
const [voteReason, setVoteReason] = useState('');
|
||||||
|
const [expandedVotes, setExpandedVotes] = useState<Record<number, MovementLineageDeleteVote[] | 'loading' | 'error'>>({});
|
||||||
|
|
||||||
|
const sortedMovements = useMemo(
|
||||||
|
() => [...movements].sort((a, b) => a.start_year - b.start_year || a.name.localeCompare(b.name)),
|
||||||
|
[movements],
|
||||||
|
);
|
||||||
|
|
||||||
|
const syncLocalLineage = useCallback((rows: MovementLineageLinkItem[]) => {
|
||||||
|
setMovementLineageFromApi(rows);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadList = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [data, all] = await Promise.all([
|
||||||
|
api.listMovementLinks({ q: q || undefined, limit: 500 }),
|
||||||
|
q ? api.listMovementLinks({ limit: 500 }) : Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
setItems(data.items);
|
||||||
|
setTotal(data.total);
|
||||||
|
syncLocalLineage((all ?? data).items);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [q, syncLocalLineage, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadList();
|
||||||
|
}, [loadList]);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setSourceId('');
|
||||||
|
setTargetId('');
|
||||||
|
setProofUrl('');
|
||||||
|
setProofLabel('');
|
||||||
|
setLinkYear('');
|
||||||
|
setNotes('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitAdd = async () => {
|
||||||
|
if (sourceId === '' || targetId === '') {
|
||||||
|
setError(t('addRequiresMovements'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!proofUrl.trim()) {
|
||||||
|
setError(t('proofRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const yearRaw = linkYear.trim();
|
||||||
|
await api.createMovementLink({
|
||||||
|
sourceMovementId: sourceId,
|
||||||
|
targetMovementId: targetId,
|
||||||
|
proofUrl: proofUrl.trim(),
|
||||||
|
proofLabel: proofLabel.trim() || undefined,
|
||||||
|
linkYearApprox: yearRaw === '' ? null : Number(yearRaw),
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
});
|
||||||
|
resetForm();
|
||||||
|
setShowAdd(false);
|
||||||
|
await loadList();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t('saveFailed'));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitVote = async () => {
|
||||||
|
if (voteLinkId == null) return;
|
||||||
|
if (voteReason.trim().length < 10) {
|
||||||
|
setError(t('voteReasonRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.voteDeleteMovementLink(voteLinkId, voteReason.trim());
|
||||||
|
setVoteLinkId(null);
|
||||||
|
setVoteReason('');
|
||||||
|
setExpandedVotes((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[voteLinkId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await loadList();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t('voteFailed'));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const withdrawVote = async (id: number) => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.withdrawDeleteMovementLinkVote(id);
|
||||||
|
setExpandedVotes((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[id];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await loadList();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t('withdrawFailed'));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadVotes = async (id: number) => {
|
||||||
|
if (expandedVotes[id] && expandedVotes[id] !== 'error') {
|
||||||
|
setExpandedVotes((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[id];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExpandedVotes((prev) => ({ ...prev, [id]: 'loading' }));
|
||||||
|
try {
|
||||||
|
const data = await api.listMovementLinkDeleteVotes(id);
|
||||||
|
setExpandedVotes((prev) => ({ ...prev, [id]: data.votes }));
|
||||||
|
} catch {
|
||||||
|
setExpandedVotes((prev) => ({ ...prev, [id]: 'error' }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAdminDelete = async (id: number) => {
|
||||||
|
if (!window.confirm(t('confirmDelete'))) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.deleteMovementLink(id);
|
||||||
|
setExpandedVotes((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[id];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await loadList();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t('deleteFailed'));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="movement-links-page">
|
||||||
|
<header className="movement-links-header">
|
||||||
|
<button type="button" className="movement-links-back" onClick={onBack}>
|
||||||
|
{t('back')}
|
||||||
|
</button>
|
||||||
|
<h1>{t('title')}</h1>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="movement-links-add-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setShowAdd((v) => !v);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showAdd ? t('cancelAdd') : t('addLink')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="movement-links-refresh" onClick={() => void loadList()} disabled={loading}>
|
||||||
|
{t('refresh')}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p className="movement-links-intro">{t('intro')}</p>
|
||||||
|
<p className="movement-links-intro-secondary">
|
||||||
|
{isAdmin ? t('introAdmin') : t('introCurator')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <div className="movement-links-error">{error}</div>}
|
||||||
|
|
||||||
|
{voteLinkId != null && (
|
||||||
|
<section className="movement-links-form movement-links-vote-form">
|
||||||
|
<h2>{t('voteTitle')}</h2>
|
||||||
|
<label className="movement-links-span-2">
|
||||||
|
{t('voteReason')} *
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={voteReason}
|
||||||
|
onChange={(e) => setVoteReason(e.target.value)}
|
||||||
|
placeholder={t('voteReasonPlaceholder')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="movement-links-vote-actions">
|
||||||
|
<button type="button" className="movement-links-save" onClick={() => void submitVote()} disabled={saving}>
|
||||||
|
{saving ? t('saving') : t('submitVote')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="movement-links-refresh"
|
||||||
|
onClick={() => {
|
||||||
|
setVoteLinkId(null);
|
||||||
|
setVoteReason('');
|
||||||
|
}}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{t('cancelVote')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAdd && (
|
||||||
|
<section className="movement-links-form">
|
||||||
|
<h2>{t('addTitle')}</h2>
|
||||||
|
<div className="movement-links-form-grid">
|
||||||
|
<label>
|
||||||
|
{t('source')}
|
||||||
|
<select
|
||||||
|
value={sourceId === '' ? '' : String(sourceId)}
|
||||||
|
onChange={(e) => setSourceId(e.target.value ? Number(e.target.value) : '')}
|
||||||
|
>
|
||||||
|
<option value="">{t('pickMovement')}</option>
|
||||||
|
{sortedMovements.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name} ({m.start_year})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t('target')}
|
||||||
|
<select
|
||||||
|
value={targetId === '' ? '' : String(targetId)}
|
||||||
|
onChange={(e) => setTargetId(e.target.value ? Number(e.target.value) : '')}
|
||||||
|
>
|
||||||
|
<option value="">{t('pickMovement')}</option>
|
||||||
|
{sortedMovements.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name} ({m.start_year})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="movement-links-span-2">
|
||||||
|
{t('proofUrl')} *
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
required
|
||||||
|
placeholder="https://…"
|
||||||
|
value={proofUrl}
|
||||||
|
onChange={(e) => setProofUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t('proofLabel')}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={proofLabel}
|
||||||
|
onChange={(e) => setProofLabel(e.target.value)}
|
||||||
|
placeholder={t('proofLabelPlaceholder')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t('linkYear')}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={linkYear}
|
||||||
|
onChange={(e) => setLinkYear(e.target.value)}
|
||||||
|
placeholder={t('linkYearPlaceholder')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="movement-links-span-2">
|
||||||
|
{t('notes')}
|
||||||
|
<input type="text" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="movement-links-save" onClick={() => void submitAdd()} disabled={saving}>
|
||||||
|
{saving ? t('saving') : t('saveLink')}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="movement-links-toolbar">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="movement-links-search"
|
||||||
|
placeholder={t('searchPlaceholder')}
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="movement-links-count">{t('total', { count: total })}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="movement-links-muted">{t('loading')}</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="movement-links-muted">{t('noLinks')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="movement-links-table-wrap">
|
||||||
|
<table className="movement-links-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('colSource')}</th>
|
||||||
|
<th>{t('colTarget')}</th>
|
||||||
|
<th>{t('colYear')}</th>
|
||||||
|
<th>{t('colProof')}</th>
|
||||||
|
<th>{t('colVotes')}</th>
|
||||||
|
<th>{t('colActions')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((row) => {
|
||||||
|
const voteCount = row.delete_vote_count ?? 0;
|
||||||
|
const votesState = expandedVotes[row.id];
|
||||||
|
return (
|
||||||
|
<tr key={row.id} className={voteCount > 0 ? 'movement-links-row-flagged' : undefined}>
|
||||||
|
<td>{row.source_name}</td>
|
||||||
|
<td>{row.target_name}</td>
|
||||||
|
<td className="movement-links-year">
|
||||||
|
{row.link_year_approx != null ? `~${row.link_year_approx}` : '—'}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href={row.proof_url} target="_blank" rel="noopener noreferrer">
|
||||||
|
{row.proof_label || row.proof_url}
|
||||||
|
</a>
|
||||||
|
{row.notes ? <div className="movement-links-notes">{row.notes}</div> : null}
|
||||||
|
{Array.isArray(votesState) && votesState.length > 0 && (
|
||||||
|
<ul className="movement-links-vote-list">
|
||||||
|
{votesState.map((v) => (
|
||||||
|
<li key={v.id}>
|
||||||
|
<strong>{v.username || `#${v.user_id}`}</strong>: {v.reason}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{votesState === 'loading' && (
|
||||||
|
<div className="movement-links-notes">{t('loadingVotes')}</div>
|
||||||
|
)}
|
||||||
|
{votesState === 'error' && (
|
||||||
|
<div className="movement-links-notes">{t('votesLoadFailed')}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="movement-links-year">
|
||||||
|
{voteCount > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="movement-links-votes-toggle"
|
||||||
|
onClick={() => void loadVotes(row.id)}
|
||||||
|
>
|
||||||
|
{t('voteCount', { count: voteCount })}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
'0'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="movement-links-actions">
|
||||||
|
{isAdmin ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="movement-links-delete"
|
||||||
|
onClick={() => void onAdminDelete(row.id)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{t('delete')}
|
||||||
|
</button>
|
||||||
|
) : row.viewer_has_delete_vote ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="movement-links-refresh"
|
||||||
|
onClick={() => void withdrawVote(row.id)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{t('withdrawVote')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="movement-links-vote-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setVoteLinkId(row.id);
|
||||||
|
setVoteReason('');
|
||||||
|
setError(null);
|
||||||
|
setShowAdd(false);
|
||||||
|
}}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{t('voteDelete')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
* widest subtree first, so the main trunk keeps x = 0 (canvas centre).
|
* widest subtree first, so the main trunk keeps x = 0 (canvas centre).
|
||||||
*/
|
*/
|
||||||
import type { ArtMovement } from '../types';
|
import type { ArtMovement } from '../types';
|
||||||
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
|
import { getMovementLineage } from '../data/movement-lineage';
|
||||||
|
|
||||||
/** All values are tree-space pixels; the renderer scales them to the canvas. */
|
/** All values are tree-space pixels; the renderer scales them to the canvas. */
|
||||||
export const TREE_LAYOUT = {
|
export const TREE_LAYOUT = {
|
||||||
@@ -103,7 +103,7 @@ function influenceCount(m: ArtMovement): number {
|
|||||||
function buildParentMap(movements: ArtMovement[]): Map<number, number[]> {
|
function buildParentMap(movements: ArtMovement[]): Map<number, number[]> {
|
||||||
const nameToId = new Map(movements.map((m) => [m.name, m.id]));
|
const nameToId = new Map(movements.map((m) => [m.name, m.id]));
|
||||||
const parents = new Map<number, number[]>();
|
const parents = new Map<number, number[]>();
|
||||||
for (const [parentName, childName] of MOVEMENT_LINEAGE) {
|
for (const [parentName, childName] of getMovementLineage()) {
|
||||||
const parentId = nameToId.get(parentName);
|
const parentId = nameToId.get(parentName);
|
||||||
const childId = nameToId.get(childName);
|
const childId = nameToId.get(childName);
|
||||||
if (parentId == null || childId == null || parentId === childId) continue;
|
if (parentId == null || childId == null || parentId === childId) continue;
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Curator votes to delete a movement lineage link (admin performs the actual delete).
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS movement_lineage_delete_votes (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
link_id INTEGER NOT NULL REFERENCES movement_lineage_links(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT movement_lineage_delete_votes_unique UNIQUE (link_id, user_id),
|
||||||
|
CONSTRAINT movement_lineage_delete_votes_reason_len CHECK (char_length(trim(reason)) >= 10)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS movement_lineage_delete_votes_link_idx
|
||||||
|
ON movement_lineage_delete_votes (link_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS movement_lineage_delete_votes_user_idx
|
||||||
|
ON movement_lineage_delete_votes (user_id);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- Art-movement predecessor → successor edges for timeline / Tree of Art.
|
||||||
|
-- Curators manage these with a required proof URL (literature or web essay).
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS movement_lineage_links (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
source_movement_id INTEGER NOT NULL REFERENCES art_movements(id) ON DELETE CASCADE,
|
||||||
|
target_movement_id INTEGER NOT NULL REFERENCES art_movements(id) ON DELETE CASCADE,
|
||||||
|
link_year_approx INTEGER,
|
||||||
|
proof_url TEXT NOT NULL,
|
||||||
|
proof_label TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT movement_lineage_links_distinct CHECK (source_movement_id <> target_movement_id),
|
||||||
|
CONSTRAINT movement_lineage_links_unique UNIQUE (source_movement_id, target_movement_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS movement_lineage_links_source_idx
|
||||||
|
ON movement_lineage_links (source_movement_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS movement_lineage_links_target_idx
|
||||||
|
ON movement_lineage_links (target_movement_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS movement_lineage_links_year_idx
|
||||||
|
ON movement_lineage_links (link_year_approx);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION movement_lineage_links_touch_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS movement_lineage_links_updated_at ON movement_lineage_links;
|
||||||
|
CREATE TRIGGER movement_lineage_links_updated_at
|
||||||
|
BEFORE UPDATE ON movement_lineage_links
|
||||||
|
FOR EACH ROW EXECUTE PROCEDURE movement_lineage_links_touch_updated_at();
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"test": "node --test server/__tests__/**/*.test.js",
|
||||||
"dev:web": "node scripts/dev-web.js",
|
"dev:web": "node scripts/dev-web.js",
|
||||||
"dev:server": "nodemon server/index.js",
|
"dev:server": "nodemon server/index.js",
|
||||||
"dev:client": "npm run dev --prefix client",
|
"dev:client": "npm run dev --prefix client",
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Tests for movement lineage validation (run before / with the service module).
|
||||||
|
* node --test server/__tests__/movement-lineage-service.test.js
|
||||||
|
*/
|
||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const {
|
||||||
|
isValidProofUrl,
|
||||||
|
validateCreatePayload,
|
||||||
|
validateDeleteVotePayload,
|
||||||
|
DEFAULT_MOVEMENT_LINEAGE,
|
||||||
|
} = require('../movement-lineage-service');
|
||||||
|
|
||||||
|
describe('isValidProofUrl', () => {
|
||||||
|
it('accepts http and https URLs', () => {
|
||||||
|
assert.equal(isValidProofUrl('https://www.metmuseum.org/toah/hd/byza/hd_byza.htm'), true);
|
||||||
|
assert.equal(isValidProofUrl('http://example.com/essay'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty, non-http, and garbage', () => {
|
||||||
|
assert.equal(isValidProofUrl(''), false);
|
||||||
|
assert.equal(isValidProofUrl(' '), false);
|
||||||
|
assert.equal(isValidProofUrl(null), false);
|
||||||
|
assert.equal(isValidProofUrl('ftp://files.example/a'), false);
|
||||||
|
assert.equal(isValidProofUrl('not-a-url'), false);
|
||||||
|
assert.equal(isValidProofUrl('www.metmuseum.org/page'), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateCreatePayload', () => {
|
||||||
|
it('requires source, target, and proof URL', () => {
|
||||||
|
const r = validateCreatePayload({});
|
||||||
|
assert.equal(r.ok, false);
|
||||||
|
assert.match(r.error, /sourceMovementId/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects same source and target', () => {
|
||||||
|
const r = validateCreatePayload({
|
||||||
|
sourceMovementId: 3,
|
||||||
|
targetMovementId: 3,
|
||||||
|
proofUrl: 'https://www.theartstory.org/movement/cubism/',
|
||||||
|
});
|
||||||
|
assert.equal(r.ok, false);
|
||||||
|
assert.match(r.error, /different/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a valid proof URL', () => {
|
||||||
|
const r = validateCreatePayload({
|
||||||
|
sourceMovementId: 1,
|
||||||
|
targetMovementId: 2,
|
||||||
|
proofUrl: 'see Wikipedia',
|
||||||
|
});
|
||||||
|
assert.equal(r.ok, false);
|
||||||
|
assert.match(r.error, /proof/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a valid payload and normalizes fields', () => {
|
||||||
|
const r = validateCreatePayload({
|
||||||
|
sourceMovementId: '10',
|
||||||
|
targetMovementId: 20,
|
||||||
|
proofUrl: ' https://www.metmuseum.org/essays/cubism ',
|
||||||
|
proofLabel: ' Met Cubism ',
|
||||||
|
linkYearApprox: '1907',
|
||||||
|
notes: ' Cézanne → Picasso ',
|
||||||
|
});
|
||||||
|
assert.equal(r.ok, true);
|
||||||
|
assert.deepEqual(r.value, {
|
||||||
|
sourceMovementId: 10,
|
||||||
|
targetMovementId: 20,
|
||||||
|
proofUrl: 'https://www.metmuseum.org/essays/cubism',
|
||||||
|
proofLabel: 'Met Cubism',
|
||||||
|
linkYearApprox: 1907,
|
||||||
|
notes: 'Cézanne → Picasso',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows null optional year and blank optional label/notes', () => {
|
||||||
|
const r = validateCreatePayload({
|
||||||
|
sourceMovementId: 1,
|
||||||
|
targetMovementId: 2,
|
||||||
|
proofUrl: 'https://example.org/a',
|
||||||
|
proofLabel: '',
|
||||||
|
linkYearApprox: '',
|
||||||
|
notes: null,
|
||||||
|
});
|
||||||
|
assert.equal(r.ok, true);
|
||||||
|
assert.equal(r.value.proofLabel, null);
|
||||||
|
assert.equal(r.value.linkYearApprox, null);
|
||||||
|
assert.equal(r.value.notes, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateDeleteVotePayload', () => {
|
||||||
|
it('requires a non-empty reason', () => {
|
||||||
|
assert.equal(validateDeleteVotePayload({}).ok, false);
|
||||||
|
assert.equal(validateDeleteVotePayload({ reason: ' ' }).ok, false);
|
||||||
|
assert.match(validateDeleteVotePayload({ reason: '' }).error, /reason/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects reasons that are too short', () => {
|
||||||
|
const r = validateDeleteVotePayload({ reason: 'no' });
|
||||||
|
assert.equal(r.ok, false);
|
||||||
|
assert.match(r.error, /reason/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts and trims a valid reason', () => {
|
||||||
|
const r = validateDeleteVotePayload({
|
||||||
|
reason: ' Incorrect lineage: Cubism did not derive from Fauvism. ',
|
||||||
|
});
|
||||||
|
assert.equal(r.ok, true);
|
||||||
|
assert.equal(r.value.reason, 'Incorrect lineage: Cubism did not derive from Fauvism.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DEFAULT_MOVEMENT_LINEAGE', () => {
|
||||||
|
it('has unique source→target pairs with proof URLs', () => {
|
||||||
|
assert.ok(DEFAULT_MOVEMENT_LINEAGE.length >= 20);
|
||||||
|
const keys = new Set();
|
||||||
|
for (const row of DEFAULT_MOVEMENT_LINEAGE) {
|
||||||
|
assert.ok(row.sourceName);
|
||||||
|
assert.ok(row.targetName);
|
||||||
|
assert.notEqual(row.sourceName, row.targetName);
|
||||||
|
assert.equal(isValidProofUrl(row.proofUrl), true, row.proofUrl);
|
||||||
|
const key = `${row.sourceName}→${row.targetName}`;
|
||||||
|
assert.equal(keys.has(key), false, `duplicate ${key}`);
|
||||||
|
keys.add(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -50,6 +50,7 @@ app.use('/api/users', usersRoutes);
|
|||||||
app.use('/api/audit', auditRoutes);
|
app.use('/api/audit', auditRoutes);
|
||||||
app.use('/api/translations', translationRoutes);
|
app.use('/api/translations', translationRoutes);
|
||||||
app.use('/api/influences', influenceRoutes);
|
app.use('/api/influences', influenceRoutes);
|
||||||
|
app.use('/api/movement-links', require('./routes/movement-links'));
|
||||||
app.use('/api/tours', tourRoutes);
|
app.use('/api/tours', tourRoutes);
|
||||||
app.use(
|
app.use(
|
||||||
'/images',
|
'/images',
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ const INCREMENTAL_MIGRATIONS = [
|
|||||||
'migrate-tours.sql',
|
'migrate-tours.sql',
|
||||||
'migrate-curator-notes.sql',
|
'migrate-curator-notes.sql',
|
||||||
'migrate-user-roles.sql',
|
'migrate-user-roles.sql',
|
||||||
|
'migrate-movement-links.sql',
|
||||||
|
'migrate-movement-link-delete-votes.sql',
|
||||||
];
|
];
|
||||||
|
|
||||||
const BOOTSTRAP_ADMIN_PERMISSIONS = [
|
const BOOTSTRAP_ADMIN_PERMISSIONS = [
|
||||||
@@ -80,6 +82,15 @@ async function migrate() {
|
|||||||
console.log('Bootstrapping curator account (if needed) …');
|
console.log('Bootstrapping curator account (if needed) …');
|
||||||
await bootstrapCurator();
|
await bootstrapCurator();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { seedDefaultsIfEmpty } = require('./movement-lineage-service');
|
||||||
|
console.log('Seeding movement lineage defaults (if empty) …');
|
||||||
|
const seed = await seedDefaultsIfEmpty();
|
||||||
|
console.log(` movement lineage: inserted=${seed.inserted}${seed.skipped ? ' (skipped)' : ''}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(` movement lineage seed skipped: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Database migration complete.');
|
console.log('Database migration complete.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,581 @@
|
|||||||
|
/**
|
||||||
|
* Movement predecessor → successor lineage (timeline branches / Tree of Art).
|
||||||
|
* Curators manage rows with required proof URLs; defaults seed from scholarship.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const pool = require('./db');
|
||||||
|
|
||||||
|
/** @typedef {{ sourceName: string, targetName: string, linkYearApprox: number | null, proofUrl: string, proofLabel: string }} DefaultLink */
|
||||||
|
|
||||||
|
/** @type {ReadonlyArray<DefaultLink>} */
|
||||||
|
const DEFAULT_MOVEMENT_LINEAGE = Object.freeze([
|
||||||
|
{
|
||||||
|
sourceName: 'Ancient Greek & Roman',
|
||||||
|
targetName: 'Byzantine',
|
||||||
|
linkYearApprox: 330,
|
||||||
|
proofUrl: 'https://www.metmuseum.org/toah/hd/byza/hd_byza.htm',
|
||||||
|
proofLabel: 'Met — Byzantine art',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Byzantine',
|
||||||
|
targetName: 'Gothic',
|
||||||
|
linkYearApprox: 1150,
|
||||||
|
proofUrl: 'https://www.metmuseum.org/toah/hd/mgot/hd_mgot.htm',
|
||||||
|
proofLabel: 'Met — Gothic art',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Gothic',
|
||||||
|
targetName: 'Early Renaissance',
|
||||||
|
linkYearApprox: 1400,
|
||||||
|
proofUrl: 'https://www.metmuseum.org/toah/hd/elar/hd_elar.htm',
|
||||||
|
proofLabel: 'Met — Florence & Early Renaissance',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Northern Renaissance',
|
||||||
|
targetName: 'Early Renaissance',
|
||||||
|
linkYearApprox: 1420,
|
||||||
|
proofUrl: 'https://www.metmuseum.org/toah/hd/enet/hd_enet.htm',
|
||||||
|
proofLabel: 'Met — Early Netherlandish painting',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Early Renaissance',
|
||||||
|
targetName: 'High Renaissance',
|
||||||
|
linkYearApprox: 1490,
|
||||||
|
proofUrl: 'https://www.metmuseum.org/toah/hd/hiren/hd_hiren.htm',
|
||||||
|
proofLabel: 'Met — High Renaissance',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'High Renaissance',
|
||||||
|
targetName: 'Mannerism',
|
||||||
|
linkYearApprox: 1520,
|
||||||
|
proofUrl: 'https://www.metmuseum.org/toah/hd/zino/hd_zino.htm',
|
||||||
|
proofLabel: 'Met — Mannerism',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Mannerism',
|
||||||
|
targetName: 'Baroque',
|
||||||
|
linkYearApprox: 1600,
|
||||||
|
proofUrl: 'https://www.metmuseum.org/toah/hd/baro/hd_baro.htm',
|
||||||
|
proofLabel: 'Met — Baroque Rome',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Baroque',
|
||||||
|
targetName: 'Rococo',
|
||||||
|
linkYearApprox: 1700,
|
||||||
|
proofUrl: 'https://www.metmuseum.org/toah/ht/09/euwf.html',
|
||||||
|
proofLabel: 'Met — France 1600–1800 (Rococo)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Rococo',
|
||||||
|
targetName: 'Neoclassicism',
|
||||||
|
linkYearApprox: 1760,
|
||||||
|
proofUrl: 'https://www.metmuseum.org/toah/ht/09/euwf.html',
|
||||||
|
proofLabel: 'Met — France 1600–1800 (Neoclassicism)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Neoclassicism',
|
||||||
|
targetName: 'Romanticism',
|
||||||
|
linkYearApprox: 1800,
|
||||||
|
proofUrl: 'https://www.metmuseum.org/toah/ht/10/euwf.html',
|
||||||
|
proofLabel: 'Met — France 1800–1900',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Romanticism',
|
||||||
|
targetName: 'Realism',
|
||||||
|
linkYearApprox: 1840,
|
||||||
|
proofUrl: 'https://www.metmuseum.org/toah/hd/gust/hd_gust.htm',
|
||||||
|
proofLabel: 'Met — Gustave Courbet / Realism',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Romanticism',
|
||||||
|
targetName: 'Impressionism',
|
||||||
|
linkYearApprox: 1860,
|
||||||
|
proofUrl: 'https://www.theartstory.org/movement/impressionism/',
|
||||||
|
proofLabel: 'TheArtStory — Impressionism',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Realism',
|
||||||
|
targetName: 'Impressionism',
|
||||||
|
linkYearApprox: 1860,
|
||||||
|
proofUrl: 'https://www.metmuseum.org/toah/hd/imml/hd_imml.htm',
|
||||||
|
proofLabel: 'Met — Impressionism',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Impressionism',
|
||||||
|
targetName: 'Post-Impressionism',
|
||||||
|
linkYearApprox: 1880,
|
||||||
|
proofUrl: 'https://www.theartstory.org/movement/post-impressionism/',
|
||||||
|
proofLabel: 'TheArtStory — Post-Impressionism',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Post-Impressionism',
|
||||||
|
targetName: 'Symbolism',
|
||||||
|
linkYearApprox: 1880,
|
||||||
|
proofUrl: 'https://www.theartstory.org/movement/symbolism/',
|
||||||
|
proofLabel: 'TheArtStory — Symbolism',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Post-Impressionism',
|
||||||
|
targetName: 'Fauvism',
|
||||||
|
linkYearApprox: 1904,
|
||||||
|
proofUrl: 'https://www.theartstory.org/movement/fauvism/',
|
||||||
|
proofLabel: 'TheArtStory — Fauvism',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Post-Impressionism',
|
||||||
|
targetName: 'Expressionism',
|
||||||
|
linkYearApprox: 1905,
|
||||||
|
proofUrl: 'https://www.theartstory.org/movement/expressionism/',
|
||||||
|
proofLabel: 'TheArtStory — Expressionism',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Post-Impressionism',
|
||||||
|
targetName: 'Cubism',
|
||||||
|
linkYearApprox: 1907,
|
||||||
|
proofUrl: 'https://www.metmuseum.org/essays/cubism',
|
||||||
|
proofLabel: 'Met — Cubism',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Symbolism',
|
||||||
|
targetName: 'Art Nouveau',
|
||||||
|
linkYearApprox: 1890,
|
||||||
|
proofUrl: 'https://www.theartstory.org/movement/art-nouveau/',
|
||||||
|
proofLabel: 'TheArtStory — Art Nouveau',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Fauvism',
|
||||||
|
targetName: 'Cubism',
|
||||||
|
linkYearApprox: 1907,
|
||||||
|
proofUrl:
|
||||||
|
'https://www.christies.com/en/stories/from-fauvism-to-cubism-bc026df2c6774575938a4e26cacb8259',
|
||||||
|
proofLabel: "Christie's — From Fauvism to Cubism",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Cubism',
|
||||||
|
targetName: 'Futurism',
|
||||||
|
linkYearApprox: 1909,
|
||||||
|
proofUrl: 'https://www.theartstory.org/movement/futurism/',
|
||||||
|
proofLabel: 'TheArtStory — Futurism',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Cubism',
|
||||||
|
targetName: 'Suprematism',
|
||||||
|
linkYearApprox: 1913,
|
||||||
|
proofUrl: 'https://www.theartstory.org/movement/suprematism/',
|
||||||
|
proofLabel: 'TheArtStory — Suprematism',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Cubism',
|
||||||
|
targetName: 'Constructivism',
|
||||||
|
linkYearApprox: 1913,
|
||||||
|
proofUrl: 'https://www.theartstory.org/movement/constructivism/',
|
||||||
|
proofLabel: 'TheArtStory — Constructivism',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Expressionism',
|
||||||
|
targetName: 'Abstract Expressionism',
|
||||||
|
linkYearApprox: 1940,
|
||||||
|
proofUrl: 'https://www.theartstory.org/movement/abstract-expressionism/',
|
||||||
|
proofLabel: 'TheArtStory — Abstract Expressionism',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Dada',
|
||||||
|
targetName: 'Surrealism',
|
||||||
|
linkYearApprox: 1924,
|
||||||
|
proofUrl: 'https://www.theartstory.org/movement/dada/',
|
||||||
|
proofLabel: 'TheArtStory — Dada',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Surrealism',
|
||||||
|
targetName: 'Abstract Expressionism',
|
||||||
|
linkYearApprox: 1940,
|
||||||
|
proofUrl: 'https://www.theartstory.org/artist/breton-andre/',
|
||||||
|
proofLabel: 'TheArtStory — André Breton',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Abstract Expressionism',
|
||||||
|
targetName: 'Pop Art',
|
||||||
|
linkYearApprox: 1955,
|
||||||
|
proofUrl: 'https://www.theartstory.org/movement/pop-art/',
|
||||||
|
proofLabel: 'TheArtStory — Pop Art',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isValidProofUrl(value) {
|
||||||
|
if (typeof value !== 'string') return false;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
try {
|
||||||
|
const u = new URL(trimmed);
|
||||||
|
return u.protocol === 'http:' || u.protocol === 'https:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveId(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalYear(value) {
|
||||||
|
if (value == null || value === '') return null;
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return NaN;
|
||||||
|
return Math.trunc(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} body
|
||||||
|
* @returns {{ ok: true, value: object } | { ok: false, error: string }}
|
||||||
|
*/
|
||||||
|
function validateCreatePayload(body) {
|
||||||
|
const raw = body && typeof body === 'object' ? body : {};
|
||||||
|
const sourceMovementId = parsePositiveId(raw.sourceMovementId);
|
||||||
|
const targetMovementId = parsePositiveId(raw.targetMovementId);
|
||||||
|
if (!sourceMovementId) {
|
||||||
|
return { ok: false, error: 'sourceMovementId is required' };
|
||||||
|
}
|
||||||
|
if (!targetMovementId) {
|
||||||
|
return { ok: false, error: 'targetMovementId is required' };
|
||||||
|
}
|
||||||
|
if (sourceMovementId === targetMovementId) {
|
||||||
|
return { ok: false, error: 'source and target must be different movements' };
|
||||||
|
}
|
||||||
|
const proofUrlRaw = typeof raw.proofUrl === 'string' ? raw.proofUrl.trim() : '';
|
||||||
|
if (!isValidProofUrl(proofUrlRaw)) {
|
||||||
|
return { ok: false, error: 'proofUrl must be a valid http(s) URL (required)' };
|
||||||
|
}
|
||||||
|
const linkYearApprox = parseOptionalYear(raw.linkYearApprox);
|
||||||
|
if (Number.isNaN(linkYearApprox)) {
|
||||||
|
return { ok: false, error: 'linkYearApprox must be a number when provided' };
|
||||||
|
}
|
||||||
|
const proofLabel =
|
||||||
|
typeof raw.proofLabel === 'string' && raw.proofLabel.trim()
|
||||||
|
? raw.proofLabel.trim()
|
||||||
|
: null;
|
||||||
|
const notes =
|
||||||
|
typeof raw.notes === 'string' && raw.notes.trim() ? raw.notes.trim() : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
sourceMovementId,
|
||||||
|
targetMovementId,
|
||||||
|
proofUrl: proofUrlRaw,
|
||||||
|
proofLabel,
|
||||||
|
linkYearApprox,
|
||||||
|
notes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_DELETE_VOTE_REASON_LEN = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} body
|
||||||
|
* @returns {{ ok: true, value: { reason: string } } | { ok: false, error: string }}
|
||||||
|
*/
|
||||||
|
function validateDeleteVotePayload(body) {
|
||||||
|
const raw = body && typeof body === 'object' ? body : {};
|
||||||
|
const reason = typeof raw.reason === 'string' ? raw.reason.trim() : '';
|
||||||
|
if (!reason) {
|
||||||
|
return { ok: false, error: 'reason is required' };
|
||||||
|
}
|
||||||
|
if (reason.length < MIN_DELETE_VOTE_REASON_LEN) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `reason must be at least ${MIN_DELETE_VOTE_REASON_LEN} characters`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, value: { reason } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIST_SELECT = `
|
||||||
|
SELECT ml.id,
|
||||||
|
ml.source_movement_id,
|
||||||
|
ml.target_movement_id,
|
||||||
|
ml.link_year_approx,
|
||||||
|
ml.proof_url,
|
||||||
|
ml.proof_label,
|
||||||
|
ml.notes,
|
||||||
|
ml.created_at,
|
||||||
|
ml.updated_at,
|
||||||
|
s.name AS source_name,
|
||||||
|
s.start_year AS source_start_year,
|
||||||
|
t.name AS target_name,
|
||||||
|
t.start_year AS target_start_year,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)::int
|
||||||
|
FROM movement_lineage_delete_votes v
|
||||||
|
WHERE v.link_id = ml.id
|
||||||
|
) AS delete_vote_count
|
||||||
|
FROM movement_lineage_links ml
|
||||||
|
JOIN art_movements s ON s.id = ml.source_movement_id
|
||||||
|
JOIN art_movements t ON t.id = ml.target_movement_id
|
||||||
|
`;
|
||||||
|
|
||||||
|
async function listLinks({ q, limit = 500, offset = 0, viewerUserId = null } = {}) {
|
||||||
|
const params = [];
|
||||||
|
const where = [];
|
||||||
|
if (q && typeof q === 'string' && q.trim()) {
|
||||||
|
params.push(`%${q.trim()}%`);
|
||||||
|
const i = params.length;
|
||||||
|
where.push(`(
|
||||||
|
s.name ILIKE $${i} OR t.name ILIKE $${i}
|
||||||
|
OR ml.proof_url ILIKE $${i} OR COALESCE(ml.proof_label, '') ILIKE $${i}
|
||||||
|
OR COALESCE(ml.notes, '') ILIKE $${i}
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||||
|
params.push(Math.min(1000, Math.max(1, Number(limit) || 500)));
|
||||||
|
const limitIdx = params.length;
|
||||||
|
params.push(Math.max(0, Number(offset) || 0));
|
||||||
|
const offsetIdx = params.length;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`${LIST_SELECT}
|
||||||
|
${whereSql}
|
||||||
|
ORDER BY COALESCE(ml.link_year_approx, t.start_year) ASC NULLS LAST, s.name, t.name
|
||||||
|
LIMIT $${limitIdx} OFFSET $${offsetIdx}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
const countParams = params.slice(0, -2);
|
||||||
|
const { rows: countRows } = await pool.query(
|
||||||
|
`SELECT COUNT(*)::int AS n
|
||||||
|
FROM movement_lineage_links ml
|
||||||
|
JOIN art_movements s ON s.id = ml.source_movement_id
|
||||||
|
JOIN art_movements t ON t.id = ml.target_movement_id
|
||||||
|
${whereSql}`,
|
||||||
|
countParams,
|
||||||
|
);
|
||||||
|
|
||||||
|
let items = rows;
|
||||||
|
if (viewerUserId && rows.length) {
|
||||||
|
const ids = rows.map((r) => r.id);
|
||||||
|
const { rows: myVotes } = await pool.query(
|
||||||
|
`SELECT link_id FROM movement_lineage_delete_votes
|
||||||
|
WHERE user_id = $1 AND link_id = ANY($2::int[])`,
|
||||||
|
[viewerUserId, ids],
|
||||||
|
);
|
||||||
|
const voted = new Set(myVotes.map((v) => v.link_id));
|
||||||
|
items = rows.map((r) => ({
|
||||||
|
...r,
|
||||||
|
viewer_has_delete_vote: voted.has(r.id),
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
items = rows.map((r) => ({ ...r, viewer_has_delete_vote: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items, total: countRows[0]?.n ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createLink(payload, { userId } = {}) {
|
||||||
|
const validated = validateCreatePayload(payload);
|
||||||
|
if (!validated.ok) {
|
||||||
|
const err = new Error(validated.error);
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const v = validated.value;
|
||||||
|
|
||||||
|
const movements = await pool.query(
|
||||||
|
`SELECT id FROM art_movements WHERE id = ANY($1::int[])`,
|
||||||
|
[[v.sourceMovementId, v.targetMovementId]],
|
||||||
|
);
|
||||||
|
if (movements.rows.length !== 2) {
|
||||||
|
const err = new Error('source or target movement not found');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO movement_lineage_links
|
||||||
|
(source_movement_id, target_movement_id, link_year_approx, proof_url, proof_label, notes, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
v.sourceMovementId,
|
||||||
|
v.targetMovementId,
|
||||||
|
v.linkYearApprox,
|
||||||
|
v.proofUrl,
|
||||||
|
v.proofLabel,
|
||||||
|
v.notes,
|
||||||
|
userId || null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const { rows: full } = await pool.query(`${LIST_SELECT} WHERE ml.id = $1`, [rows[0].id]);
|
||||||
|
return full[0];
|
||||||
|
} catch (e) {
|
||||||
|
if (e && e.code === '23505') {
|
||||||
|
const err = new Error('A link between these movements already exists');
|
||||||
|
err.status = 409;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteLink(id) {
|
||||||
|
const linkId = parsePositiveId(id);
|
||||||
|
if (!linkId) {
|
||||||
|
const err = new Error('Invalid link id');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`DELETE FROM movement_lineage_links WHERE id = $1 RETURNING id`,
|
||||||
|
[linkId],
|
||||||
|
);
|
||||||
|
if (!rows.length) {
|
||||||
|
const err = new Error('Link not found');
|
||||||
|
err.status = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return { id: linkId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listDeleteVotes(linkId) {
|
||||||
|
const id = parsePositiveId(linkId);
|
||||||
|
if (!id) {
|
||||||
|
const err = new Error('Invalid link id');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const { rows: linkRows } = await pool.query(
|
||||||
|
`SELECT id FROM movement_lineage_links WHERE id = $1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (!linkRows.length) {
|
||||||
|
const err = new Error('Link not found');
|
||||||
|
err.status = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT v.id, v.link_id, v.user_id, v.reason, v.created_at, u.username
|
||||||
|
FROM movement_lineage_delete_votes v
|
||||||
|
JOIN users u ON u.id = v.user_id
|
||||||
|
WHERE v.link_id = $1
|
||||||
|
ORDER BY v.created_at ASC`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
return { linkId: id, votes: rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function castDeleteVote(linkId, body, { userId }) {
|
||||||
|
const id = parsePositiveId(linkId);
|
||||||
|
if (!id) {
|
||||||
|
const err = new Error('Invalid link id');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (!userId) {
|
||||||
|
const err = new Error('Curator login required');
|
||||||
|
err.status = 401;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const validated = validateDeleteVotePayload(body);
|
||||||
|
if (!validated.ok) {
|
||||||
|
const err = new Error(validated.error);
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows: linkRows } = await pool.query(
|
||||||
|
`SELECT id FROM movement_lineage_links WHERE id = $1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (!linkRows.length) {
|
||||||
|
const err = new Error('Link not found');
|
||||||
|
err.status = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO movement_lineage_delete_votes (link_id, user_id, reason)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, link_id, user_id, reason, created_at`,
|
||||||
|
[id, userId, validated.value.reason],
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
} catch (e) {
|
||||||
|
if (e && e.code === '23505') {
|
||||||
|
const err = new Error('You already voted to delete this link');
|
||||||
|
err.status = 409;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withdrawDeleteVote(linkId, { userId }) {
|
||||||
|
const id = parsePositiveId(linkId);
|
||||||
|
if (!id || !userId) {
|
||||||
|
const err = new Error('Invalid request');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`DELETE FROM movement_lineage_delete_votes
|
||||||
|
WHERE link_id = $1 AND user_id = $2
|
||||||
|
RETURNING id`,
|
||||||
|
[id, userId],
|
||||||
|
);
|
||||||
|
if (!rows.length) {
|
||||||
|
const err = new Error('Vote not found');
|
||||||
|
err.status = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return { id: rows[0].id, linkId: id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert default scholarship links when the table is empty and movements exist. */
|
||||||
|
async function seedDefaultsIfEmpty() {
|
||||||
|
const { rows: countRows } = await pool.query(
|
||||||
|
`SELECT COUNT(*)::int AS n FROM movement_lineage_links`,
|
||||||
|
);
|
||||||
|
if ((countRows[0]?.n ?? 0) > 0) return { inserted: 0, skipped: true };
|
||||||
|
|
||||||
|
const { rows: movements } = await pool.query(`SELECT id, name FROM art_movements`);
|
||||||
|
if (!movements.length) return { inserted: 0, skipped: true, reason: 'no movements' };
|
||||||
|
|
||||||
|
const byName = new Map(movements.map((m) => [m.name, m.id]));
|
||||||
|
let inserted = 0;
|
||||||
|
let missing = 0;
|
||||||
|
for (const row of DEFAULT_MOVEMENT_LINEAGE) {
|
||||||
|
const sourceId = byName.get(row.sourceName);
|
||||||
|
const targetId = byName.get(row.targetName);
|
||||||
|
if (!sourceId || !targetId) {
|
||||||
|
missing += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO movement_lineage_links
|
||||||
|
(source_movement_id, target_movement_id, link_year_approx, proof_url, proof_label)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (source_movement_id, target_movement_id) DO NOTHING
|
||||||
|
RETURNING id`,
|
||||||
|
[sourceId, targetId, row.linkYearApprox, row.proofUrl, row.proofLabel],
|
||||||
|
);
|
||||||
|
if (result.rows.length) inserted += 1;
|
||||||
|
}
|
||||||
|
return { inserted, missing };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DEFAULT_MOVEMENT_LINEAGE,
|
||||||
|
MIN_DELETE_VOTE_REASON_LEN,
|
||||||
|
isValidProofUrl,
|
||||||
|
validateCreatePayload,
|
||||||
|
validateDeleteVotePayload,
|
||||||
|
listLinks,
|
||||||
|
createLink,
|
||||||
|
deleteLink,
|
||||||
|
listDeleteVotes,
|
||||||
|
castDeleteVote,
|
||||||
|
withdrawDeleteVote,
|
||||||
|
seedDefaultsIfEmpty,
|
||||||
|
};
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { requirePermission, requireAdmin } = require('../middleware/auth');
|
||||||
|
const { logCuratorAction } = require('../audit-log');
|
||||||
|
const {
|
||||||
|
listLinks,
|
||||||
|
createLink,
|
||||||
|
deleteLink,
|
||||||
|
listDeleteVotes,
|
||||||
|
castDeleteVote,
|
||||||
|
withdrawDeleteVote,
|
||||||
|
seedDefaultsIfEmpty,
|
||||||
|
} = require('../movement-lineage-service');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/** Optional staff session for vote annotations on list (public otherwise). */
|
||||||
|
async function optionalStaff(req, _res, next) {
|
||||||
|
try {
|
||||||
|
if (req.session?.userId) {
|
||||||
|
const { loadStaffUser } = require('../middleware/auth');
|
||||||
|
const user = await loadStaffUser(req.session.userId);
|
||||||
|
if (user && user.is_active) {
|
||||||
|
req.curatorUser = user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore — list stays public */
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public: timeline / tree consumers. */
|
||||||
|
router.get('/', optionalStaff, async (req, res) => {
|
||||||
|
try {
|
||||||
|
await seedDefaultsIfEmpty();
|
||||||
|
const q = typeof req.query.q === 'string' ? req.query.q : '';
|
||||||
|
const limit = Number(req.query.limit) || 500;
|
||||||
|
const offset = Number(req.query.offset) || 0;
|
||||||
|
const data = await listLinks({
|
||||||
|
q,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
viewerUserId: req.curatorUser?.id || null,
|
||||||
|
});
|
||||||
|
res.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to list movement links' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', requirePermission('influences'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const item = await createLink(req.body || {}, { userId: req.session?.userId });
|
||||||
|
await logCuratorAction({
|
||||||
|
userId: req.session.userId,
|
||||||
|
action: 'movement_link.create',
|
||||||
|
resourceType: 'movement_lineage_link',
|
||||||
|
resourceId: item.id,
|
||||||
|
details: {
|
||||||
|
source_movement_id: item.source_movement_id,
|
||||||
|
target_movement_id: item.target_movement_id,
|
||||||
|
proof_url: item.proof_url,
|
||||||
|
},
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
res.status(201).json({ item });
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.status || 500;
|
||||||
|
if (status >= 500) console.error(err);
|
||||||
|
res.status(status).json({ error: err.message || 'Failed to create movement link' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id/delete-votes', requirePermission('influences'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = await listDeleteVotes(req.params.id);
|
||||||
|
res.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.status || 500;
|
||||||
|
if (status >= 500) console.error(err);
|
||||||
|
res.status(status).json({ error: err.message || 'Failed to list delete votes' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/delete-votes', requirePermission('influences'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const vote = await castDeleteVote(req.params.id, req.body || {}, {
|
||||||
|
userId: req.session.userId,
|
||||||
|
});
|
||||||
|
await logCuratorAction({
|
||||||
|
userId: req.session.userId,
|
||||||
|
action: 'movement_link.delete_vote',
|
||||||
|
resourceType: 'movement_lineage_link',
|
||||||
|
resourceId: Number(req.params.id),
|
||||||
|
details: { reason: vote.reason },
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
res.status(201).json({ vote });
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.status || 500;
|
||||||
|
if (status >= 500) console.error(err);
|
||||||
|
res.status(status).json({ error: err.message || 'Failed to cast delete vote' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id/delete-votes/me', requirePermission('influences'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await withdrawDeleteVote(req.params.id, { userId: req.session.userId });
|
||||||
|
await logCuratorAction({
|
||||||
|
userId: req.session.userId,
|
||||||
|
action: 'movement_link.delete_vote_withdraw',
|
||||||
|
resourceType: 'movement_lineage_link',
|
||||||
|
resourceId: result.linkId,
|
||||||
|
details: {},
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
res.json({ ok: true, ...result });
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.status || 500;
|
||||||
|
if (status >= 500) console.error(err);
|
||||||
|
res.status(status).json({ error: err.message || 'Failed to withdraw delete vote' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Hard delete — admin only. Curators vote instead. */
|
||||||
|
router.delete('/:id', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const votes = await listDeleteVotes(req.params.id).catch(() => ({ votes: [] }));
|
||||||
|
const result = await deleteLink(req.params.id);
|
||||||
|
await logCuratorAction({
|
||||||
|
userId: req.session.userId,
|
||||||
|
action: 'movement_link.delete',
|
||||||
|
resourceType: 'movement_lineage_link',
|
||||||
|
resourceId: result.id,
|
||||||
|
details: {
|
||||||
|
prior_votes: (votes.votes || []).map((v) => ({
|
||||||
|
username: v.username,
|
||||||
|
reason: v.reason,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
res.json({ ok: true, id: result.id });
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.status || 500;
|
||||||
|
if (status >= 500) console.error(err);
|
||||||
|
res.status(status).json({ error: err.message || 'Failed to delete movement link' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
Reference in new issue
Block a user