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