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:
Danila KhodjaefandCursor committed 2026-09-08 14:37:20 +03:00
1 parent 2b4cdc5a9f
commit 1b760faf0d
36 files changed
+2051 -43

No files matched your search

+21 -1
View File
@@ -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).
+32
View File
@@ -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`.
+4 -4
View File
@@ -95,7 +95,7 @@ CURATOR_USERNAME=curator
CURATOR_PASSWORD=your-secure-password
```
Then open the gallery → **Curator login** (top-right) → use tools allowed by your role/permissions (debug, Checkup, Translations, Influences, Tour editor, **Users**, **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.
+1
View File
@@ -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)
+5 -4
View File
@@ -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).
+12 -7
View File
@@ -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)
+17
View File
@@ -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)
+5 -3
View File
@@ -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.
+1 -1
View File
@@ -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).
+102
View File
@@ -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 }>;
+2 -2
View File
@@ -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<number, number[]>();
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;
@@ -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<number, number[]> {
const parents = new Map<number, number[]>();
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;
+37 -10
View File
@@ -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<readonly [parent: string, child: string]> = [
export type MovementLineageEdge = readonly [parent: string, child: string];
export const FALLBACK_MOVEMENT_LINEAGE: ReadonlyArray<MovementLineageEdge> = [
// Classical & medieval
['Ancient Greek & Roman', 'Byzantine'],
['Byzantine', 'Gothic'],
@@ -21,17 +23,17 @@ export const MOVEMENT_LINEAGE: ReadonlyArray<readonly [parent: string, child: st
['Rococo', 'Neoclassicism'],
['Neoclassicism', 'Romanticism'],
['Romanticism', 'Realism'],
['Romanticism', 'Impressionism'], // Turner, Delacroix → light and atmosphere
['Romanticism', 'Impressionism'],
['Realism', 'Impressionism'],
// Modern origins (Met / ArtStory: Impressionism → Post-Impressionism → …)
// Modern origins
['Impressionism', 'Post-Impressionism'],
['Post-Impressionism', 'Symbolism'],
['Post-Impressionism', 'Fauvism'],
['Post-Impressionism', 'Expressionism'],
['Post-Impressionism', 'Cubism'], // Cézanne's structure → Picasso/Braque
['Post-Impressionism', 'Cubism'],
['Symbolism', 'Art Nouveau'],
['Fauvism', 'Cubism'], // Braque, Matisse → geometric turn
['Fauvism', 'Cubism'],
['Cubism', 'Futurism'],
['Cubism', 'Suprematism'],
['Cubism', 'Constructivism'],
@@ -43,10 +45,35 @@ export const MOVEMENT_LINEAGE: ReadonlyArray<readonly [parent: string, child: st
['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[] {
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);
}
+5 -1
View File
@@ -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,
+1
View File
@@ -15,6 +15,7 @@
"checkup": "Checkup",
"translations": "Translations",
"influences": "Influences",
"movementLinks": "Movement links",
"tours": "Tours",
"toursEditor": "Tour editor",
"users": "Users",
+1
View File
@@ -1,6 +1,7 @@
{
"title": "Influence links",
"back": "← Back to gallery",
"openMovementLinks": "Movement links →",
"tabList": "List",
"tabImport": "Import",
"tabGraph": "Graph",
+52
View File
@@ -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."
}
+1 -1
View File
@@ -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.",
+1
View File
@@ -15,6 +15,7 @@
"checkup": "Проверка",
"translations": "Переводы",
"influences": "Влияния",
"movementLinks": "Связи движений",
"tours": "Экскурсии",
"toursEditor": "Редактор экскурсий",
"users": "Пользователи",
+1
View File
@@ -1,6 +1,7 @@
{
"title": "Связи влияния",
"back": "← Назад в галерею",
"openMovementLinks": "Связи движений →",
"tabList": "Список",
"tabImport": "Импорт",
"tabGraph": "Граф",
+52
View File
@@ -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": "Удалить эту связь движений? Причины голосов кураторов будут записаны в журнал аудита."
}
+1 -1
View File
@@ -25,7 +25,7 @@
"perm_checkup": "Проверка",
"perm_curator_notes": "Заметки куратора",
"perm_translations": "Переводы",
"perm_influences": "Влияния",
"perm_influences": "Влияния и связи движений",
"perm_tours": "Экскурсии",
"perm_users": "Пользователи",
"adminAllPerms": "У администраторов все права автоматически.",
+4
View File
@@ -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,
+50 -3
View File
@@ -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<ReturnType<typeof api.listMovementLinks>>['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 ? (
<InfluencesPage onBack={goToTimelineHome} />
<InfluencesPage onBack={goToTimelineHome} onOpenMovementLinks={openMovementLinks} />
) : (
<div className="curator-login-gate">
<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' && (
canTours ? (
<ToursPage onBack={goToTimelineHome} />
@@ -1273,6 +1312,14 @@ export default function HomePage() {
<div className="home-page">
<header className="site-header">
<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 && (
<button
type="button"
+7 -1
View File
@@ -16,6 +16,7 @@ type WizardStep = 'upload' | 'mapping' | 'preview' | 'done';
interface Props {
onBack: () => void;
onOpenMovementLinks?: () => void;
}
const ROLE_OPTIONS = [
@@ -29,7 +30,7 @@ const ROLE_OPTIONS = [
'source_url',
] as const;
export default function InfluencesPage({ onBack }: Props) {
export default function InfluencesPage({ onBack, onOpenMovementLinks }: Props) {
const { t } = useTranslation('influences');
const [tab, setTab] = useState<Tab>('list');
const [error, setError] = useState<string | null>(null);
@@ -335,6 +336,11 @@ export default function InfluencesPage({ onBack }: Props) {
{t('back')}
</button>
<h1>{t('title')}</h1>
{onOpenMovementLinks && (
<button type="button" className="influences-back" onClick={onOpenMovementLinks}>
{t('openMovementLinks')}
</button>
)}
<nav className="influences-tabs">
<button type="button" className={tab === 'list' ? 'active' : ''} onClick={() => setTab('list')}>
{t('tabList')}
+265
View File
@@ -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;
}
}
+439
View File
@@ -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>
);
}
+2 -2
View File
@@ -34,7 +34,7 @@
* widest subtree first, so the main trunk keeps x = 0 (canvas centre).
*/
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. */
export const TREE_LAYOUT = {
@@ -103,7 +103,7 @@ function influenceCount(m: ArtMovement): number {
function buildParentMap(movements: ArtMovement[]): Map<number, number[]> {
const nameToId = new Map(movements.map((m) => [m.name, m.id]));
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 childId = nameToId.get(childName);
if (parentId == null || childId == null || parentId === childId) continue;
+16
View File
@@ -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);
+37
View File
@@ -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();
+1
View File
@@ -4,6 +4,7 @@
"description": "",
"main": "index.js",
"scripts": {
"test": "node --test server/__tests__/**/*.test.js",
"dev:web": "node scripts/dev-web.js",
"dev:server": "nodemon server/index.js",
"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);
}
});
});
+1
View File
@@ -50,6 +50,7 @@ app.use('/api/users', usersRoutes);
app.use('/api/audit', auditRoutes);
app.use('/api/translations', translationRoutes);
app.use('/api/influences', influenceRoutes);
app.use('/api/movement-links', require('./routes/movement-links'));
app.use('/api/tours', tourRoutes);
app.use(
'/images',
+11
View File
@@ -18,6 +18,8 @@ const INCREMENTAL_MIGRATIONS = [
'migrate-tours.sql',
'migrate-curator-notes.sql',
'migrate-user-roles.sql',
'migrate-movement-links.sql',
'migrate-movement-link-delete-votes.sql',
];
const BOOTSTRAP_ADMIN_PERMISSIONS = [
@@ -80,6 +82,15 @@ async function migrate() {
console.log('Bootstrapping curator account (if needed) …');
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.');
}
+581
View File
@@ -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,
};
+152
View File
@@ -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;