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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user