Add curator Influences tool with import wizard, CRUD, and graph.
CSV/JSON/XLSX mapping wizard expands artist-level rows to all paintings, blocks duplicate file/data imports via content and payload hashes, and documents the workflow in influence-import.md. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
62d7ebbe6a
commit
48bd17e985
@@ -188,6 +188,23 @@ async function fileToBase64Payload(file: File): Promise<{ imageData: string; mim
|
||||
});
|
||||
}
|
||||
|
||||
async function fileToBase64Raw(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result;
|
||||
if (typeof result !== 'string') {
|
||||
reject(new Error('Could not read file'));
|
||||
return;
|
||||
}
|
||||
const comma = result.indexOf(',');
|
||||
resolve(comma >= 0 ? result.slice(comma + 1) : result);
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Could not read file'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function mimeTypeFromFilename(filename: string): string | null {
|
||||
const ext = filename.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1];
|
||||
switch (ext) {
|
||||
@@ -516,6 +533,159 @@ export const api = {
|
||||
return res.json();
|
||||
}),
|
||||
|
||||
listInfluences: (params: {
|
||||
artistId?: number;
|
||||
paintingId?: number;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.artistId) qs.set('artistId', String(params.artistId));
|
||||
if (params.paintingId) qs.set('paintingId', String(params.paintingId));
|
||||
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: InfluenceEdgeItem[]; total: number; limit: number; offset: number }>(
|
||||
`${API}/influences${q ? `?${q}` : ''}`,
|
||||
);
|
||||
},
|
||||
|
||||
getInfluenceGraph: (params: { artistId?: number; paintingId?: number }) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.artistId) qs.set('artistId', String(params.artistId));
|
||||
if (params.paintingId) qs.set('paintingId', String(params.paintingId));
|
||||
return fetchJson<InfluenceGraph>(`${API}/influences/graph?${qs.toString()}`);
|
||||
},
|
||||
|
||||
createInfluence: (payload: {
|
||||
paintingId: number;
|
||||
sourceType: 'painting' | 'artist' | 'movement';
|
||||
sourcePaintingId?: number;
|
||||
sourceArtistId?: number;
|
||||
sourceMovementId?: number;
|
||||
notes?: string;
|
||||
source?: string;
|
||||
sourceUrl?: string;
|
||||
}) =>
|
||||
fetch(`${API}/influences`, {
|
||||
...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<{ id: number }>;
|
||||
}),
|
||||
|
||||
updateInfluence: (
|
||||
id: number,
|
||||
payload: Partial<{
|
||||
notes: string;
|
||||
source: string;
|
||||
sourceUrl: string;
|
||||
confidence: string;
|
||||
sourceType: 'painting' | 'artist' | 'movement';
|
||||
sourcePaintingId: number;
|
||||
sourceArtistId: number;
|
||||
sourceMovementId: number;
|
||||
}>,
|
||||
) =>
|
||||
fetch(`${API}/influences/${id}`, {
|
||||
...fetchCredentials,
|
||||
method: 'PATCH',
|
||||
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 || `Update failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<{ id: number }>;
|
||||
}),
|
||||
|
||||
deleteInfluence: (id: number) =>
|
||||
fetch(`${API}/influences/${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 }>;
|
||||
}),
|
||||
|
||||
parseInfluenceImport: async (file: File, sheet?: string) => {
|
||||
const contentBase64 = await fileToBase64Raw(file);
|
||||
return fetch(`${API}/influences/import/parse`, {
|
||||
...fetchCredentials,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
filename: file.name,
|
||||
sheet,
|
||||
contentBase64,
|
||||
}),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Parse failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<InfluenceImportParseResult>;
|
||||
});
|
||||
},
|
||||
|
||||
previewInfluenceImport: (payload: {
|
||||
rows: Record<string, string>[];
|
||||
mapping: Record<string, string>;
|
||||
sourceLabel?: string;
|
||||
contentHash?: string;
|
||||
payloadHash?: string;
|
||||
}) =>
|
||||
fetch(`${API}/influences/import/preview`, {
|
||||
...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 || `Preview failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<InfluenceImportPreview>;
|
||||
}),
|
||||
|
||||
commitInfluenceImport: (payload: {
|
||||
proposals: InfluenceImportProposal[];
|
||||
fileName?: string;
|
||||
contentHash?: string;
|
||||
payloadHash?: string;
|
||||
force?: boolean;
|
||||
}) =>
|
||||
fetch(`${API}/influences/import/commit`, {
|
||||
...fetchCredentials,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
const err = new Error(body.error || `Commit failed: ${res.status}`) as Error & {
|
||||
code?: string;
|
||||
priorImport?: InfluencePriorImport;
|
||||
};
|
||||
err.code = body.code;
|
||||
err.priorImport = body.priorImport;
|
||||
throw err;
|
||||
}
|
||||
return res.json() as Promise<{ inserted: number; skipped: number; attempted: number }>;
|
||||
}),
|
||||
|
||||
preloadArtistImages,
|
||||
};
|
||||
|
||||
@@ -542,6 +712,108 @@ export interface TranslationDetail {
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface InfluenceEdgeItem {
|
||||
id: number;
|
||||
paintingId: number;
|
||||
paintingTitle: string;
|
||||
paintingYear: number | null;
|
||||
artistId: number;
|
||||
artistName: string;
|
||||
sourceType: 'painting' | 'artist' | 'movement';
|
||||
sourcePaintingId: number | null;
|
||||
sourceArtistId: number | null;
|
||||
sourceMovementId: number | null;
|
||||
sourceLabel: string | null;
|
||||
notes: string | null;
|
||||
source: string | null;
|
||||
sourceUrl: string | null;
|
||||
aspects: string | null;
|
||||
quote: string | null;
|
||||
confidence: string | null;
|
||||
discoveredVia: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
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 }>;
|
||||
edges: Array<{ id: number; from: string; to: string; direction: string; label: string }>;
|
||||
}
|
||||
|
||||
export interface InfluencePriorImport {
|
||||
importedAt: string;
|
||||
username: string | null;
|
||||
fileName: string | null;
|
||||
inserted: number | null;
|
||||
contentHash: string | null;
|
||||
payloadHash: string | null;
|
||||
match: 'file' | 'data' | 'unknown';
|
||||
}
|
||||
|
||||
export interface InfluenceImportParseResult {
|
||||
filename: string;
|
||||
format: string;
|
||||
sheets: string[] | null;
|
||||
sheet: string | null;
|
||||
columns: string[];
|
||||
rowCount: number;
|
||||
sampleRows: Record<string, string>[];
|
||||
rows?: Record<string, string>[];
|
||||
suggestedPreset: string;
|
||||
suggestedMapping: Record<string, string>;
|
||||
roles: string[];
|
||||
presets: Array<{ id: string; label: string; mapping: Record<string, string> }>;
|
||||
contentHash?: string;
|
||||
payloadHash?: string;
|
||||
alreadyImported?: boolean;
|
||||
priorImport?: InfluencePriorImport | null;
|
||||
}
|
||||
|
||||
export interface InfluenceImportProposal {
|
||||
rowIndex: number;
|
||||
direction: string;
|
||||
paintingId: number;
|
||||
paintingTitle: string;
|
||||
artistId: number;
|
||||
artistName: string;
|
||||
sourceType: string;
|
||||
sourcePaintingId: number | null;
|
||||
sourceArtistId: number | null;
|
||||
sourceMovementId: number | null;
|
||||
sourceLabel: string;
|
||||
token: string;
|
||||
notes: string | null;
|
||||
source: string | null;
|
||||
sourceUrl: string | null;
|
||||
confidence: string;
|
||||
discoveredVia: string;
|
||||
edgeKey: string;
|
||||
action: 'create' | 'skip';
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export interface InfluenceImportPreview {
|
||||
proposals: InfluenceImportProposal[];
|
||||
warnings: Array<{
|
||||
rowIndex: number;
|
||||
message: string;
|
||||
token?: string;
|
||||
direction?: string;
|
||||
candidates?: Array<{ sourceType: string; sourcePaintingId?: number; label: string }>;
|
||||
}>;
|
||||
counts: {
|
||||
rows: number;
|
||||
proposals: number;
|
||||
willCreate: number;
|
||||
willSkip: number;
|
||||
errors: number;
|
||||
};
|
||||
contentHash?: string | null;
|
||||
payloadHash?: string | null;
|
||||
alreadyImported?: boolean;
|
||||
priorImport?: InfluencePriorImport | null;
|
||||
}
|
||||
|
||||
export function debugImageProxyUrl(
|
||||
imageUrl: string,
|
||||
context?: { searchUrl?: string; source?: string }
|
||||
|
||||
@@ -11,6 +11,7 @@ import enBio from '../locales/en/bio.json';
|
||||
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 ruCommon from '../locales/ru/common.json';
|
||||
import ruHome from '../locales/ru/home.json';
|
||||
@@ -21,6 +22,7 @@ import ruBio from '../locales/ru/bio.json';
|
||||
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';
|
||||
|
||||
const initialLocale = readStoredLocale();
|
||||
writeStoredLocale(initialLocale);
|
||||
@@ -29,7 +31,7 @@ void i18n.use(initReactI18next).init({
|
||||
lng: initialLocale,
|
||||
fallbackLng: 'en',
|
||||
supportedLngs: ['en', 'ru'],
|
||||
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations'],
|
||||
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences'],
|
||||
defaultNS: 'common',
|
||||
resources: {
|
||||
en: {
|
||||
@@ -42,6 +44,7 @@ void i18n.use(initReactI18next).init({
|
||||
annotations: enAnnotations,
|
||||
debug: enDebug,
|
||||
translations: enTranslations,
|
||||
influences: enInfluences,
|
||||
},
|
||||
ru: {
|
||||
common: ruCommon,
|
||||
@@ -53,6 +56,7 @@ void i18n.use(initReactI18next).init({
|
||||
annotations: ruAnnotations,
|
||||
debug: ruDebug,
|
||||
translations: ruTranslations,
|
||||
influences: ruInfluences,
|
||||
},
|
||||
},
|
||||
interpolation: { escapeValue: false },
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"showMoreDebug": "Show more (debug)",
|
||||
"checkup": "Checkup",
|
||||
"translations": "Translations",
|
||||
"influences": "Influences",
|
||||
"curatorRequiredTitle": "Curator access required",
|
||||
"curatorRequiredBody": "Sign in as a curator to use this tool.",
|
||||
"backToGalleryBtn": "Back to gallery"
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"title": "Influence links",
|
||||
"back": "← Back to gallery",
|
||||
"tabList": "List",
|
||||
"tabImport": "Import",
|
||||
"tabGraph": "Graph",
|
||||
"loadFailed": "Failed to load influences",
|
||||
"searchPlaceholder": "Search artist, painting, source…",
|
||||
"refresh": "Refresh",
|
||||
"loading": "Loading…",
|
||||
"loadingParse": "Reading file…",
|
||||
"loadingPreview": "Validating influence links…",
|
||||
"loadingCommit": "Importing links into the database…",
|
||||
"loadingSave": "Saving link…",
|
||||
"addEdge": "Add link",
|
||||
"cancelAdd": "Cancel",
|
||||
"total": "{{count}} links",
|
||||
"filtered": "filtered by artist",
|
||||
"clearFilter": "Clear artist filter",
|
||||
"subjectPainting": "Subject painting",
|
||||
"searchPainting": "Search painting…",
|
||||
"sourceType": "Source type",
|
||||
"typeArtist": "Artist",
|
||||
"typePainting": "Painting",
|
||||
"typeMovement": "Movement",
|
||||
"sourceEntity": "Source entity",
|
||||
"searchSource": "Search source…",
|
||||
"notes": "Notes",
|
||||
"saveEdge": "Save link",
|
||||
"addRequiresIds": "Pick a subject painting and a source entity",
|
||||
"colSubject": "Subject",
|
||||
"colSource": "Source",
|
||||
"colType": "Type",
|
||||
"colNotes": "Notes",
|
||||
"colActions": "Actions",
|
||||
"colAction": "Action",
|
||||
"colDirection": "Direction",
|
||||
"delete": "Delete",
|
||||
"confirmDelete": "Delete this influence link?",
|
||||
"noEdges": "No influence links match.",
|
||||
"stepUpload": "1. Upload",
|
||||
"stepMapping": "2. Map columns",
|
||||
"stepPreview": "3. Validate",
|
||||
"stepDone": "4. Done",
|
||||
"uploadHelp": "Choose a CSV, JSON, or XLSX file with influence rows (e.g. Inputs/artist_influences_web_sources.xlsx).",
|
||||
"parseFailed": "Failed to parse file",
|
||||
"previewFailed": "Failed to build preview",
|
||||
"commitFailed": "Failed to commit import",
|
||||
"rowsMissing": "File rows were not returned (too large). Use a smaller file (≤2000 rows).",
|
||||
"fileInfo": "{{name}} · {{rows}} rows · {{format}}",
|
||||
"sheet": "Sheet",
|
||||
"preset": "Mapping preset",
|
||||
"column": "Column",
|
||||
"role": "Role",
|
||||
"sample": "Sample",
|
||||
"backStep": "Back",
|
||||
"runPreview": "Validate & preview",
|
||||
"previewCounts": "Will create {{create}} · skip {{skip}} · row errors {{errors}} · proposals {{proposals}}",
|
||||
"warnings": "{{count}} warnings",
|
||||
"commitImport": "Import {{count}} links",
|
||||
"commitSummary": "Imported {{inserted}} links ({{skipped}} already present or skipped).",
|
||||
"alreadyImportedWarn": "Already imported {{when}} by {{who}} ({{match}}: {{file}}). Commit is blocked unless you force re-import.",
|
||||
"alreadyImportedBlock": "This file or identical data was already imported. Enable “Import anyway” to force, or cancel.",
|
||||
"forceImport": "Import anyway (force — may recreate skipped edges only; existing edges stay unique)",
|
||||
"matchFile": "same file bytes",
|
||||
"matchData": "same mapped data",
|
||||
"searchArtist": "Search artist for neighborhood graph…",
|
||||
"graphEmpty": "Select an artist to visualize influence neighbors.",
|
||||
"graphStats": "{{nodes}} nodes · {{edges}} edges"
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
"showMoreDebug": "Показать больше (отладка)",
|
||||
"checkup": "Проверка",
|
||||
"translations": "Переводы",
|
||||
"influences": "Влияния",
|
||||
"curatorRequiredTitle": "Требуется доступ куратора",
|
||||
"curatorRequiredBody": "Войдите как куратор, чтобы использовать этот инструмент.",
|
||||
"backToGalleryBtn": "Вернуться в галерею"
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"title": "Связи влияния",
|
||||
"back": "← Назад в галерею",
|
||||
"tabList": "Список",
|
||||
"tabImport": "Импорт",
|
||||
"tabGraph": "Граф",
|
||||
"loadFailed": "Не удалось загрузить связи",
|
||||
"searchPlaceholder": "Поиск: художник, картина, источник…",
|
||||
"refresh": "Обновить",
|
||||
"loading": "Загрузка…",
|
||||
"loadingParse": "Чтение файла…",
|
||||
"loadingPreview": "Проверка связей влияния…",
|
||||
"loadingCommit": "Импорт связей в базу…",
|
||||
"loadingSave": "Сохранение связи…",
|
||||
"addEdge": "Добавить связь",
|
||||
"cancelAdd": "Отмена",
|
||||
"total": "{{count}} связей",
|
||||
"filtered": "фильтр по художнику",
|
||||
"clearFilter": "Сбросить фильтр",
|
||||
"subjectPainting": "Картина (субъект)",
|
||||
"searchPainting": "Поиск картины…",
|
||||
"sourceType": "Тип источника",
|
||||
"typeArtist": "Художник",
|
||||
"typePainting": "Картина",
|
||||
"typeMovement": "Направление",
|
||||
"sourceEntity": "Источник",
|
||||
"searchSource": "Поиск источника…",
|
||||
"notes": "Заметки",
|
||||
"saveEdge": "Сохранить связь",
|
||||
"addRequiresIds": "Выберите картину и источник",
|
||||
"colSubject": "Субъект",
|
||||
"colSource": "Источник",
|
||||
"colType": "Тип",
|
||||
"colNotes": "Заметки",
|
||||
"colActions": "Действия",
|
||||
"colAction": "Действие",
|
||||
"colDirection": "Направление",
|
||||
"delete": "Удалить",
|
||||
"confirmDelete": "Удалить эту связь влияния?",
|
||||
"noEdges": "Связи не найдены.",
|
||||
"stepUpload": "1. Файл",
|
||||
"stepMapping": "2. Столбцы",
|
||||
"stepPreview": "3. Проверка",
|
||||
"stepDone": "4. Готово",
|
||||
"uploadHelp": "Выберите CSV, JSON или XLSX со связями влияния (например Inputs/artist_influences_web_sources.xlsx).",
|
||||
"parseFailed": "Не удалось разобрать файл",
|
||||
"previewFailed": "Не удалось построить превью",
|
||||
"commitFailed": "Не удалось выполнить импорт",
|
||||
"rowsMissing": "Строки файла не получены (слишком большой файл). Используйте ≤2000 строк.",
|
||||
"fileInfo": "{{name}} · {{rows}} строк · {{format}}",
|
||||
"sheet": "Лист",
|
||||
"preset": "Шаблон сопоставления",
|
||||
"column": "Столбец",
|
||||
"role": "Роль",
|
||||
"sample": "Пример",
|
||||
"backStep": "Назад",
|
||||
"runPreview": "Проверить",
|
||||
"previewCounts": "Создать {{create}} · пропустить {{skip}} · ошибки строк {{errors}} · предложений {{proposals}}",
|
||||
"warnings": "{{count}} предупреждений",
|
||||
"commitImport": "Импортировать {{count}} связей",
|
||||
"commitSummary": "Импортировано {{inserted}} (пропущено {{skipped}}).",
|
||||
"alreadyImportedWarn": "Уже импортировано {{when}} пользователем {{who}} ({{match}}: {{file}}). Импорт заблокирован, пока не включите принудительный повтор.",
|
||||
"alreadyImportedBlock": "Этот файл или те же данные уже импортировались. Включите «Импортировать всё равно» или отмените.",
|
||||
"forceImport": "Импортировать всё равно (принудительно — существующие связи остаются уникальными)",
|
||||
"matchFile": "те же байты файла",
|
||||
"matchData": "те же сопоставленные данные",
|
||||
"searchArtist": "Поиск художника для графа…",
|
||||
"graphEmpty": "Выберите художника, чтобы увидеть соседей по влиянию.",
|
||||
"graphStats": "{{nodes}} узлов · {{edges}} рёбер"
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import PaintingDetailView from '../components/PaintingDetail';
|
||||
import ArtistBio from '../components/ArtistBio';
|
||||
import CheckupPage from '../pages/CheckupPage';
|
||||
import TranslationsPage from '../pages/TranslationsPage';
|
||||
import InfluencesPage from '../pages/InfluencesPage';
|
||||
import CuratorLoginModal from '../components/CuratorLoginModal';
|
||||
import CatalogSearchBar from '../components/CatalogSearchBar';
|
||||
import LocaleSwitcher from '../components/LocaleSwitcher';
|
||||
@@ -16,6 +17,7 @@ import '../components/CatalogSearchBar.css';
|
||||
import '../components/CuratorLoginModal.css';
|
||||
import '../components/LocaleSwitcher.css';
|
||||
import '../pages/TranslationsPage.css';
|
||||
import '../pages/InfluencesPage.css';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
||||
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail, MovementGalleryDetail } from '../types';
|
||||
@@ -28,6 +30,7 @@ type View =
|
||||
| { type: 'timeline' }
|
||||
| { type: 'checkup' }
|
||||
| { type: 'translations' }
|
||||
| { type: 'influences' }
|
||||
| { type: 'gallery'; artistId: number; data: ArtistDetail }
|
||||
| { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail }
|
||||
| { type: 'painting'; paintingId: number; data: PaintingDetail; returnTo: View }
|
||||
@@ -128,7 +131,7 @@ export default function HomePage() {
|
||||
const [debugMode, setDebugMode] = useState(readDebugMode);
|
||||
const [debugShowMore, setDebugShowMore] = useState(readDebugShowMore);
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
const [loginRedirect, setLoginRedirect] = useState<'checkup' | 'translations' | null>(null);
|
||||
const [loginRedirect, setLoginRedirect] = useState<'checkup' | 'translations' | 'influences' | null>(null);
|
||||
const effectiveDebugMode = debugMode && isCurator;
|
||||
const [galleryRevision, setGalleryRevision] = useState(0);
|
||||
const viewRef = useRef(view);
|
||||
@@ -222,7 +225,7 @@ export default function HomePage() {
|
||||
writeDebugShowMore(enabled);
|
||||
};
|
||||
|
||||
const openCuratorLogin = (redirect: 'checkup' | 'translations' | null = null) => {
|
||||
const openCuratorLogin = (redirect: 'checkup' | 'translations' | 'influences' | null = null) => {
|
||||
setLoginRedirect(redirect);
|
||||
setLoginOpen(true);
|
||||
};
|
||||
@@ -234,6 +237,8 @@ export default function HomePage() {
|
||||
setView({ type: 'checkup' });
|
||||
} else if (loginRedirect === 'translations') {
|
||||
setView({ type: 'translations' });
|
||||
} else if (loginRedirect === 'influences') {
|
||||
setView({ type: 'influences' });
|
||||
}
|
||||
setLoginRedirect(null);
|
||||
};
|
||||
@@ -242,7 +247,7 @@ export default function HomePage() {
|
||||
await logout();
|
||||
writeDebugMode(false);
|
||||
setDebugMode(false);
|
||||
if (view.type === 'checkup' || view.type === 'translations') {
|
||||
if (view.type === 'checkup' || view.type === 'translations' || view.type === 'influences') {
|
||||
goToTimelineHome();
|
||||
}
|
||||
};
|
||||
@@ -263,6 +268,14 @@ export default function HomePage() {
|
||||
setView({ type: 'translations' });
|
||||
};
|
||||
|
||||
const openInfluences = () => {
|
||||
if (!isCurator) {
|
||||
openCuratorLogin('influences');
|
||||
return;
|
||||
}
|
||||
setView({ type: 'influences' });
|
||||
};
|
||||
|
||||
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
|
||||
const data = await api.getPainting(paintingId);
|
||||
const patch: Partial<Painting> = {
|
||||
@@ -746,6 +759,25 @@ export default function HomePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.type === 'influences' && (
|
||||
isCurator ? (
|
||||
<InfluencesPage onBack={goToTimelineHome} />
|
||||
) : (
|
||||
<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('influences')}>
|
||||
{t('curatorLogin')}
|
||||
</button>
|
||||
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
|
||||
{t('backToGalleryBtn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{view.type === 'translations' && (
|
||||
isCurator ? (
|
||||
<TranslationsPage onBack={goToTimelineHome} />
|
||||
@@ -824,6 +856,14 @@ export default function HomePage() {
|
||||
/>
|
||||
Show more
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="checkup-link-btn"
|
||||
onClick={openInfluences}
|
||||
title="Manage influence links"
|
||||
>
|
||||
{t('influences')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="checkup-link-btn"
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
.influences-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem 1.5rem 3rem;
|
||||
color: #e8d5b5;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.influences-loading-overlay.gallery-loading-marker-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
background: rgba(10, 10, 20, 0.72);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.influences-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.influences-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 650;
|
||||
flex: 1;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-back {
|
||||
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;
|
||||
}
|
||||
|
||||
.influences-back:hover {
|
||||
border-color: #c9a96e;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-tabs {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.influences-tabs button {
|
||||
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.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.influences-tabs button:hover {
|
||||
border-color: #c9a96e;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-tabs button.active {
|
||||
background: rgba(232, 160, 64, 0.2);
|
||||
color: #e8d5b5;
|
||||
border-color: #e8a040;
|
||||
}
|
||||
|
||||
.influences-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;
|
||||
}
|
||||
|
||||
.influences-success {
|
||||
background: rgba(22, 101, 52, 0.35);
|
||||
border: 1px solid rgba(134, 239, 172, 0.35);
|
||||
color: #bbf7d0;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.influences-dup-warn {
|
||||
background: rgba(120, 53, 15, 0.45);
|
||||
border: 1px solid rgba(251, 191, 36, 0.45);
|
||||
color: #fde68a;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.influences-dup-warn p {
|
||||
margin: 0;
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.influences-force {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #e8d5b5;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.influences-force input {
|
||||
accent-color: #e8a040;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.influences-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.influences-toolbar input[type='search'],
|
||||
.influences-add input,
|
||||
.influences-add textarea,
|
||||
.influences-add select,
|
||||
.wizard-block select,
|
||||
.wizard-block input {
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
border-radius: 6px;
|
||||
padding: 0.45rem 0.65rem;
|
||||
min-width: 12rem;
|
||||
font: inherit;
|
||||
background: rgba(15, 15, 26, 0.85);
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-toolbar input::placeholder,
|
||||
.influences-add input::placeholder,
|
||||
.wizard-block input::placeholder {
|
||||
color: rgba(201, 169, 110, 0.5);
|
||||
}
|
||||
|
||||
.influences-toolbar button,
|
||||
.wizard-actions button,
|
||||
.influences-add button,
|
||||
.influences-hits button {
|
||||
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;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.influences-toolbar button:hover,
|
||||
.wizard-actions button:hover,
|
||||
.influences-add button:hover,
|
||||
.influences-hits button:hover {
|
||||
border-color: #c9a96e;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-meta {
|
||||
color: rgba(201, 169, 110, 0.75);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.influences-add {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid rgba(201, 169, 110, 0.25);
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 15, 26, 0.55);
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-add label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-hits {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.influences-table-wrap {
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(201, 169, 110, 0.25);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.influences-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-table th,
|
||||
.influences-table td {
|
||||
border-bottom: 1px solid rgba(201, 169, 110, 0.15);
|
||||
padding: 0.55rem 0.65rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-table th {
|
||||
background: rgba(15, 15, 26, 0.85);
|
||||
font-weight: 600;
|
||||
color: #c9a96e;
|
||||
}
|
||||
|
||||
.notes-cell {
|
||||
max-width: 18rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: rgba(201, 169, 110, 0.65);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.linkish {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #e8a040;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.linkish:hover {
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: #ffaaaa !important;
|
||||
border-color: rgba(255, 170, 170, 0.4) !important;
|
||||
}
|
||||
|
||||
.wizard-steps {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wizard-steps li {
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 15, 26, 0.85);
|
||||
border: 1px solid rgba(201, 169, 110, 0.25);
|
||||
color: rgba(201, 169, 110, 0.75);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.wizard-steps li.active {
|
||||
background: rgba(232, 160, 64, 0.2);
|
||||
border-color: #e8a040;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.wizard-block {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.wizard-block p,
|
||||
.wizard-block label,
|
||||
.wizard-block summary {
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.wizard-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.warnings-list {
|
||||
max-height: 12rem;
|
||||
overflow: auto;
|
||||
font-size: 0.85rem;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.mapping-table select {
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
.influences-graph h2 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.15rem;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-svg {
|
||||
width: 100%;
|
||||
max-width: 760px;
|
||||
height: auto;
|
||||
background: rgba(15, 15, 26, 0.65);
|
||||
border: 1px solid rgba(201, 169, 110, 0.25);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.influences-svg .edge-in {
|
||||
stroke: rgba(201, 169, 110, 0.55);
|
||||
stroke-width: 1.5;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.influences-svg .edge-out {
|
||||
stroke: #e8a040;
|
||||
stroke-width: 1.5;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.influences-svg .node circle {
|
||||
fill: rgba(201, 169, 110, 0.35);
|
||||
stroke: #c9a96e;
|
||||
stroke-width: 1.5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.influences-svg .node-artist circle {
|
||||
fill: rgba(96, 165, 250, 0.35);
|
||||
stroke: #93c5fd;
|
||||
}
|
||||
|
||||
.influences-svg .node-movement circle {
|
||||
fill: rgba(74, 222, 128, 0.3);
|
||||
stroke: #86efac;
|
||||
}
|
||||
|
||||
.influences-svg .node-painting circle {
|
||||
fill: rgba(251, 146, 60, 0.3);
|
||||
stroke: #fdba74;
|
||||
}
|
||||
|
||||
.influences-svg .node.focus circle {
|
||||
fill: #e8a040;
|
||||
stroke: #e8d5b5;
|
||||
}
|
||||
|
||||
.influences-svg .node text {
|
||||
font-size: 10px;
|
||||
fill: #e8d5b5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.influences-svg .node.focus text {
|
||||
font-weight: 650;
|
||||
fill: #e8d5b5;
|
||||
}
|
||||
@@ -0,0 +1,831 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
api,
|
||||
type InfluenceEdgeItem,
|
||||
type InfluenceGraph,
|
||||
type InfluenceImportParseResult,
|
||||
type InfluenceImportPreview,
|
||||
type InfluenceImportProposal,
|
||||
} from '../api/client';
|
||||
import GalleryLoadingMarker from '../components/GalleryLoadingMarker';
|
||||
import './InfluencesPage.css';
|
||||
|
||||
type Tab = 'list' | 'import' | 'graph';
|
||||
type WizardStep = 'upload' | 'mapping' | 'preview' | 'done';
|
||||
|
||||
interface Props {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
'ignore',
|
||||
'subject_artist',
|
||||
'subject_painting',
|
||||
'influenced_by',
|
||||
'influenced',
|
||||
'notes',
|
||||
'reference',
|
||||
'source_url',
|
||||
] as const;
|
||||
|
||||
export default function InfluencesPage({ onBack }: Props) {
|
||||
const { t } = useTranslation('influences');
|
||||
const [tab, setTab] = useState<Tab>('list');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// List
|
||||
const [items, setItems] = useState<InfluenceEdgeItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [q, setQ] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filterArtistId, setFilterArtistId] = useState<number | null>(null);
|
||||
|
||||
// Add form
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [addPaintingQuery, setAddPaintingQuery] = useState('');
|
||||
const [addSourceQuery, setAddSourceQuery] = useState('');
|
||||
const [addSourceType, setAddSourceType] = useState<'artist' | 'painting' | 'movement'>('artist');
|
||||
const [addPaintingId, setAddPaintingId] = useState<number | null>(null);
|
||||
const [addSourceId, setAddSourceId] = useState<number | null>(null);
|
||||
const [addNotes, setAddNotes] = useState('');
|
||||
const [searchHits, setSearchHits] = useState<Array<{ type: string; id: number; label: string }>>([]);
|
||||
const [sourceHits, setSourceHits] = useState<Array<{ type: string; id: number; label: string }>>([]);
|
||||
|
||||
// Graph
|
||||
const [graphArtistQuery, setGraphArtistQuery] = useState('');
|
||||
const [graphArtistId, setGraphArtistId] = useState<number | null>(null);
|
||||
const [graphArtistHits, setGraphArtistHits] = useState<Array<{ id: number; label: string }>>([]);
|
||||
const [graph, setGraph] = useState<InfluenceGraph | null>(null);
|
||||
|
||||
// Import wizard
|
||||
const [wizardStep, setWizardStep] = useState<WizardStep>('upload');
|
||||
const [parseResult, setParseResult] = useState<InfluenceImportParseResult | null>(null);
|
||||
const [mapping, setMapping] = useState<Record<string, string>>({});
|
||||
const [selectedSheet, setSelectedSheet] = useState<string | null>(null);
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<InfluenceImportPreview | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [busyMessage, setBusyMessage] = useState('');
|
||||
const [commitResult, setCommitResult] = useState<{ inserted: number; skipped: number } | null>(null);
|
||||
const [forceImport, setForceImport] = useState(false);
|
||||
|
||||
const startBusy = (message: string) => {
|
||||
setBusyMessage(message);
|
||||
setBusy(true);
|
||||
};
|
||||
|
||||
const stopBusy = () => {
|
||||
setBusy(false);
|
||||
setBusyMessage('');
|
||||
};
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.listInfluences({
|
||||
q: q || undefined,
|
||||
artistId: filterArtistId || undefined,
|
||||
limit: 200,
|
||||
});
|
||||
setItems(data.items);
|
||||
setTotal(data.total);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [q, filterArtistId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === 'list') void loadList();
|
||||
}, [tab, loadList]);
|
||||
|
||||
const runSearch = async (query: string, types: string) => {
|
||||
if (query.trim().length < 2) return [] as Array<{ type: string; id: number; label: string }>;
|
||||
const data = await api.search(query.trim(), { types, limit: 12 });
|
||||
return data.results.map((r) => {
|
||||
if (r.type === 'painting') {
|
||||
return { type: r.type, id: r.id, label: `${r.artist_name} — ${r.title}` };
|
||||
}
|
||||
return { type: r.type, id: r.id, label: r.name };
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
setSearchHits(await runSearch(addPaintingQuery, 'painting'));
|
||||
} catch {
|
||||
setSearchHits([]);
|
||||
}
|
||||
})();
|
||||
}, 250);
|
||||
return () => clearTimeout(handle);
|
||||
}, [addPaintingQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
setSourceHits(await runSearch(addSourceQuery, addSourceType));
|
||||
} catch {
|
||||
setSourceHits([]);
|
||||
}
|
||||
})();
|
||||
}, 250);
|
||||
return () => clearTimeout(handle);
|
||||
}, [addSourceQuery, addSourceType]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const hits = await runSearch(graphArtistQuery, 'artist');
|
||||
setGraphArtistHits(hits.filter((h) => h.type === 'artist').map((h) => ({ id: h.id, label: h.label })));
|
||||
} catch {
|
||||
setGraphArtistHits([]);
|
||||
}
|
||||
})();
|
||||
}, 250);
|
||||
return () => clearTimeout(handle);
|
||||
}, [graphArtistQuery]);
|
||||
|
||||
const loadGraph = async (artistId: number) => {
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.getInfluenceGraph({ artistId });
|
||||
setGraph(data);
|
||||
setGraphArtistId(artistId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!window.confirm(t('confirmDelete'))) return;
|
||||
try {
|
||||
await api.deleteInfluence(id);
|
||||
await loadList();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!addPaintingId || !addSourceId) {
|
||||
setError(t('addRequiresIds'));
|
||||
return;
|
||||
}
|
||||
startBusy(t('loadingSave'));
|
||||
setError(null);
|
||||
try {
|
||||
const payload: Parameters<typeof api.createInfluence>[0] = {
|
||||
paintingId: addPaintingId,
|
||||
sourceType: addSourceType,
|
||||
notes: addNotes || undefined,
|
||||
source: 'curator-ui',
|
||||
};
|
||||
if (addSourceType === 'artist') payload.sourceArtistId = addSourceId;
|
||||
if (addSourceType === 'painting') payload.sourcePaintingId = addSourceId;
|
||||
if (addSourceType === 'movement') payload.sourceMovementId = addSourceId;
|
||||
await api.createInfluence(payload);
|
||||
setShowAdd(false);
|
||||
setAddPaintingId(null);
|
||||
setAddSourceId(null);
|
||||
setAddNotes('');
|
||||
await loadList();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
stopBusy();
|
||||
}
|
||||
};
|
||||
|
||||
const onFileChosen = async (file: File | null) => {
|
||||
if (!file) return;
|
||||
setPendingFile(file);
|
||||
startBusy(t('loadingParse'));
|
||||
setError(null);
|
||||
setCommitResult(null);
|
||||
setForceImport(false);
|
||||
try {
|
||||
const parsed = await api.parseInfluenceImport(file);
|
||||
setParseResult(parsed);
|
||||
setMapping(parsed.suggestedMapping);
|
||||
setSelectedSheet(parsed.sheet);
|
||||
setWizardStep('mapping');
|
||||
setPreview(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('parseFailed'));
|
||||
} finally {
|
||||
stopBusy();
|
||||
}
|
||||
};
|
||||
|
||||
const reparseSheet = async (sheet: string) => {
|
||||
if (!pendingFile) return;
|
||||
startBusy(t('loadingParse'));
|
||||
try {
|
||||
const parsed = await api.parseInfluenceImport(pendingFile, sheet);
|
||||
setParseResult(parsed);
|
||||
setMapping(parsed.suggestedMapping);
|
||||
setSelectedSheet(sheet);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('parseFailed'));
|
||||
} finally {
|
||||
stopBusy();
|
||||
}
|
||||
};
|
||||
|
||||
const applyPreset = (presetId: string) => {
|
||||
if (!parseResult) return;
|
||||
const preset = parseResult.presets.find((p) => p.id === presetId);
|
||||
if (!preset) return;
|
||||
const next: Record<string, string> = {};
|
||||
for (const col of parseResult.columns) next[col] = 'ignore';
|
||||
for (const [col, role] of Object.entries(preset.mapping)) {
|
||||
if (parseResult.columns.includes(col)) next[col] = role;
|
||||
}
|
||||
// Fill gaps with auto suggestions
|
||||
for (const [col, role] of Object.entries(parseResult.suggestedMapping)) {
|
||||
if (next[col] === 'ignore' && role !== 'ignore') next[col] = role;
|
||||
}
|
||||
setMapping(next);
|
||||
};
|
||||
|
||||
const runPreview = async () => {
|
||||
if (!parseResult?.rows) {
|
||||
setError(t('rowsMissing'));
|
||||
return;
|
||||
}
|
||||
startBusy(t('loadingPreview'));
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.previewInfluenceImport({
|
||||
rows: parseResult.rows,
|
||||
mapping,
|
||||
sourceLabel: parseResult.filename,
|
||||
contentHash: parseResult.contentHash,
|
||||
payloadHash: parseResult.payloadHash,
|
||||
});
|
||||
setPreview(result);
|
||||
setWizardStep('preview');
|
||||
if (result.alreadyImported) setForceImport(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('previewFailed'));
|
||||
} finally {
|
||||
stopBusy();
|
||||
}
|
||||
};
|
||||
|
||||
const runCommit = async () => {
|
||||
if (!preview) return;
|
||||
const already = preview.alreadyImported || parseResult?.alreadyImported;
|
||||
if (already && !forceImport) {
|
||||
setError(t('alreadyImportedBlock'));
|
||||
return;
|
||||
}
|
||||
startBusy(t('loadingCommit'));
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.commitInfluenceImport({
|
||||
proposals: preview.proposals,
|
||||
fileName: parseResult?.filename,
|
||||
contentHash: preview.contentHash || parseResult?.contentHash,
|
||||
payloadHash: preview.payloadHash || parseResult?.payloadHash,
|
||||
force: forceImport,
|
||||
});
|
||||
setCommitResult(result);
|
||||
setWizardStep('done');
|
||||
await loadList();
|
||||
} catch (err) {
|
||||
const e = err as Error & { code?: string };
|
||||
if (e.code === 'ALREADY_IMPORTED') {
|
||||
setError(t('alreadyImportedBlock'));
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : t('commitFailed'));
|
||||
}
|
||||
} finally {
|
||||
stopBusy();
|
||||
}
|
||||
};
|
||||
|
||||
const priorImport = preview?.priorImport || parseResult?.priorImport || null;
|
||||
const alreadyImported = Boolean(preview?.alreadyImported || parseResult?.alreadyImported);
|
||||
|
||||
const createProposals = useMemo(
|
||||
() => (preview?.proposals || []).filter((p) => p.action === 'create').slice(0, 200),
|
||||
[preview],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="influences-page">
|
||||
{busy && (
|
||||
<GalleryLoadingMarker
|
||||
overlay
|
||||
className="influences-loading-overlay"
|
||||
message={busyMessage || t('loading')}
|
||||
/>
|
||||
)}
|
||||
<header className="influences-header">
|
||||
<button type="button" className="influences-back" onClick={onBack}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<h1>{t('title')}</h1>
|
||||
<nav className="influences-tabs">
|
||||
<button type="button" className={tab === 'list' ? 'active' : ''} onClick={() => setTab('list')}>
|
||||
{t('tabList')}
|
||||
</button>
|
||||
<button type="button" className={tab === 'import' ? 'active' : ''} onClick={() => setTab('import')}>
|
||||
{t('tabImport')}
|
||||
</button>
|
||||
<button type="button" className={tab === 'graph' ? 'active' : ''} onClick={() => setTab('graph')}>
|
||||
{t('tabGraph')}
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{error && <div className="influences-error">{error}</div>}
|
||||
|
||||
{tab === 'list' && (
|
||||
<section className="influences-panel">
|
||||
<div className="influences-toolbar">
|
||||
<input
|
||||
type="search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder={t('searchPlaceholder')}
|
||||
/>
|
||||
<button type="button" onClick={() => void loadList()} disabled={loading}>
|
||||
{loading ? t('loading') : t('refresh')}
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowAdd((v) => !v)}>
|
||||
{showAdd ? t('cancelAdd') : t('addEdge')}
|
||||
</button>
|
||||
<span className="influences-meta">
|
||||
{t('total', { count: total })}
|
||||
{filterArtistId ? ` · ${t('filtered')}` : ''}
|
||||
</span>
|
||||
{filterArtistId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFilterArtistId(null);
|
||||
}}
|
||||
>
|
||||
{t('clearFilter')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAdd && (
|
||||
<div className="influences-add">
|
||||
<label>
|
||||
{t('subjectPainting')}
|
||||
<input
|
||||
value={addPaintingQuery}
|
||||
onChange={(e) => {
|
||||
setAddPaintingQuery(e.target.value);
|
||||
setAddPaintingId(null);
|
||||
}}
|
||||
placeholder={t('searchPainting')}
|
||||
/>
|
||||
</label>
|
||||
{searchHits.length > 0 && !addPaintingId && (
|
||||
<ul className="influences-hits">
|
||||
{searchHits.map((h) => (
|
||||
<li key={`${h.type}-${h.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAddPaintingId(h.id);
|
||||
setAddPaintingQuery(h.label);
|
||||
}}
|
||||
>
|
||||
{h.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<label>
|
||||
{t('sourceType')}
|
||||
<select
|
||||
value={addSourceType}
|
||||
onChange={(e) => {
|
||||
setAddSourceType(e.target.value as 'artist' | 'painting' | 'movement');
|
||||
setAddSourceId(null);
|
||||
setAddSourceQuery('');
|
||||
}}
|
||||
>
|
||||
<option value="artist">{t('typeArtist')}</option>
|
||||
<option value="painting">{t('typePainting')}</option>
|
||||
<option value="movement">{t('typeMovement')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t('sourceEntity')}
|
||||
<input
|
||||
value={addSourceQuery}
|
||||
onChange={(e) => {
|
||||
setAddSourceQuery(e.target.value);
|
||||
setAddSourceId(null);
|
||||
}}
|
||||
placeholder={t('searchSource')}
|
||||
/>
|
||||
</label>
|
||||
{sourceHits.length > 0 && !addSourceId && (
|
||||
<ul className="influences-hits">
|
||||
{sourceHits.map((h) => (
|
||||
<li key={`${h.type}-${h.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAddSourceId(h.id);
|
||||
setAddSourceQuery(h.label);
|
||||
}}
|
||||
>
|
||||
{h.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<label>
|
||||
{t('notes')}
|
||||
<textarea value={addNotes} onChange={(e) => setAddNotes(e.target.value)} rows={2} />
|
||||
</label>
|
||||
<button type="button" disabled={busy} onClick={() => void handleCreate()}>
|
||||
{t('saveEdge')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="influences-table-wrap">
|
||||
<table className="influences-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('colSubject')}</th>
|
||||
<th>{t('colSource')}</th>
|
||||
<th>{t('colType')}</th>
|
||||
<th>{t('colNotes')}</th>
|
||||
<th>{t('colActions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="linkish"
|
||||
onClick={() => {
|
||||
setFilterArtistId(item.artistId);
|
||||
setTab('graph');
|
||||
void loadGraph(item.artistId);
|
||||
}}
|
||||
>
|
||||
{item.artistName}
|
||||
</button>
|
||||
<div className="muted">{item.paintingTitle}</div>
|
||||
</td>
|
||||
<td>{item.sourceLabel || '—'}</td>
|
||||
<td>{item.sourceType}</td>
|
||||
<td className="notes-cell">{item.notes || '—'}</td>
|
||||
<td>
|
||||
<button type="button" className="danger" onClick={() => void handleDelete(item.id)}>
|
||||
{t('delete')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5}>{t('noEdges')}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === 'import' && (
|
||||
<section className="influences-panel">
|
||||
<ol className="wizard-steps">
|
||||
<li className={wizardStep === 'upload' ? 'active' : ''}>{t('stepUpload')}</li>
|
||||
<li className={wizardStep === 'mapping' ? 'active' : ''}>{t('stepMapping')}</li>
|
||||
<li className={wizardStep === 'preview' ? 'active' : ''}>{t('stepPreview')}</li>
|
||||
<li className={wizardStep === 'done' ? 'active' : ''}>{t('stepDone')}</li>
|
||||
</ol>
|
||||
|
||||
{(wizardStep === 'upload' || wizardStep === 'done') && (
|
||||
<div className="wizard-block">
|
||||
<p>{t('uploadHelp')}</p>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.json,.xlsx,.xls,application/json,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
onChange={(e) => void onFileChosen(e.target.files?.[0] || null)}
|
||||
disabled={busy}
|
||||
/>
|
||||
{commitResult && (
|
||||
<p className="influences-success">
|
||||
{t('commitSummary', { inserted: commitResult.inserted, skipped: commitResult.skipped })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wizardStep === 'mapping' && parseResult && (
|
||||
<div className="wizard-block">
|
||||
<p>
|
||||
{t('fileInfo', {
|
||||
name: parseResult.filename,
|
||||
rows: parseResult.rowCount,
|
||||
format: parseResult.format,
|
||||
})}
|
||||
</p>
|
||||
{alreadyImported && priorImport && (
|
||||
<div className="influences-dup-warn">
|
||||
<p>
|
||||
{t('alreadyImportedWarn', {
|
||||
when: new Date(priorImport.importedAt).toLocaleString(),
|
||||
who: priorImport.username || '—',
|
||||
file: priorImport.fileName || parseResult.filename,
|
||||
match: priorImport.match === 'file' ? t('matchFile') : t('matchData'),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{parseResult.sheets && parseResult.sheets.length > 1 && (
|
||||
<label>
|
||||
{t('sheet')}
|
||||
<select
|
||||
value={selectedSheet || ''}
|
||||
onChange={(e) => void reparseSheet(e.target.value)}
|
||||
>
|
||||
{parseResult.sheets.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
{t('preset')}
|
||||
<select
|
||||
defaultValue={parseResult.suggestedPreset}
|
||||
onChange={(e) => applyPreset(e.target.value)}
|
||||
>
|
||||
{parseResult.presets.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<table className="influences-table mapping-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('column')}</th>
|
||||
<th>{t('role')}</th>
|
||||
<th>{t('sample')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{parseResult.columns.map((col) => (
|
||||
<tr key={col}>
|
||||
<td>{col}</td>
|
||||
<td>
|
||||
<select
|
||||
value={mapping[col] || 'ignore'}
|
||||
onChange={(e) => setMapping((m) => ({ ...m, [col]: e.target.value }))}
|
||||
>
|
||||
{ROLE_OPTIONS.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{role}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="muted">
|
||||
{parseResult.sampleRows[0]?.[col]?.slice(0, 80) || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="wizard-actions">
|
||||
<button type="button" onClick={() => setWizardStep('upload')}>
|
||||
{t('backStep')}
|
||||
</button>
|
||||
<button type="button" disabled={busy || !parseResult.rows} onClick={() => void runPreview()}>
|
||||
{t('runPreview')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wizardStep === 'preview' && preview && (
|
||||
<div className="wizard-block">
|
||||
<p>
|
||||
{t('previewCounts', {
|
||||
create: preview.counts.willCreate,
|
||||
skip: preview.counts.willSkip,
|
||||
errors: preview.counts.errors,
|
||||
proposals: preview.counts.proposals,
|
||||
})}
|
||||
</p>
|
||||
{alreadyImported && priorImport && (
|
||||
<div className="influences-dup-warn">
|
||||
<p>
|
||||
{t('alreadyImportedWarn', {
|
||||
when: new Date(priorImport.importedAt).toLocaleString(),
|
||||
who: priorImport.username || '—',
|
||||
file: priorImport.fileName || parseResult?.filename || '—',
|
||||
match: priorImport.match === 'file' ? t('matchFile') : t('matchData'),
|
||||
})}
|
||||
</p>
|
||||
<label className="influences-force">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={forceImport}
|
||||
onChange={(e) => setForceImport(e.target.checked)}
|
||||
/>
|
||||
{t('forceImport')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{preview.warnings.length > 0 && (
|
||||
<details open={preview.warnings.length < 30}>
|
||||
<summary>
|
||||
{t('warnings', { count: preview.warnings.length })}
|
||||
</summary>
|
||||
<ul className="warnings-list">
|
||||
{preview.warnings.slice(0, 80).map((w, i) => (
|
||||
<li key={`${w.rowIndex}-${i}`}>
|
||||
#{w.rowIndex + 1}: {w.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
<div className="influences-table-wrap">
|
||||
<table className="influences-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('colAction')}</th>
|
||||
<th>{t('colSubject')}</th>
|
||||
<th>{t('colSource')}</th>
|
||||
<th>{t('colType')}</th>
|
||||
<th>{t('colDirection')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{createProposals.map((p: InfluenceImportProposal) => (
|
||||
<tr key={p.edgeKey}>
|
||||
<td>{p.action}</td>
|
||||
<td>
|
||||
{p.artistName}
|
||||
<div className="muted">{p.paintingTitle}</div>
|
||||
</td>
|
||||
<td>{p.sourceLabel}</td>
|
||||
<td>{p.sourceType}</td>
|
||||
<td>{p.direction}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="wizard-actions">
|
||||
<button type="button" onClick={() => setWizardStep('mapping')}>
|
||||
{t('backStep')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
busy
|
||||
|| preview.counts.willCreate === 0
|
||||
|| (alreadyImported && !forceImport)
|
||||
}
|
||||
onClick={() => void runCommit()}
|
||||
>
|
||||
{t('commitImport', { count: preview.counts.willCreate })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === 'graph' && (
|
||||
<section className="influences-panel">
|
||||
<div className="influences-toolbar">
|
||||
<input
|
||||
type="search"
|
||||
value={graphArtistQuery}
|
||||
onChange={(e) => setGraphArtistQuery(e.target.value)}
|
||||
placeholder={t('searchArtist')}
|
||||
/>
|
||||
</div>
|
||||
{graphArtistHits.length > 0 && (
|
||||
<ul className="influences-hits">
|
||||
{graphArtistHits.map((h) => (
|
||||
<li key={h.id}>
|
||||
<button type="button" onClick={() => void loadGraph(h.id)}>
|
||||
{h.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{graph && (
|
||||
<>
|
||||
{graphArtistId != null && (
|
||||
<p className="muted">Artist id: {graphArtistId}</p>
|
||||
)}
|
||||
<InfluenceGraphSvg graph={graph} onSelectArtist={(id) => {
|
||||
setFilterArtistId(id);
|
||||
setTab('list');
|
||||
}} />
|
||||
</>
|
||||
)}
|
||||
{!graph && <p className="muted">{t('graphEmpty')}</p>}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfluenceGraphSvg({
|
||||
graph,
|
||||
onSelectArtist,
|
||||
}: {
|
||||
graph: InfluenceGraph;
|
||||
onSelectArtist: (artistId: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('influences');
|
||||
const width = 720;
|
||||
const height = 420;
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
const focus = graph.nodes.find((n) => n.focus) || graph.nodes[0];
|
||||
const others = graph.nodes.filter((n) => n.id !== focus?.id);
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
if (focus) positions.set(focus.id, { x: cx, y: cy });
|
||||
others.forEach((n, i) => {
|
||||
const angle = (Math.PI * 2 * i) / Math.max(others.length, 1) - Math.PI / 2;
|
||||
const r = 140 + (i % 3) * 28;
|
||||
positions.set(n.id, { x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r });
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="influences-graph">
|
||||
<h2>{graph.focus.label}</h2>
|
||||
<p className="muted">
|
||||
{t('graphStats', { nodes: graph.nodes.length, edges: graph.edges.length })}
|
||||
</p>
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="influences-svg" role="img">
|
||||
{graph.edges.map((e) => {
|
||||
const a = positions.get(e.from);
|
||||
const b = positions.get(e.to);
|
||||
if (!a || !b) return null;
|
||||
return (
|
||||
<line
|
||||
key={`${e.id}-${e.from}-${e.to}`}
|
||||
x1={a.x}
|
||||
y1={a.y}
|
||||
x2={b.x}
|
||||
y2={b.y}
|
||||
className={e.direction === 'influenced_by' ? 'edge-in' : 'edge-out'}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{graph.nodes.map((n) => {
|
||||
const p = positions.get(n.id);
|
||||
if (!p) return null;
|
||||
return (
|
||||
<g
|
||||
key={n.id}
|
||||
transform={`translate(${p.x},${p.y})`}
|
||||
className={`node node-${n.type}${n.focus ? ' focus' : ''}`}
|
||||
onClick={() => {
|
||||
if (n.artistId) onSelectArtist(n.artistId);
|
||||
}}
|
||||
>
|
||||
<circle r={n.focus ? 22 : 14} />
|
||||
<title>{n.label}</title>
|
||||
<text y={n.focus ? 36 : 28} textAnchor="middle">
|
||||
{n.label.length > 28 ? `${n.label.slice(0, 26)}…` : n.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user