Add curator Movement links config with proof URLs and admin-gated delete votes.
Persist lineage in PostgreSQL, seed documented edges, and keep timeline/tree consumers API-backed with a static fallback. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
2b4cdc5a9f
commit
1b760faf0d
36 files changed
+2051
-43
No files matched your search
@@ -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 }>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"checkup": "Checkup",
|
||||
"translations": "Translations",
|
||||
"influences": "Influences",
|
||||
"movementLinks": "Movement links",
|
||||
"tours": "Tours",
|
||||
"toursEditor": "Tour editor",
|
||||
"users": "Users",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"title": "Influence links",
|
||||
"back": "← Back to gallery",
|
||||
"openMovementLinks": "Movement links →",
|
||||
"tabList": "List",
|
||||
"tabImport": "Import",
|
||||
"tabGraph": "Graph",
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"title": "Movement links",
|
||||
"back": "← Back to gallery",
|
||||
"intro": "Predecessor → successor edges used by the timeline flow and Tree of Art. Every link needs a proof URL (museum essay, literature, or other web reference).",
|
||||
"introCurator": "Curators can vote to delete a link and must give a reason. Only an admin can perform the final delete after reviewing votes.",
|
||||
"introAdmin": "As admin you can delete links directly. Review curator delete votes (reasons) before confirming.",
|
||||
"loadFailed": "Failed to load movement links",
|
||||
"saveFailed": "Failed to save movement link",
|
||||
"deleteFailed": "Failed to delete movement link",
|
||||
"voteFailed": "Failed to cast delete vote",
|
||||
"withdrawFailed": "Failed to withdraw vote",
|
||||
"searchPlaceholder": "Search source, target, proof…",
|
||||
"refresh": "Refresh",
|
||||
"loading": "Loading…",
|
||||
"loadingVotes": "Loading votes…",
|
||||
"votesLoadFailed": "Could not load votes.",
|
||||
"total": "{{count}} links",
|
||||
"noLinks": "No movement links yet.",
|
||||
"addLink": "Add link",
|
||||
"cancelAdd": "Cancel",
|
||||
"addTitle": "New movement link",
|
||||
"source": "Source (from)",
|
||||
"target": "Target (to)",
|
||||
"pickMovement": "Select movement…",
|
||||
"proofUrl": "Proof URL",
|
||||
"proofLabel": "Proof label",
|
||||
"proofLabelPlaceholder": "e.g. Met — Cubism",
|
||||
"linkYear": "Link date ≈",
|
||||
"linkYearPlaceholder": "e.g. 1907",
|
||||
"notes": "Notes",
|
||||
"saveLink": "Save link",
|
||||
"saving": "Saving…",
|
||||
"addRequiresMovements": "Pick both source and target movements",
|
||||
"proofRequired": "Proof URL is required",
|
||||
"voteTitle": "Vote to delete",
|
||||
"voteReason": "Reason",
|
||||
"voteReasonPlaceholder": "Explain why this link should be removed (min. 10 characters)…",
|
||||
"voteReasonRequired": "Reason must be at least 10 characters",
|
||||
"submitVote": "Submit vote",
|
||||
"cancelVote": "Cancel",
|
||||
"voteDelete": "Vote to delete",
|
||||
"withdrawVote": "Withdraw vote",
|
||||
"voteCount": "{{count}} vote(s)",
|
||||
"colSource": "Source",
|
||||
"colTarget": "Target",
|
||||
"colYear": "Link date ≈",
|
||||
"colProof": "Reference / proof",
|
||||
"colVotes": "Delete votes",
|
||||
"colActions": "Actions",
|
||||
"delete": "Delete",
|
||||
"confirmDelete": "Delete this movement link? Curator vote reasons will be recorded in the audit log."
|
||||
}
|
||||
@@ -25,7 +25,7 @@
|
||||
"perm_checkup": "Checkup",
|
||||
"perm_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.",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"checkup": "Проверка",
|
||||
"translations": "Переводы",
|
||||
"influences": "Влияния",
|
||||
"movementLinks": "Связи движений",
|
||||
"tours": "Экскурсии",
|
||||
"toursEditor": "Редактор экскурсий",
|
||||
"users": "Пользователи",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"title": "Связи влияния",
|
||||
"back": "← Назад в галерею",
|
||||
"openMovementLinks": "Связи движений →",
|
||||
"tabList": "Список",
|
||||
"tabImport": "Импорт",
|
||||
"tabGraph": "Граф",
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"title": "Связи движений",
|
||||
"back": "← Назад в галерею",
|
||||
"intro": "Связи предшественник → преемник для ленты и «Дерева искусства». Для каждой связи нужна ссылка-доказательство (музейный очерк, литература или интернет).",
|
||||
"introCurator": "Кураторы могут голосовать за удаление связи с указанием причины. Окончательное удаление выполняет только администратор после просмотра голосов.",
|
||||
"introAdmin": "Как администратор вы можете удалять связи напрямую. Перед подтверждением просмотрите голоса кураторов и их причины.",
|
||||
"loadFailed": "Не удалось загрузить связи движений",
|
||||
"saveFailed": "Не удалось сохранить связь",
|
||||
"deleteFailed": "Не удалось удалить связь",
|
||||
"voteFailed": "Не удалось отправить голос",
|
||||
"withdrawFailed": "Не удалось отозвать голос",
|
||||
"searchPlaceholder": "Поиск: источник, цель, ссылка…",
|
||||
"refresh": "Обновить",
|
||||
"loading": "Загрузка…",
|
||||
"loadingVotes": "Загрузка голосов…",
|
||||
"votesLoadFailed": "Не удалось загрузить голоса.",
|
||||
"total": "{{count}} связей",
|
||||
"noLinks": "Связей движений пока нет.",
|
||||
"addLink": "Добавить связь",
|
||||
"cancelAdd": "Отмена",
|
||||
"addTitle": "Новая связь движений",
|
||||
"source": "Источник (от)",
|
||||
"target": "Цель (к)",
|
||||
"pickMovement": "Выберите движение…",
|
||||
"proofUrl": "Ссылка-доказательство",
|
||||
"proofLabel": "Подпись ссылки",
|
||||
"proofLabelPlaceholder": "напр. Met — Cubism",
|
||||
"linkYear": "Дата связи ≈",
|
||||
"linkYearPlaceholder": "напр. 1907",
|
||||
"notes": "Заметки",
|
||||
"saveLink": "Сохранить",
|
||||
"saving": "Сохранение…",
|
||||
"addRequiresMovements": "Выберите исходное и целевое движение",
|
||||
"proofRequired": "Ссылка-доказательство обязательна",
|
||||
"voteTitle": "Голос за удаление",
|
||||
"voteReason": "Причина",
|
||||
"voteReasonPlaceholder": "Почему связь нужно удалить (мин. 10 символов)…",
|
||||
"voteReasonRequired": "Причина должна быть не короче 10 символов",
|
||||
"submitVote": "Отправить голос",
|
||||
"cancelVote": "Отмена",
|
||||
"voteDelete": "Голосовать за удаление",
|
||||
"withdrawVote": "Отозвать голос",
|
||||
"voteCount": "{{count}} голос(ов)",
|
||||
"colSource": "Источник",
|
||||
"colTarget": "Цель",
|
||||
"colYear": "Дата ≈",
|
||||
"colProof": "Ссылка / доказательство",
|
||||
"colVotes": "Голоса за удаление",
|
||||
"colActions": "Действия",
|
||||
"delete": "Удалить",
|
||||
"confirmDelete": "Удалить эту связь движений? Причины голосов кураторов будут записаны в журнал аудита."
|
||||
}
|
||||
@@ -25,7 +25,7 @@
|
||||
"perm_checkup": "Проверка",
|
||||
"perm_curator_notes": "Заметки куратора",
|
||||
"perm_translations": "Переводы",
|
||||
"perm_influences": "Влияния",
|
||||
"perm_influences": "Влияния и связи движений",
|
||||
"perm_tours": "Экскурсии",
|
||||
"perm_users": "Пользователи",
|
||||
"adminAllPerms": "У администраторов все права автоматически.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
.movement-links-page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem 1.5rem 3rem;
|
||||
color: #e8d5b5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.movement-links-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.movement-links-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 650;
|
||||
flex: 1;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.movement-links-back,
|
||||
.movement-links-add-btn,
|
||||
.movement-links-refresh,
|
||||
.movement-links-save,
|
||||
.movement-links-delete {
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
background: rgba(15, 15, 26, 0.85);
|
||||
color: #c9a96e;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.movement-links-back:hover,
|
||||
.movement-links-add-btn:hover,
|
||||
.movement-links-refresh:hover,
|
||||
.movement-links-save:hover {
|
||||
border-color: #c9a96e;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.movement-links-intro {
|
||||
margin: 0 0 0.35rem;
|
||||
color: rgba(232, 213, 181, 0.75);
|
||||
font-size: 0.95rem;
|
||||
max-width: 52rem;
|
||||
}
|
||||
|
||||
.movement-links-intro-secondary {
|
||||
margin: 0 0 1rem;
|
||||
color: rgba(232, 213, 181, 0.55);
|
||||
font-size: 0.88rem;
|
||||
max-width: 52rem;
|
||||
}
|
||||
|
||||
.movement-links-error {
|
||||
background: rgba(139, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 170, 170, 0.4);
|
||||
color: #ffaaaa;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.movement-links-form {
|
||||
border: 1px solid rgba(201, 169, 110, 0.28);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.1rem 1.15rem;
|
||||
margin-bottom: 1.25rem;
|
||||
background: rgba(15, 15, 26, 0.55);
|
||||
}
|
||||
|
||||
.movement-links-form h2 {
|
||||
margin: 0 0 0.85rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.movement-links-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem 1rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.movement-links-form-grid label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(232, 213, 181, 0.85);
|
||||
}
|
||||
|
||||
.movement-links-span-2 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.movement-links-form-grid select,
|
||||
.movement-links-form-grid input,
|
||||
.movement-links-form-grid textarea,
|
||||
.movement-links-vote-form textarea,
|
||||
.movement-links-search {
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
background: rgba(10, 10, 20, 0.9);
|
||||
color: #e8d5b5;
|
||||
border-radius: 6px;
|
||||
padding: 0.45rem 0.6rem;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.movement-links-vote-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(232, 213, 181, 0.85);
|
||||
}
|
||||
|
||||
.movement-links-vote-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.movement-links-vote-btn,
|
||||
.movement-links-votes-toggle {
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
background: rgba(15, 15, 26, 0.85);
|
||||
color: #c9a96e;
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.65rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.movement-links-vote-btn:hover,
|
||||
.movement-links-votes-toggle:hover {
|
||||
border-color: #c9a96e;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.movement-links-row-flagged {
|
||||
background: rgba(232, 160, 64, 0.08);
|
||||
}
|
||||
|
||||
.movement-links-vote-list {
|
||||
margin: 0.45rem 0 0;
|
||||
padding-left: 1.1rem;
|
||||
color: rgba(232, 213, 181, 0.7);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.movement-links-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.movement-links-save {
|
||||
background: rgba(232, 160, 64, 0.18);
|
||||
border-color: #e8a040;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.movement-links-save:disabled,
|
||||
.movement-links-delete:disabled,
|
||||
.movement-links-refresh:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.movement-links-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.movement-links-search {
|
||||
flex: 1;
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.movement-links-count {
|
||||
color: rgba(232, 213, 181, 0.65);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.movement-links-muted {
|
||||
color: rgba(232, 213, 181, 0.55);
|
||||
}
|
||||
|
||||
.movement-links-table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid rgba(201, 169, 110, 0.28);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.movement-links-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.movement-links-table th,
|
||||
.movement-links-table td {
|
||||
text-align: left;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-bottom: 1px solid rgba(201, 169, 110, 0.18);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.movement-links-table th {
|
||||
background: rgba(15, 15, 26, 0.85);
|
||||
color: #c9a96e;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.movement-links-table tbody tr:nth-child(even) {
|
||||
background: rgba(15, 15, 26, 0.35);
|
||||
}
|
||||
|
||||
.movement-links-table a {
|
||||
color: #e8a040;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.movement-links-table a:hover {
|
||||
color: #f0c070;
|
||||
}
|
||||
|
||||
.movement-links-year {
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.movement-links-notes {
|
||||
margin-top: 0.25rem;
|
||||
color: rgba(232, 213, 181, 0.55);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.movement-links-delete {
|
||||
color: #ffaaaa;
|
||||
border-color: rgba(255, 170, 170, 0.35);
|
||||
}
|
||||
|
||||
.movement-links-delete:hover:not(:disabled) {
|
||||
border-color: #ffaaaa;
|
||||
color: #ffcccc;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.movement-links-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.movement-links-span-2 {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
api,
|
||||
type MovementLineageDeleteVote,
|
||||
type MovementLineageLinkItem,
|
||||
} from '../api/client';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { setMovementLineageFromApi } from '../data/movement-lineage';
|
||||
import type { ArtMovement } from '../types';
|
||||
import './MovementLinksPage.css';
|
||||
|
||||
interface Props {
|
||||
onBack: () => void;
|
||||
movements: ArtMovement[];
|
||||
}
|
||||
|
||||
export default function MovementLinksPage({ onBack, movements }: Props) {
|
||||
const { t } = useTranslation('movementLinks');
|
||||
const { isAdmin } = useAuth();
|
||||
const [items, setItems] = useState<MovementLineageLinkItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [q, setQ] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
|
||||
const [sourceId, setSourceId] = useState<number | ''>('');
|
||||
const [targetId, setTargetId] = useState<number | ''>('');
|
||||
const [proofUrl, setProofUrl] = useState('');
|
||||
const [proofLabel, setProofLabel] = useState('');
|
||||
const [linkYear, setLinkYear] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
const [voteLinkId, setVoteLinkId] = useState<number | null>(null);
|
||||
const [voteReason, setVoteReason] = useState('');
|
||||
const [expandedVotes, setExpandedVotes] = useState<Record<number, MovementLineageDeleteVote[] | 'loading' | 'error'>>({});
|
||||
|
||||
const sortedMovements = useMemo(
|
||||
() => [...movements].sort((a, b) => a.start_year - b.start_year || a.name.localeCompare(b.name)),
|
||||
[movements],
|
||||
);
|
||||
|
||||
const syncLocalLineage = useCallback((rows: MovementLineageLinkItem[]) => {
|
||||
setMovementLineageFromApi(rows);
|
||||
}, []);
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [data, all] = await Promise.all([
|
||||
api.listMovementLinks({ q: q || undefined, limit: 500 }),
|
||||
q ? api.listMovementLinks({ limit: 500 }) : Promise.resolve(null),
|
||||
]);
|
||||
setItems(data.items);
|
||||
setTotal(data.total);
|
||||
syncLocalLineage((all ?? data).items);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [q, syncLocalLineage, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadList();
|
||||
}, [loadList]);
|
||||
|
||||
const resetForm = () => {
|
||||
setSourceId('');
|
||||
setTargetId('');
|
||||
setProofUrl('');
|
||||
setProofLabel('');
|
||||
setLinkYear('');
|
||||
setNotes('');
|
||||
};
|
||||
|
||||
const submitAdd = async () => {
|
||||
if (sourceId === '' || targetId === '') {
|
||||
setError(t('addRequiresMovements'));
|
||||
return;
|
||||
}
|
||||
if (!proofUrl.trim()) {
|
||||
setError(t('proofRequired'));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const yearRaw = linkYear.trim();
|
||||
await api.createMovementLink({
|
||||
sourceMovementId: sourceId,
|
||||
targetMovementId: targetId,
|
||||
proofUrl: proofUrl.trim(),
|
||||
proofLabel: proofLabel.trim() || undefined,
|
||||
linkYearApprox: yearRaw === '' ? null : Number(yearRaw),
|
||||
notes: notes.trim() || undefined,
|
||||
});
|
||||
resetForm();
|
||||
setShowAdd(false);
|
||||
await loadList();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('saveFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitVote = async () => {
|
||||
if (voteLinkId == null) return;
|
||||
if (voteReason.trim().length < 10) {
|
||||
setError(t('voteReasonRequired'));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.voteDeleteMovementLink(voteLinkId, voteReason.trim());
|
||||
setVoteLinkId(null);
|
||||
setVoteReason('');
|
||||
setExpandedVotes((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[voteLinkId];
|
||||
return next;
|
||||
});
|
||||
await loadList();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('voteFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const withdrawVote = async (id: number) => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.withdrawDeleteMovementLinkVote(id);
|
||||
setExpandedVotes((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
await loadList();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('withdrawFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadVotes = async (id: number) => {
|
||||
if (expandedVotes[id] && expandedVotes[id] !== 'error') {
|
||||
setExpandedVotes((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setExpandedVotes((prev) => ({ ...prev, [id]: 'loading' }));
|
||||
try {
|
||||
const data = await api.listMovementLinkDeleteVotes(id);
|
||||
setExpandedVotes((prev) => ({ ...prev, [id]: data.votes }));
|
||||
} catch {
|
||||
setExpandedVotes((prev) => ({ ...prev, [id]: 'error' }));
|
||||
}
|
||||
};
|
||||
|
||||
const onAdminDelete = async (id: number) => {
|
||||
if (!window.confirm(t('confirmDelete'))) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.deleteMovementLink(id);
|
||||
setExpandedVotes((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
await loadList();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('deleteFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="movement-links-page">
|
||||
<header className="movement-links-header">
|
||||
<button type="button" className="movement-links-back" onClick={onBack}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<h1>{t('title')}</h1>
|
||||
<button
|
||||
type="button"
|
||||
className="movement-links-add-btn"
|
||||
onClick={() => {
|
||||
setShowAdd((v) => !v);
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
{showAdd ? t('cancelAdd') : t('addLink')}
|
||||
</button>
|
||||
<button type="button" className="movement-links-refresh" onClick={() => void loadList()} disabled={loading}>
|
||||
{t('refresh')}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p className="movement-links-intro">{t('intro')}</p>
|
||||
<p className="movement-links-intro-secondary">
|
||||
{isAdmin ? t('introAdmin') : t('introCurator')}
|
||||
</p>
|
||||
|
||||
{error && <div className="movement-links-error">{error}</div>}
|
||||
|
||||
{voteLinkId != null && (
|
||||
<section className="movement-links-form movement-links-vote-form">
|
||||
<h2>{t('voteTitle')}</h2>
|
||||
<label className="movement-links-span-2">
|
||||
{t('voteReason')} *
|
||||
<textarea
|
||||
rows={3}
|
||||
value={voteReason}
|
||||
onChange={(e) => setVoteReason(e.target.value)}
|
||||
placeholder={t('voteReasonPlaceholder')}
|
||||
/>
|
||||
</label>
|
||||
<div className="movement-links-vote-actions">
|
||||
<button type="button" className="movement-links-save" onClick={() => void submitVote()} disabled={saving}>
|
||||
{saving ? t('saving') : t('submitVote')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="movement-links-refresh"
|
||||
onClick={() => {
|
||||
setVoteLinkId(null);
|
||||
setVoteReason('');
|
||||
}}
|
||||
disabled={saving}
|
||||
>
|
||||
{t('cancelVote')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{showAdd && (
|
||||
<section className="movement-links-form">
|
||||
<h2>{t('addTitle')}</h2>
|
||||
<div className="movement-links-form-grid">
|
||||
<label>
|
||||
{t('source')}
|
||||
<select
|
||||
value={sourceId === '' ? '' : String(sourceId)}
|
||||
onChange={(e) => setSourceId(e.target.value ? Number(e.target.value) : '')}
|
||||
>
|
||||
<option value="">{t('pickMovement')}</option>
|
||||
{sortedMovements.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name} ({m.start_year})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t('target')}
|
||||
<select
|
||||
value={targetId === '' ? '' : String(targetId)}
|
||||
onChange={(e) => setTargetId(e.target.value ? Number(e.target.value) : '')}
|
||||
>
|
||||
<option value="">{t('pickMovement')}</option>
|
||||
{sortedMovements.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name} ({m.start_year})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="movement-links-span-2">
|
||||
{t('proofUrl')} *
|
||||
<input
|
||||
type="url"
|
||||
required
|
||||
placeholder="https://…"
|
||||
value={proofUrl}
|
||||
onChange={(e) => setProofUrl(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('proofLabel')}
|
||||
<input
|
||||
type="text"
|
||||
value={proofLabel}
|
||||
onChange={(e) => setProofLabel(e.target.value)}
|
||||
placeholder={t('proofLabelPlaceholder')}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('linkYear')}
|
||||
<input
|
||||
type="number"
|
||||
value={linkYear}
|
||||
onChange={(e) => setLinkYear(e.target.value)}
|
||||
placeholder={t('linkYearPlaceholder')}
|
||||
/>
|
||||
</label>
|
||||
<label className="movement-links-span-2">
|
||||
{t('notes')}
|
||||
<input type="text" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" className="movement-links-save" onClick={() => void submitAdd()} disabled={saving}>
|
||||
{saving ? t('saving') : t('saveLink')}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="movement-links-toolbar">
|
||||
<input
|
||||
type="search"
|
||||
className="movement-links-search"
|
||||
placeholder={t('searchPlaceholder')}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<span className="movement-links-count">{t('total', { count: total })}</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="movement-links-muted">{t('loading')}</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="movement-links-muted">{t('noLinks')}</p>
|
||||
) : (
|
||||
<div className="movement-links-table-wrap">
|
||||
<table className="movement-links-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('colSource')}</th>
|
||||
<th>{t('colTarget')}</th>
|
||||
<th>{t('colYear')}</th>
|
||||
<th>{t('colProof')}</th>
|
||||
<th>{t('colVotes')}</th>
|
||||
<th>{t('colActions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((row) => {
|
||||
const voteCount = row.delete_vote_count ?? 0;
|
||||
const votesState = expandedVotes[row.id];
|
||||
return (
|
||||
<tr key={row.id} className={voteCount > 0 ? 'movement-links-row-flagged' : undefined}>
|
||||
<td>{row.source_name}</td>
|
||||
<td>{row.target_name}</td>
|
||||
<td className="movement-links-year">
|
||||
{row.link_year_approx != null ? `~${row.link_year_approx}` : '—'}
|
||||
</td>
|
||||
<td>
|
||||
<a href={row.proof_url} target="_blank" rel="noopener noreferrer">
|
||||
{row.proof_label || row.proof_url}
|
||||
</a>
|
||||
{row.notes ? <div className="movement-links-notes">{row.notes}</div> : null}
|
||||
{Array.isArray(votesState) && votesState.length > 0 && (
|
||||
<ul className="movement-links-vote-list">
|
||||
{votesState.map((v) => (
|
||||
<li key={v.id}>
|
||||
<strong>{v.username || `#${v.user_id}`}</strong>: {v.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{votesState === 'loading' && (
|
||||
<div className="movement-links-notes">{t('loadingVotes')}</div>
|
||||
)}
|
||||
{votesState === 'error' && (
|
||||
<div className="movement-links-notes">{t('votesLoadFailed')}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="movement-links-year">
|
||||
{voteCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="movement-links-votes-toggle"
|
||||
onClick={() => void loadVotes(row.id)}
|
||||
>
|
||||
{t('voteCount', { count: voteCount })}
|
||||
</button>
|
||||
) : (
|
||||
'0'
|
||||
)}
|
||||
</td>
|
||||
<td className="movement-links-actions">
|
||||
{isAdmin ? (
|
||||
<button
|
||||
type="button"
|
||||
className="movement-links-delete"
|
||||
onClick={() => void onAdminDelete(row.id)}
|
||||
disabled={saving}
|
||||
>
|
||||
{t('delete')}
|
||||
</button>
|
||||
) : row.viewer_has_delete_vote ? (
|
||||
<button
|
||||
type="button"
|
||||
className="movement-links-refresh"
|
||||
onClick={() => void withdrawVote(row.id)}
|
||||
disabled={saving}
|
||||
>
|
||||
{t('withdrawVote')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="movement-links-vote-btn"
|
||||
onClick={() => {
|
||||
setVoteLinkId(row.id);
|
||||
setVoteReason('');
|
||||
setError(null);
|
||||
setShowAdd(false);
|
||||
}}
|
||||
disabled={saving}
|
||||
>
|
||||
{t('voteDelete')}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,7 @@
|
||||
* widest subtree first, so the main trunk keeps x = 0 (canvas centre).
|
||||
*/
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user