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

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