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
@@ -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