From 1b760faf0de79467c62325c90ff4262ab4bfd995 Mon Sep 17 00:00:00 2001 From: Danila Khodjaef Date: Tue, 8 Sep 2026 14:37:20 +0300 Subject: [PATCH] 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 --- Documentation/API.md | 22 +- Documentation/DB_structure.md | 32 + Documentation/FAC.md | 8 +- Documentation/Plans.md | 1 + Documentation/basics.md | 9 +- Documentation/data-and-images.md | 19 +- Documentation/influence-import.md | 17 + Documentation/movement-tree.md | 8 +- Documentation/setup.md | 2 +- client/src/api/client.ts | 102 +++ client/src/components/MovementBands.tsx | 4 +- .../src/components/VerticalMovementBands.tsx | 4 +- client/src/data/movement-lineage.ts | 47 +- client/src/i18n/index.ts | 6 +- client/src/locales/en/home.json | 1 + client/src/locales/en/influences.json | 1 + client/src/locales/en/movementLinks.json | 52 ++ client/src/locales/en/users.json | 2 +- client/src/locales/ru/home.json | 1 + client/src/locales/ru/influences.json | 1 + client/src/locales/ru/movementLinks.json | 52 ++ client/src/locales/ru/users.json | 2 +- client/src/pages/HomePage.css | 4 + client/src/pages/HomePage.tsx | 53 +- client/src/pages/InfluencesPage.tsx | 8 +- client/src/pages/MovementLinksPage.css | 265 ++++++++ client/src/pages/MovementLinksPage.tsx | 439 +++++++++++++ client/src/utils/movementTree.ts | 4 +- db/migrate-movement-link-delete-votes.sql | 16 + db/migrate-movement-links.sql | 37 ++ package.json | 1 + .../movement-lineage-service.test.js | 129 ++++ server/index.js | 1 + server/migrate.js | 11 + server/movement-lineage-service.js | 581 ++++++++++++++++++ server/routes/movement-links.js | 152 +++++ 36 files changed, 2051 insertions(+), 43 deletions(-) create mode 100644 client/src/locales/en/movementLinks.json create mode 100644 client/src/locales/ru/movementLinks.json create mode 100644 client/src/pages/MovementLinksPage.css create mode 100644 client/src/pages/MovementLinksPage.tsx create mode 100644 db/migrate-movement-link-delete-votes.sql create mode 100644 db/migrate-movement-links.sql create mode 100644 server/__tests__/movement-lineage-service.test.js create mode 100644 server/movement-lineage-service.js create mode 100644 server/routes/movement-links.js diff --git a/Documentation/API.md b/Documentation/API.md index ff8dc3c..1a766c3 100644 --- a/Documentation/API.md +++ b/Documentation/API.md @@ -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` | | `/api/translations/*` | `translations` | `translation.*` | | `/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.*` | | `/api/users/*` | `users` | `user.*` | | `/api/audit/*` | admin role | — (read) | -**Public** (no login): all catalog `GET` routes, `POST /api/artists/:id/preload-images`, `POST /api/movements/:id/preload-images` (local file linking for 3D halls), `/images`, SPA static. +**Public** (no login): all catalog `GET` routes, `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`. @@ -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 Base path: `/api/tours`. Full guide: [tours.md](tours.md). diff --git a/Documentation/DB_structure.md b/Documentation/DB_structure.md index 6335d2c..f0bb5c9 100644 --- a/Documentation/DB_structure.md +++ b/Documentation/DB_structure.md @@ -33,6 +33,8 @@ erDiagram paintings ||--o{ painting_influence_sources : influenced_by artists ||--o{ painting_influence_sources : artist_source art_movements ||--o{ painting_influence_sources : movement_source + art_movements ||--o{ movement_lineage_links : source + art_movements ||--o{ movement_lineage_links : target ``` ## 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`. +### `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` Staff accounts (named logins). Anonymous site visitors do not have rows here. Migration: `db/migrate-auth.sql` + `db/migrate-user-roles.sql`. diff --git a/Documentation/FAC.md b/Documentation/FAC.md index 86f39de..74d3530 100644 --- a/Documentation/FAC.md +++ b/Documentation/FAC.md @@ -95,7 +95,7 @@ CURATOR_USERNAME=curator CURATOR_PASSWORD=your-secure-password ``` -Then open the gallery → **Curator login** (top-right) → use tools allowed by your role/permissions (debug, Checkup, Translations, Influences, Tour editor, **Users**, **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**). @@ -103,9 +103,9 @@ If login fails after changing `CURATOR_PASSWORD` in `.env`, run `npm run dev:res | Role | Access | |------|--------| -| Guest (`user`) | Timeline, movement flow, 3D halls, painting detail, bios | -| Curator | Public browse + assigned permission flags (`images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`) | -| Admin | All curator tools + **Users** + **Activity** audit reports | +| 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`). With `influences`: Influences + Movement links create/vote-to-delete | +| 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. diff --git a/Documentation/Plans.md b/Documentation/Plans.md index ac7fb27..676d8cc 100644 --- a/Documentation/Plans.md +++ b/Documentation/Plans.md @@ -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) 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) +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) diff --git a/Documentation/basics.md b/Documentation/basics.md index f014b5b..c633f2e 100644 --- a/Documentation/basics.md +++ b/Documentation/basics.md @@ -56,6 +56,7 @@ Gallery/ │ │ ├── pages/CheckupPage.tsx # Image audit table │ │ ├── pages/TranslationsPage.tsx # Russian translation review │ │ ├── pages/InfluencesPage.tsx # Influence links CRUD + import wizard +│ │ ├── pages/MovementLinksPage.tsx # Movement lineage CRUD + delete votes │ │ ├── pages/ToursPage.tsx # Guided tour editor │ │ ├── pages/UsersPage.tsx # Staff accounts │ │ ├── pages/AuditPage.tsx # Admin curator activity reports @@ -63,7 +64,7 @@ Gallery/ │ │ ├── i18n/ # react-i18next bootstrap │ │ ├── locales/{en,ru}/ # UI chrome strings (incl. timeline layout + captions) │ │ ├── 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/debugMode.ts # Debug mode + “Show more” localStorage prefs │ │ ├── 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 | |---------|----------------| -| 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 | | 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 | @@ -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. -**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) @@ -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()`. - **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 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. - **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). diff --git a/Documentation/data-and-images.md b/Documentation/data-and-images.md index 62ee326..da87cee 100644 --- a/Documentation/data-and-images.md +++ b/Documentation/data-and-images.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. -## 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 | |--------|--------| -| Storage | TypeScript module in the client — **not** a database table | -| Format | `[parentMovementName, childMovementName]` tuples; names must match `art_movements.name` from the seed | -| Multiple parents | Allowed (e.g. Post-Impressionism feeding several modern paths) | -| Sources | Curator notes in the file reference Met essays, TheArtStory, and similar | +| Storage | PostgreSQL `movement_lineage_links` (`db/migrate-movement-links.sql`) | +| Required proof | `proof_url` must be a valid `http(s)` URL (museum essay, literature, web reference) | +| Optional | `link_year_approx`, `proof_label`, `notes` | +| 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) diff --git a/Documentation/influence-import.md b/Documentation/influence-import.md index 61610da..773c822 100644 --- a/Documentation/influence-import.md +++ b/Documentation/influence-import.md @@ -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 - `npm run dev:update-influences` — curated [`scripts/art-influences-data.js`](../scripts/art-influences-data.js) diff --git a/Documentation/movement-tree.md b/Documentation/movement-tree.md index 55f4223..4d209da 100644 --- a/Documentation/movement-tree.md +++ b/Documentation/movement-tree.md @@ -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 | | `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 -[`client/src/data/movement-lineage.ts`](../client/src/data/movement-lineage.ts) — the tree -picks up parents, grafts, thickness and spacing from there automatically. +New lineage edges: top-left **Movement links** (required proof URL) → +`movement_lineage_links` / `GET /api/movement-links`. Curators vote to delete; +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. diff --git a/Documentation/setup.md b/Documentation/setup.md index 60de1a3..1d2fa9b 100644 --- a/Documentation/setup.md +++ b/Documentation/setup.md @@ -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`. -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). diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 2df78db..69fb07a 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -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 }) => { const qs = new URLSearchParams(); if (params.artistId) qs.set('artistId', String(params.artistId)); @@ -1032,6 +1107,33 @@ export interface InfluenceEdgeItem { 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 { focus: { artistId: number; paintingId: number | null; label: string }; nodes: Array<{ id: string; type: string; label: string; focus?: boolean; artistId?: number; paintingId?: number; movementId?: number }>; diff --git a/client/src/components/MovementBands.tsx b/client/src/components/MovementBands.tsx index c3c3bb8..e55cc1a 100644 --- a/client/src/components/MovementBands.tsx +++ b/client/src/components/MovementBands.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import type { ArtMovement, Artist } from '../types'; import { portraitThumbUrl } from '../api/client'; 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 { parseHexColor, rgbToHsl, hslToHex, vividMovementColor } from '../utils/movementColor'; import './MovementBands.css'; @@ -269,7 +269,7 @@ function buildLineageParentMap( const visibleIds = new Set(visible.map((m) => m.id)); const childToParents = new Map(); - for (const [parentName, childName] of MOVEMENT_LINEAGE) { + for (const [parentName, childName] of getMovementLineage()) { const parentId = nameToId.get(parentName); const childId = nameToId.get(childName); if (!parentId || !childId) continue; diff --git a/client/src/components/VerticalMovementBands.tsx b/client/src/components/VerticalMovementBands.tsx index 03c3ef1..4fe5730 100644 --- a/client/src/components/VerticalMovementBands.tsx +++ b/client/src/components/VerticalMovementBands.tsx @@ -1,7 +1,7 @@ import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; 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 { vividMovementColor } from '../utils/movementColor'; import './VerticalMovementBands.css'; @@ -59,7 +59,7 @@ function buildLineageParentMap( ): Map { const parents = new Map(); 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 childId = nameToId.get(childName); if (parentId == null || childId == null) continue; diff --git a/client/src/data/movement-lineage.ts b/client/src/data/movement-lineage.ts index 2f80426..01515b2 100644 --- a/client/src/data/movement-lineage.ts +++ b/client/src/data/movement-lineage.ts @@ -1,10 +1,12 @@ /** - * Documented art-historical movement lineage (predecessor → successor). - * Sources: Met Museum essays, TheArtStory, Sotheby's movement guides. - * + * Art-historical movement lineage (predecessor → successor). + * Defaults match museum essays; live data comes from GET /api/movement-links. * Multiple parents are allowed (e.g. Post-Impressionism feeding several modern paths). */ -export const MOVEMENT_LINEAGE: ReadonlyArray = [ + +export type MovementLineageEdge = readonly [parent: string, child: string]; + +export const FALLBACK_MOVEMENT_LINEAGE: ReadonlyArray = [ // Classical & medieval ['Ancient Greek & Roman', 'Byzantine'], ['Byzantine', 'Gothic'], @@ -21,17 +23,17 @@ export const MOVEMENT_LINEAGE: ReadonlyArray = FALLBACK_MOVEMENT_LINEAGE; + +/** Active edges for timeline / tree layout (API-backed when loaded). */ +export function getMovementLineage(): ReadonlyArray { + 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[] { - 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[] { - return MOVEMENT_LINEAGE.filter(([parent]) => parent === parentName).map(([, child]) => child); + return getMovementLineage() + .filter(([parent]) => parent === parentName) + .map(([, child]) => child); } diff --git a/client/src/i18n/index.ts b/client/src/i18n/index.ts index 3c5dc30..9a28793 100644 --- a/client/src/i18n/index.ts +++ b/client/src/i18n/index.ts @@ -12,6 +12,7 @@ import enAnnotations from '../locales/en/annotations.json'; import enDebug from '../locales/en/debug.json'; import enTranslations from '../locales/en/translations.json'; import enInfluences from '../locales/en/influences.json'; +import enMovementLinks from '../locales/en/movementLinks.json'; import enTours from '../locales/en/tours.json'; import enUsers from '../locales/en/users.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 ruTranslations from '../locales/ru/translations.json'; import ruInfluences from '../locales/ru/influences.json'; +import ruMovementLinks from '../locales/ru/movementLinks.json'; import ruTours from '../locales/ru/tours.json'; import ruUsers from '../locales/ru/users.json'; import ruAudit from '../locales/ru/audit.json'; @@ -37,7 +39,7 @@ void i18n.use(initReactI18next).init({ lng: initialLocale, fallbackLng: 'en', supportedLngs: ['en', 'ru'], - ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences', 'tours', 'users', 'audit'], + ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences', 'movementLinks', 'tours', 'users', 'audit'], defaultNS: 'common', resources: { en: { @@ -51,6 +53,7 @@ void i18n.use(initReactI18next).init({ debug: enDebug, translations: enTranslations, influences: enInfluences, + movementLinks: enMovementLinks, tours: enTours, users: enUsers, audit: enAudit, @@ -66,6 +69,7 @@ void i18n.use(initReactI18next).init({ debug: ruDebug, translations: ruTranslations, influences: ruInfluences, + movementLinks: ruMovementLinks, tours: ruTours, users: ruUsers, audit: ruAudit, diff --git a/client/src/locales/en/home.json b/client/src/locales/en/home.json index 72d4c35..c150107 100644 --- a/client/src/locales/en/home.json +++ b/client/src/locales/en/home.json @@ -15,6 +15,7 @@ "checkup": "Checkup", "translations": "Translations", "influences": "Influences", + "movementLinks": "Movement links", "tours": "Tours", "toursEditor": "Tour editor", "users": "Users", diff --git a/client/src/locales/en/influences.json b/client/src/locales/en/influences.json index 04478ea..0f5ba53 100644 --- a/client/src/locales/en/influences.json +++ b/client/src/locales/en/influences.json @@ -1,6 +1,7 @@ { "title": "Influence links", "back": "← Back to gallery", + "openMovementLinks": "Movement links →", "tabList": "List", "tabImport": "Import", "tabGraph": "Graph", diff --git a/client/src/locales/en/movementLinks.json b/client/src/locales/en/movementLinks.json new file mode 100644 index 0000000..9c74e6f --- /dev/null +++ b/client/src/locales/en/movementLinks.json @@ -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." +} diff --git a/client/src/locales/en/users.json b/client/src/locales/en/users.json index 6146e0f..bb55be0 100644 --- a/client/src/locales/en/users.json +++ b/client/src/locales/en/users.json @@ -25,7 +25,7 @@ "perm_checkup": "Checkup", "perm_curator_notes": "Curator notes", "perm_translations": "Translations", - "perm_influences": "Influences", + "perm_influences": "Influences & movement links", "perm_tours": "Tours", "perm_users": "Users", "adminAllPerms": "Admins have all permissions automatically.", diff --git a/client/src/locales/ru/home.json b/client/src/locales/ru/home.json index 16d248e..2e5a8d5 100644 --- a/client/src/locales/ru/home.json +++ b/client/src/locales/ru/home.json @@ -15,6 +15,7 @@ "checkup": "Проверка", "translations": "Переводы", "influences": "Влияния", + "movementLinks": "Связи движений", "tours": "Экскурсии", "toursEditor": "Редактор экскурсий", "users": "Пользователи", diff --git a/client/src/locales/ru/influences.json b/client/src/locales/ru/influences.json index 5ef8081..bd1984c 100644 --- a/client/src/locales/ru/influences.json +++ b/client/src/locales/ru/influences.json @@ -1,6 +1,7 @@ { "title": "Связи влияния", "back": "← Назад в галерею", + "openMovementLinks": "Связи движений →", "tabList": "Список", "tabImport": "Импорт", "tabGraph": "Граф", diff --git a/client/src/locales/ru/movementLinks.json b/client/src/locales/ru/movementLinks.json new file mode 100644 index 0000000..0be6fe7 --- /dev/null +++ b/client/src/locales/ru/movementLinks.json @@ -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": "Удалить эту связь движений? Причины голосов кураторов будут записаны в журнал аудита." +} diff --git a/client/src/locales/ru/users.json b/client/src/locales/ru/users.json index 1c36299..debd93d 100644 --- a/client/src/locales/ru/users.json +++ b/client/src/locales/ru/users.json @@ -25,7 +25,7 @@ "perm_checkup": "Проверка", "perm_curator_notes": "Заметки куратора", "perm_translations": "Переводы", - "perm_influences": "Влияния", + "perm_influences": "Влияния и связи движений", "perm_tours": "Экскурсии", "perm_users": "Пользователи", "adminAllPerms": "У администраторов все права автоматически.", diff --git a/client/src/pages/HomePage.css b/client/src/pages/HomePage.css index 2dcec01..2faad91 100644 --- a/client/src/pages/HomePage.css +++ b/client/src/pages/HomePage.css @@ -116,8 +116,12 @@ top: 16px; right: 16px; display: flex; + flex-wrap: wrap; + justify-content: flex-end; gap: 8px; align-items: center; + max-width: min(780px, calc(100vw - 24px)); + z-index: 120; } .debug-mode-toggle, diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx index bc433c1..0fc7d95 100644 --- a/client/src/pages/HomePage.tsx +++ b/client/src/pages/HomePage.tsx @@ -12,6 +12,7 @@ import ArtistBio from '../components/ArtistBio'; import CheckupPage from '../pages/CheckupPage'; import TranslationsPage from '../pages/TranslationsPage'; import InfluencesPage from '../pages/InfluencesPage'; +import MovementLinksPage from '../pages/MovementLinksPage'; import ToursPage from '../pages/ToursPage'; import UsersPage from '../pages/UsersPage'; import AuditPage from '../pages/AuditPage'; @@ -28,11 +29,13 @@ import '../components/ToursPopup.css'; import '../components/LocaleSwitcher.css'; import '../pages/TranslationsPage.css'; import '../pages/InfluencesPage.css'; +import '../pages/MovementLinksPage.css'; import '../pages/ToursPage.css'; import '../pages/UsersPage.css'; import '../pages/AuditPage.css'; import { useAuth } from '../context/AuthContext'; import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client'; +import { setMovementLineageFromApi } from '../data/movement-lineage'; import type { TimelineData, Artist, @@ -55,6 +58,7 @@ type View = | { type: 'checkup' } | { type: 'translations' } | { type: 'influences' } + | { type: 'movement-links' } | { type: 'tours' } | { type: 'users' } | { type: 'audit' } @@ -69,7 +73,7 @@ type GallerySession = | { kind: 'movement'; movementId: number; data: MovementGalleryDetail } | { 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( detail: MovementGalleryDetail, @@ -259,9 +263,14 @@ export default function HomePage() { (async () => { try { setLoading(true); - const catalog = await api.getCatalogBootstrap(); + const [catalog, lineage] = await Promise.all([ + api.getCatalogBootstrap(), + api.listMovementLinks({ limit: 500 }).catch(() => ({ items: [] as Awaited>['items'] })), + ]); if (cancelled) return; + setMovementLineageFromApi(lineage.items); + const min = catalog.bounds.min_year ?? -800; const max = catalog.bounds.max_year ?? 2025; setBounds({ min, max }); @@ -363,6 +372,8 @@ export default function HomePage() { setView({ type: 'translations' }); } else if (loginRedirect === 'influences') { setView({ type: 'influences' }); + } else if (loginRedirect === 'movement-links') { + setView({ type: 'movement-links' }); } else if (loginRedirect === 'tours') { setView({ type: 'tours' }); } else if (loginRedirect === 'users') { @@ -381,6 +392,7 @@ export default function HomePage() { view.type === 'checkup' || view.type === 'translations' || view.type === 'influences' || + view.type === 'movement-links' || view.type === 'tours' || view.type === 'users' || view.type === 'audit' @@ -413,6 +425,14 @@ export default function HomePage() { setView({ type: 'influences' }); }; + const openMovementLinks = () => { + if (!canInfluences) { + openCuratorLogin('movement-links'); + return; + } + setView({ type: 'movement-links' }); + }; + const openToursEditor = () => { if (!canTours) { openCuratorLogin('tours'); @@ -1128,7 +1148,7 @@ export default function HomePage() { {view.type === 'influences' && ( canInfluences ? ( - + ) : (

{t('curatorRequiredTitle')}

@@ -1145,6 +1165,25 @@ export default function HomePage() { ) )} + {view.type === 'movement-links' && ( + canInfluences ? ( + + ) : ( +
+

{t('curatorRequiredTitle')}

+

{t('curatorRequiredBody')}

+
+ + +
+
+ ) + )} + {view.type === 'tours' && ( canTours ? ( @@ -1273,6 +1312,14 @@ export default function HomePage() {
+ {!isTreeTimeline && (

{t('title')}

+ {onOpenMovementLinks && ( + + )}
+ +

{t('intro')}

+

+ {isAdmin ? t('introAdmin') : t('introCurator')} +

+ + {error &&
{error}
} + + {voteLinkId != null && ( +
+

{t('voteTitle')}

+