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

+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;