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:
Danila Khodjaef
2026-07-16 18:54:26 +03:00
co-authored by Cursor
parent 62d7ebbe6a
commit 48bd17e985
21 changed files with 3402 additions and 15 deletions
+272
View File
@@ -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 }
+5 -1
View File
@@ -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 },
+1
View File
@@ -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"
+70
View File
@@ -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"
}
+1
View File
@@ -14,6 +14,7 @@
"showMoreDebug": "Показать больше (отладка)",
"checkup": "Проверка",
"translations": "Переводы",
"influences": "Влияния",
"curatorRequiredTitle": "Требуется доступ куратора",
"curatorRequiredBody": "Войдите как куратор, чтобы использовать этот инструмент.",
"backToGalleryBtn": "Вернуться в галерею"
+70
View File
@@ -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}} рёбер"
}
+43 -3
View File
@@ -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"
+378
View File
@@ -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;
}
+831
View File
@@ -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>
);
}