Add curator Movement links config with proof URLs and admin-gated delete votes.

Persist lineage in PostgreSQL, seed documented edges, and keep timeline/tree consumers API-backed with a static fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila KhodjaefandCursor committed 2026-09-08 14:37:20 +03:00
1 parent 2b4cdc5a9f
commit 1b760faf0d
36 files changed
+2051 -43

No files matched your search

+102
View File
@@ -773,6 +773,81 @@ export const api = {
);
},
listMovementLinks: (params: { q?: string; limit?: number; offset?: number } = {}) => {
const qs = new URLSearchParams();
if (params.q) qs.set('q', params.q);
if (params.limit) qs.set('limit', String(params.limit));
if (params.offset) qs.set('offset', String(params.offset));
const q = qs.toString();
return fetchJson<{ items: MovementLineageLinkItem[]; total: number }>(
`${API}/movement-links${q ? `?${q}` : ''}`,
);
},
createMovementLink: (payload: {
sourceMovementId: number;
targetMovementId: number;
proofUrl: string;
proofLabel?: string;
linkYearApprox?: number | null;
notes?: string;
}) =>
fetch(`${API}/movement-links`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Create failed: ${res.status}`);
}
return res.json() as Promise<{ item: MovementLineageLinkItem }>;
}),
listMovementLinkDeleteVotes: (id: number) =>
fetchJson<{ linkId: number; votes: MovementLineageDeleteVote[] }>(
`${API}/movement-links/${id}/delete-votes`,
),
voteDeleteMovementLink: (id: number, reason: string) =>
fetch(`${API}/movement-links/${id}/delete-votes`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason }),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Vote failed: ${res.status}`);
}
return res.json() as Promise<{ vote: MovementLineageDeleteVote }>;
}),
withdrawDeleteMovementLinkVote: (id: number) =>
fetch(`${API}/movement-links/${id}/delete-votes/me`, {
...fetchCredentials,
method: 'DELETE',
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Withdraw failed: ${res.status}`);
}
return res.json() as Promise<{ ok: boolean; id: number; linkId: number }>;
}),
deleteMovementLink: (id: number) =>
fetch(`${API}/movement-links/${id}`, {
...fetchCredentials,
method: 'DELETE',
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Delete failed: ${res.status}`);
}
return res.json() as Promise<{ ok: boolean; id: number }>;
}),
getInfluenceGraph: (params: { artistId?: number; paintingId?: number }) => {
const qs = new URLSearchParams();
if (params.artistId) qs.set('artistId', String(params.artistId));
@@ -1032,6 +1107,33 @@ export interface InfluenceEdgeItem {
updatedAt: string | null;
}
export interface MovementLineageLinkItem {
id: number;
source_movement_id: number;
target_movement_id: number;
link_year_approx: number | null;
proof_url: string;
proof_label: string | null;
notes: string | null;
created_at: string;
updated_at: string;
source_name: string;
source_start_year: number;
target_name: string;
target_start_year: number;
delete_vote_count?: number;
viewer_has_delete_vote?: boolean;
}
export interface MovementLineageDeleteVote {
id: number;
link_id: number;
user_id: number;
reason: string;
created_at: string;
username?: string;
}
export interface InfluenceGraph {
focus: { artistId: number; paintingId: number | null; label: string };
nodes: Array<{ id: string; type: string; label: string; focus?: boolean; artistId?: number; paintingId?: number; movementId?: number }>;