Files
Linking-Tool/src/components/IdentityGraph.svelte
Sudo-Ivan 5e07339709
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 16s
CI / check (push) Successful in 19s
CI / build (push) Successful in 32s
0.1.0
2025-12-24 18:43:08 -06:00

1734 lines
49 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import {
Plus,
Upload,
Download,
Image as ImageIcon,
Undo2,
Redo2,
Maximize,
Trash2,
X,
ChevronDown,
ChevronUp,
Share2,
Search,
} from 'lucide-svelte';
import { iconMap, nodeTypes, typeColors, type NodeType } from '$lib/identityGraph';
let autoSaveTimeout: ReturnType<typeof setTimeout> | null = null;
$: if (nodes && links && (nodes.length > 0 || links.length > 0)) {
if (autoSaveTimeout) clearTimeout(autoSaveTimeout);
autoSaveTimeout = setTimeout(() => {
saveToLocalStorage();
}, 2000);
}
type Node = {
id: string;
label: string;
type: NodeType;
x: number;
y: number;
notes?: string;
imageUrl?: string;
showLabel?: boolean;
showType?: boolean;
showNotes?: boolean;
};
type Link = {
id: string;
source: string;
target: string;
label: string;
};
type GraphState = {
nodes: Node[];
links: Link[];
};
let nodes: Node[] = [];
let links: Link[] = [];
const MAX_HISTORY = 50;
let undoStack: GraphState[] = [];
let redoStack: GraphState[] = [];
let transform = { x: 0, y: 0, k: 1 };
let isDragging = false;
let isPanning = false;
let isLinking = false;
let selectedNodeId: string | null = null;
let draggedNodeId: string | null = null;
let linkStartNodeId: string | null = null;
let hoverNodeId: string | null = null;
let editingLinkId: string | null = null;
let editingLinkLabel = '';
let selectedNode: Node | null = null;
type ConnectedEdge = {
id: string;
label: string;
otherNode: Node | null;
};
let connectedEdges: ConnectedEdge[] = [];
$: connectedEdges = selectedNode
? links
.filter(
(l) => selectedNode && (l.source === selectedNode.id || l.target === selectedNode.id)
)
.map((l) => {
if (!selectedNode) return null;
const otherId = l.source === selectedNode.id ? l.target : l.source;
return {
id: l.id,
label: l.label,
otherNode: nodes.find((n) => n.id === otherId) || null,
};
})
.filter((e): e is ConnectedEdge => e !== null)
: [];
let lastMouse = { x: 0, y: 0 };
let mouseWorld = { x: 0, y: 0 };
let svgElement: SVGSVGElement;
let containerElement: HTMLDivElement;
let showAddModal = false;
let newNodeLabel = '';
let newNodeType: NodeType = 'person';
let newNodeImage = '';
let newNodeNotes = '';
let editLabel = '';
let editType: NodeType = 'person';
let editImageUrl = '';
let editNotes = '';
let editShowLabel = true;
let editShowType = true;
let editShowNotes = true;
let inspectorNodeId: string | null = null;
let inspectorHistoryNodeId: string | null = null;
let inspectorHistoryCommitted = false;
let imageUploadInput: HTMLInputElement | null = null;
let modalImageUploadInput: HTMLInputElement | null = null;
let imageUploadError = '';
let newNodeImageError = '';
let controlsCollapsed = false;
let copiedNodes: Node[] = [];
let searchQuery = '';
let searchInput: HTMLInputElement | null = null;
let showSearch = false;
let linkEditInput: HTMLInputElement | null = null;
let addNodeInput: HTMLInputElement | null = null;
const ALLOWED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
$: filteredNodes = searchQuery.trim()
? nodes.filter(
(n) =>
n.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
n.type.toLowerCase().includes(searchQuery.toLowerCase()) ||
(n.notes && n.notes.toLowerCase().includes(searchQuery.toLowerCase()))
)
: nodes;
$: filteredLinks = searchQuery.trim()
? links.filter((l) => {
const source = nodes.find((n) => n.id === l.source);
const target = nodes.find((n) => n.id === l.target);
return (
l.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
(source && source.label.toLowerCase().includes(searchQuery.toLowerCase())) ||
(target && target.label.toLowerCase().includes(searchQuery.toLowerCase()))
);
})
: links;
$: selectedNode = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) || null : null;
$: if (selectedNode && selectedNode.id !== inspectorNodeId) {
hydrateInspectorFromNode(selectedNode);
} else if (!selectedNode && inspectorNodeId) {
clearInspector();
}
function hydrateInspectorFromNode(node: Node) {
editLabel = node.label;
editType = node.type;
editImageUrl = node.imageUrl || '';
editNotes = node.notes || '';
editShowLabel = node.showLabel !== false;
editShowType = node.showType !== false;
editShowNotes = node.showNotes !== false;
imageUploadError = '';
inspectorNodeId = node.id;
inspectorHistoryNodeId = node.id;
inspectorHistoryCommitted = false;
}
function clearInspector() {
editLabel = '';
editType = 'person';
editImageUrl = '';
editNotes = '';
editShowLabel = true;
editShowType = true;
editShowNotes = true;
imageUploadError = '';
inspectorNodeId = null;
inspectorHistoryNodeId = null;
inspectorHistoryCommitted = false;
}
function ensureInspectorHistory() {
if (!selectedNode) return;
if (inspectorHistoryNodeId !== selectedNode.id) {
inspectorHistoryNodeId = selectedNode.id;
inspectorHistoryCommitted = false;
}
if (!inspectorHistoryCommitted) {
pushState();
inspectorHistoryCommitted = true;
}
}
function updateSelectedNodeField(patch: Partial<Node>) {
if (!selectedNode) return;
ensureInspectorHistory();
nodes = nodes.map((n) =>
n.id === selectedNode.id
? {
...n,
...patch,
}
: n
);
saveToLocalStorage();
}
function handleLabelInput(value: string) {
editLabel = value;
updateSelectedNodeField({ label: value });
}
function handleLabelInputEvent(event: Event) {
const target = event.currentTarget as HTMLInputElement | null;
handleLabelInput(target?.value ?? '');
}
function handleTypeSelect(type: NodeType) {
editType = type;
updateSelectedNodeField({ type });
}
function handleImageUrlInput(value: string) {
editImageUrl = value;
const trimmed = value.trim();
updateSelectedNodeField({ imageUrl: trimmed || undefined });
imageUploadError = '';
}
function handleImageUrlInputEvent(event: Event) {
const target = event.currentTarget as HTMLInputElement | null;
handleImageUrlInput(target?.value ?? '');
}
function handleNotesInput(value: string) {
editNotes = value;
const trimmed = value.trim();
updateSelectedNodeField({ notes: trimmed || undefined });
}
function handleNotesInputEvent(event: Event) {
const target = event.currentTarget as HTMLTextAreaElement | null;
handleNotesInput(target?.value ?? '');
}
function toggleShowLabel() {
editShowLabel = !editShowLabel;
updateSelectedNodeField({ showLabel: editShowLabel });
}
function toggleShowType() {
editShowType = !editShowType;
updateSelectedNodeField({ showType: editShowType });
}
function toggleShowNotes() {
editShowNotes = !editShowNotes;
updateSelectedNodeField({ showNotes: editShowNotes });
}
function triggerImageUpload() {
imageUploadInput?.click();
}
function handleImageFileSelected(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
imageUploadError = 'Only PNG, JPG, or WebP images are allowed.';
input.value = '';
return;
}
if (file.size > MAX_IMAGE_BYTES) {
imageUploadError = 'Image must be under 2MB.';
input.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
editImageUrl = dataUrl;
updateSelectedNodeField({ imageUrl: dataUrl });
imageUploadError = '';
input.value = '';
};
reader.onerror = () => {
imageUploadError = 'Failed to read image.';
input.value = '';
};
reader.readAsDataURL(file);
}
function clearImage() {
editImageUrl = '';
updateSelectedNodeField({ imageUrl: undefined });
imageUploadError = '';
}
function triggerNewNodeImageUpload() {
modalImageUploadInput?.click();
}
function handleNewNodeImageFileSelected(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
newNodeImageError = 'Only PNG, JPG, or WebP images are allowed.';
input.value = '';
return;
}
if (file.size > MAX_IMAGE_BYTES) {
newNodeImageError = 'Image must be under 2MB.';
input.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => {
newNodeImage = reader.result as string;
newNodeImageError = '';
input.value = '';
};
reader.onerror = () => {
newNodeImageError = 'Failed to read image.';
input.value = '';
};
reader.readAsDataURL(file);
}
function clearNewNodeImage() {
newNodeImage = '';
newNodeImageError = '';
}
function closeInspector() {
selectedNodeId = null;
}
function pushState() {
const currentState: GraphState = {
nodes: JSON.parse(JSON.stringify(nodes)),
links: JSON.parse(JSON.stringify(links)),
};
undoStack = [...undoStack, currentState];
if (undoStack.length > MAX_HISTORY) undoStack.shift();
redoStack = [];
}
function undo() {
if (undoStack.length === 0) return;
const currentState = {
nodes: JSON.parse(JSON.stringify(nodes)),
links: JSON.parse(JSON.stringify(links)),
};
redoStack = [currentState, ...redoStack];
const prevState = undoStack.pop();
// eslint-disable-next-line no-self-assign
undoStack = undoStack;
if (prevState) {
nodes = prevState.nodes;
links = prevState.links;
}
}
function redo() {
if (redoStack.length === 0) return;
const currentState = {
nodes: JSON.parse(JSON.stringify(nodes)),
links: JSON.parse(JSON.stringify(links)),
};
undoStack = [...undoStack, currentState];
const nextState = redoStack.shift();
// eslint-disable-next-line no-self-assign
redoStack = redoStack;
if (nextState) {
nodes = nextState.nodes;
links = nextState.links;
}
}
function saveToLocalStorage() {
const data = { nodes, links, transform };
localStorage.setItem('quad4-linking-graph-v1', JSON.stringify(data));
}
function loadFromUrl() {
if (typeof window === 'undefined') return false;
const params = new URLSearchParams(window.location.search);
const encoded = params.get('graph');
if (!encoded) return false;
try {
const decoded = atob(encoded);
const data = JSON.parse(decoded);
if (data.nodes && data.links) {
pushState();
nodes = normalizeNodes(data.nodes);
links = data.links;
if (data.transform) {
transform = data.transform;
} else {
centerView();
}
window.history.replaceState({}, '', window.location.pathname);
return true;
}
} catch (e) {
console.error('Failed to load graph from URL', e);
}
return false;
}
function loadFromLocalStorage() {
const raw = localStorage.getItem('quad4-linking-graph-v1');
if (raw) {
try {
const data = JSON.parse(raw);
nodes = normalizeNodes(data.nodes || []);
links = data.links || [];
transform = data.transform || { x: 0, y: 0, k: 1 };
} catch (e) {
console.error('Failed to load graph', e);
}
}
}
async function shareGraph() {
if (nodes.length === 0 && links.length === 0) {
alert('Nothing to share. Add some nodes first.');
return;
}
try {
const data = {
version: 1,
timestamp: new Date().toISOString(),
nodes,
links,
transform,
};
const jsonData = JSON.stringify(data);
const encoded = btoa(jsonData);
const shareUrl = `${window.location.origin}${window.location.pathname}?graph=${encoded}`;
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(shareUrl);
alert('Share link copied to clipboard!');
} else {
const textarea = document.createElement('textarea');
textarea.value = shareUrl;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
alert('Share link copied to clipboard!');
} catch {
prompt('Copy this link:', shareUrl);
}
document.body.removeChild(textarea);
}
} catch (err) {
console.error('Failed to share graph', err);
alert(`Failed to create share link: ${err instanceof Error ? err.message : String(err)}`);
}
}
async function exportGraph() {
const data = {
version: 1,
timestamp: new Date().toISOString(),
nodes,
links,
};
try {
const jsonData = JSON.stringify(data, null, 2);
const blob = new Blob([jsonData], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `quad4-linking-graph-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (err) {
console.error('Failed to export graph', err);
alert(`Failed to export graph: ${err instanceof Error ? err.message : String(err)}`);
}
}
async function importGraph() {
try {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const text = await file.text();
let data;
try {
data = JSON.parse(text);
} catch (parseErr) {
console.error('JSON parse error:', parseErr);
alert(
`Failed to parse JSON: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`
);
return;
}
if (data.nodes && data.links) {
pushState();
nodes = normalizeNodes(data.nodes);
links = data.links;
centerView();
saveToLocalStorage();
} else {
alert('Invalid graph format: file must contain nodes and links arrays');
}
};
input.click();
} catch (err) {
console.error('Failed to import graph', err);
alert(`Failed to import graph: ${err instanceof Error ? err.message : String(err)}`);
}
}
async function exportPNG() {
if (!svgElement) return;
try {
const serializer = new XMLSerializer();
const svgStr = serializer.serializeToString(svgElement);
const svgBlob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return;
const bbox = svgElement.getBoundingClientRect();
canvas.width = bbox.width * 2;
canvas.height = bbox.height * 2;
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.scale(2, 2);
ctx.drawImage(img, 0, 0);
canvas.toBlob((blob) => {
if (blob) {
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = `quad4-linking-snapshot-${new Date().toISOString().slice(0, 10)}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
URL.revokeObjectURL(url);
}
}, 'image/png');
};
img.onerror = () => {
console.error('Failed to load SVG image');
URL.revokeObjectURL(url);
};
img.src = url;
} catch (err) {
console.error('Failed to export PNG', err);
}
}
function getScreenCoords(e: MouseEvent) {
const rect = containerElement.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
}
function screenToWorld(x: number, y: number) {
return {
x: (x - transform.x) / transform.k,
y: (y - transform.y) / transform.k,
};
}
function handleWheel(e: WheelEvent) {
e.preventDefault();
const zoomIntensity = 0.001;
const delta = -e.deltaY * zoomIntensity;
const newScale = Math.min(Math.max(0.1, transform.k + delta), 5);
const mouse = getScreenCoords(e);
const worldBefore = screenToWorld(mouse.x, mouse.y);
transform.k = newScale;
const worldAfter = screenToWorld(mouse.x, mouse.y);
transform.x += (worldAfter.x - worldBefore.x) * newScale;
transform.y += (worldAfter.y - worldBefore.y) * newScale;
}
function handleMouseDown(e: MouseEvent) {
const mouse = getScreenCoords(e);
lastMouse = mouse;
if (e.button === 1 || (e.button === 0 && e.altKey)) {
isPanning = true;
containerElement.style.cursor = 'grabbing';
} else if (e.button === 0 && !hoverNodeId) {
isPanning = true;
selectedNodeId = null;
containerElement.style.cursor = 'grabbing';
}
}
function handleNodeMouseDown(e: MouseEvent, nodeId: string) {
e.stopPropagation();
const mouse = getScreenCoords(e);
lastMouse = mouse;
if (e.shiftKey) {
isLinking = true;
linkStartNodeId = nodeId;
selectedNodeId = nodeId;
} else {
isDragging = true;
draggedNodeId = nodeId;
selectedNodeId = nodeId;
}
}
function handleMouseMove(e: MouseEvent) {
const mouse = getScreenCoords(e);
const dx = mouse.x - lastMouse.x;
const dy = mouse.y - lastMouse.y;
lastMouse = mouse;
mouseWorld = screenToWorld(mouse.x, mouse.y);
if (isPanning) {
transform.x += dx;
transform.y += dy;
} else if (isDragging && draggedNodeId) {
const node = nodes.find((n) => n.id === draggedNodeId);
if (node) {
node.x += dx / transform.k;
node.y += dy / transform.k;
// eslint-disable-next-line no-self-assign
nodes = nodes;
}
}
}
function handleMouseUp(_e: MouseEvent) {
if (isDragging && draggedNodeId) {
saveToLocalStorage();
}
if (isLinking && linkStartNodeId && hoverNodeId && linkStartNodeId !== hoverNodeId) {
pushState();
addLink(linkStartNodeId, hoverNodeId);
saveToLocalStorage();
}
isPanning = false;
isDragging = false;
isLinking = false;
draggedNodeId = null;
linkStartNodeId = null;
containerElement.style.cursor = 'default';
saveToLocalStorage();
}
function addNode() {
if (!newNodeLabel) return;
pushState();
const id = 'n-' + Math.random().toString(36).slice(2, 11);
const center = screenToWorld(
containerElement.clientWidth / 2,
containerElement.clientHeight / 2
);
const trimmedNotes = newNodeNotes.trim();
const trimmedImage = newNodeImage.trim();
const lastNode = selectedNode || nodes[nodes.length - 1];
const inheritShowLabel = lastNode?.showLabel !== false;
const inheritShowType = lastNode?.showType !== false;
const inheritShowNotes = lastNode?.showNotes !== false;
nodes = [
...nodes,
{
id,
label: newNodeLabel,
type: newNodeType,
x: center.x + (Math.random() * 40 - 20),
y: center.y + (Math.random() * 40 - 20),
notes: trimmedNotes ? trimmedNotes : undefined,
imageUrl: trimmedImage ? trimmedImage : undefined,
showLabel: inheritShowLabel,
showType: inheritShowType,
showNotes: inheritShowNotes,
},
];
newNodeLabel = '';
newNodeImage = '';
newNodeNotes = '';
newNodeImageError = '';
showAddModal = false;
selectedNodeId = id;
saveToLocalStorage();
}
function addLink(sourceId: string, targetId: string) {
const exists = links.find(
(l) =>
(l.source === sourceId && l.target === targetId) ||
(l.source === targetId && l.target === sourceId)
);
if (exists) return;
const id = 'l-' + Math.random().toString(36).slice(2, 11);
links = [
...links,
{
id,
source: sourceId,
target: targetId,
label: 'Linked',
},
];
}
function deleteSelected() {
if (!selectedNodeId) return;
pushState();
nodes = nodes.filter((n) => n.id !== selectedNodeId);
links = links.filter((l) => l.source !== selectedNodeId && l.target !== selectedNodeId);
selectedNodeId = null;
saveToLocalStorage();
}
function startEditingLink(linkId: string, e: MouseEvent) {
e.stopPropagation();
const link = links.find((l) => l.id === linkId);
if (link) {
editingLinkId = linkId;
editingLinkLabel = link.label;
setTimeout(() => linkEditInput?.focus(), 10);
}
}
function saveLinkEdit() {
if (!editingLinkId) return;
pushState();
links = links.map((l) =>
l.id === editingLinkId ? { ...l, label: editingLinkLabel.trim() || 'Linked' } : l
);
editingLinkId = null;
editingLinkLabel = '';
saveToLocalStorage();
}
function cancelLinkEdit() {
editingLinkId = null;
editingLinkLabel = '';
}
function handleLinkLabelInput(event: Event) {
const target = event.currentTarget as HTMLInputElement | null;
if (target) {
editingLinkLabel = target.value;
}
}
function centerView() {
if (nodes.length === 0) {
transform = { x: 0, y: 0, k: 1 };
return;
}
const xs = nodes.map((n) => n.x);
const ys = nodes.map((n) => n.y);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const width = maxX - minX;
const height = maxY - minY;
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
const padding = 100;
const availWidth = containerElement.clientWidth - padding * 2;
const availHeight = containerElement.clientHeight - padding * 2;
const scale = Math.min(
availWidth / Math.max(width, 100),
availHeight / Math.max(height, 100),
1
);
transform = {
k: scale,
x: containerElement.clientWidth / 2 - cx * scale,
y: containerElement.clientHeight / 2 - cy * scale,
};
}
function normalizeNodes(nodesToNormalize: Node[]): Node[] {
return nodesToNormalize.map((node) => ({
...node,
showLabel: node.showLabel !== undefined ? node.showLabel : true,
showType: node.showType !== undefined ? node.showType : true,
showNotes: node.showNotes !== undefined ? node.showNotes : true,
}));
}
function clearGraph() {
pushState();
nodes = [];
links = [];
transform = { x: 0, y: 0, k: 1 };
saveToLocalStorage();
}
function copySelectedNode() {
if (!selectedNodeId) return;
const node = nodes.find((n) => n.id === selectedNodeId);
if (node) {
copiedNodes = [JSON.parse(JSON.stringify(node))];
}
}
function pasteNodes() {
if (copiedNodes.length === 0) return;
pushState();
const newNodes: Node[] = [];
const center = screenToWorld(
containerElement.clientWidth / 2,
containerElement.clientHeight / 2
);
copiedNodes.forEach((copiedNode, index) => {
const id = 'n-' + Math.random().toString(36).slice(2, 11);
const offsetX = (index % 3) * 60 - 60;
const offsetY = Math.floor(index / 3) * 60 - 60;
const newNode: Node = {
...copiedNode,
id,
x: center.x + offsetX,
y: center.y + offsetY,
};
newNodes.push(newNode);
});
nodes = [...nodes, ...newNodes];
selectedNodeId = newNodes[0]?.id || null;
saveToLocalStorage();
}
function handleKeydown(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault();
showSearch = true;
setTimeout(() => searchInput?.focus(), 10);
}
return;
}
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
e.preventDefault();
copySelectedNode();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key === 'v') {
e.preventDefault();
pasteNodes();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault();
showSearch = true;
setTimeout(() => searchInput?.focus(), 10);
return;
}
if (e.key === 'Escape' && showSearch) {
showSearch = false;
searchQuery = '';
return;
}
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedNodeId) {
deleteSelected();
}
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
e.preventDefault();
undo();
}
if ((e.ctrlKey || e.metaKey) && e.key === 'y') {
e.preventDefault();
redo();
}
}
onMount(() => {
if (!loadFromUrl()) {
loadFromLocalStorage();
}
});
$: if (showAddModal && addNodeInput) {
setTimeout(() => addNodeInput?.focus(), 10);
}
</script>
<div
class="flex h-full flex-col bg-neutral-900/50 rounded-xl border border-neutral-800 overflow-hidden relative"
bind:this={containerElement}
>
<div class="absolute top-4 left-4 z-10 flex flex-col gap-2 pointer-events-none">
<div
class="bg-neutral-900/70 backdrop-blur border border-neutral-800 rounded-lg p-2 pointer-events-auto shadow-lg"
>
<div class="flex flex-col gap-1">
<button
class="p-2 hover:bg-neutral-800 rounded text-neutral-400 hover:text-white"
title="Add Node"
on:click={() => (showAddModal = true)}
>
<Plus size={18} />
</button>
<div class="h-px bg-neutral-800 my-1"></div>
<button
class="p-2 hover:bg-neutral-800 rounded text-neutral-400 hover:text-white"
title="Import Graph"
on:click={importGraph}
>
<Upload size={18} />
</button>
<button
class="p-2 hover:bg-neutral-800 rounded text-neutral-400 hover:text-white"
title="Export JSON"
on:click={exportGraph}
>
<Download size={18} />
</button>
<button
class="p-2 hover:bg-neutral-800 rounded text-neutral-400 hover:text-white"
title="Export PNG"
on:click={exportPNG}
>
<ImageIcon size={18} />
</button>
<button
class="p-2 hover:bg-neutral-800 rounded text-neutral-400 hover:text-white"
title="Share Link"
on:click={shareGraph}
>
<Share2 size={18} />
</button>
<div class="h-px bg-neutral-800 my-1"></div>
<button
class="p-2 hover:bg-neutral-800 rounded text-neutral-400 hover:text-white"
title="Undo (Ctrl+Z)"
on:click={undo}
disabled={undoStack.length === 0}
class:opacity-50={undoStack.length === 0}
>
<Undo2 size={18} />
</button>
<button
class="p-2 hover:bg-neutral-800 rounded text-neutral-400 hover:text-white"
title="Redo (Ctrl+Y)"
on:click={redo}
disabled={redoStack.length === 0}
class:opacity-50={redoStack.length === 0}
>
<Redo2 size={18} />
</button>
<div class="h-px bg-neutral-800 my-1"></div>
<button
class="p-2 hover:bg-neutral-800 rounded text-neutral-400 hover:text-white"
title="Fit to Screen"
on:click={centerView}
>
<Maximize size={18} />
</button>
<button
class="p-2 hover:bg-neutral-800 rounded text-neutral-400 hover:text-white"
title="Clear Graph"
on:click={clearGraph}
>
<Trash2 size={18} />
</button>
</div>
</div>
</div>
{#if showSearch}
<div class="absolute top-4 left-1/2 -translate-x-1/2 z-20 pointer-events-none">
<div
class="bg-neutral-900/90 backdrop-blur border border-neutral-800 rounded-lg p-3 pointer-events-auto shadow-lg min-w-[300px]"
>
<div class="flex items-center gap-2">
<Search size={16} class="text-neutral-400" />
<input
bind:this={searchInput}
type="text"
bind:value={searchQuery}
placeholder="Search nodes and links... (Ctrl+F)"
class="flex-1 bg-neutral-800 border border-neutral-700 rounded px-3 py-1.5 text-sm text-neutral-100 placeholder-neutral-500 focus:border-rose-500 focus:outline-none"
on:keydown={(e) => {
if (e.key === 'Escape') {
showSearch = false;
searchQuery = '';
}
}}
/>
<button
class="text-neutral-400 hover:text-white p-1"
on:click={() => {
showSearch = false;
searchQuery = '';
}}
title="Close (Esc)"
>
<X size={16} />
</button>
</div>
{#if searchQuery.trim()}
<div class="mt-2 text-xs text-neutral-400">
Found {filteredNodes.length} node{filteredNodes.length !== 1 ? 's' : ''} and {filteredLinks.length}
link{filteredLinks.length !== 1 ? 's' : ''}
</div>
{/if}
</div>
</div>
{/if}
<div class="absolute bottom-4 right-4 z-10 pointer-events-none">
<div
class="bg-neutral-900/70 backdrop-blur border border-neutral-800 rounded-lg pointer-events-auto shadow-lg"
>
<button
class="w-full flex items-center justify-between px-3 py-2 text-[10px] text-neutral-500 uppercase tracking-wider font-semibold hover:text-neutral-400 transition-colors"
on:click={() => (controlsCollapsed = !controlsCollapsed)}
>
<span>Controls</span>
{#if controlsCollapsed}
<ChevronUp size={14} />
{:else}
<ChevronDown size={14} />
{/if}
</button>
{#if !controlsCollapsed}
<div class="px-3 pb-3">
<div class="text-xs text-neutral-300 space-y-1">
<div class="flex justify-between gap-4">
<span>Pan</span> <span class="text-neutral-500">Left/Middle Drag</span>
</div>
<div class="flex justify-between gap-4">
<span>Zoom</span> <span class="text-neutral-500">Scroll Wheel</span>
</div>
<div class="flex justify-between gap-4">
<span>Select</span> <span class="text-neutral-500">Click Node</span>
</div>
<div class="flex justify-between gap-4">
<span>Link</span> <span class="text-neutral-500">Shift + Drag Node</span>
</div>
<div class="flex justify-between gap-4">
<span>Copy</span> <span class="text-neutral-500">Ctrl + C</span>
</div>
<div class="flex justify-between gap-4">
<span>Paste</span> <span class="text-neutral-500">Ctrl + V</span>
</div>
<div class="flex justify-between gap-4">
<span>Search</span> <span class="text-neutral-500">Ctrl + F</span>
</div>
<div class="flex justify-between gap-4">
<span>Delete</span> <span class="text-neutral-500">Del Key</span>
</div>
<div class="flex justify-between gap-4">
<span>Undo</span> <span class="text-neutral-500">Ctrl + Z</span>
</div>
</div>
</div>
{/if}
</div>
</div>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div
class="flex-1 w-full h-full cursor-default bg-neutral-950 outline-none"
on:mousedown={handleMouseDown}
on:wheel={handleWheel}
on:mousemove={handleMouseMove}
on:mouseup={handleMouseUp}
on:mouseleave={handleMouseUp}
tabindex="0"
on:keydown={handleKeydown}
role="application"
aria-label="Identity Graph Canvas"
aria-roledescription="Interactive graph canvas"
>
<svg bind:this={svgElement} class="w-full h-full block">
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#262626" stroke-width="1" />
</pattern>
</defs>
<g
transform="translate({transform.x % (40 * transform.k)}, {transform.y %
(40 * transform.k)}) scale({transform.k})"
>
<rect
x={-containerElement?.clientWidth / transform.k || -1000}
y={-containerElement?.clientHeight / transform.k || -1000}
width="400%"
height="400%"
fill="url(#grid)"
/>
</g>
<g transform="translate({transform.x}, {transform.y}) scale({transform.k})">
{#each filteredLinks as link (link.id)}
{@const source = nodes.find((n) => n.id === link.source)}
{@const target = nodes.find((n) => n.id === link.target)}
{@const isMatch =
searchQuery.trim() &&
(link.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
(source && source.label.toLowerCase().includes(searchQuery.toLowerCase())) ||
(target && target.label.toLowerCase().includes(searchQuery.toLowerCase())))}
{#if source && target}
<g class="group">
<line
x1={source.x}
y1={source.y}
x2={target.x}
y2={target.y}
stroke={isMatch ? '#ef4444' : '#525252'}
stroke-width={isMatch ? '3' : '2'}
stroke-opacity={isMatch ? '0.8' : '1'}
class="transition-colors group-hover:stroke-neutral-300"
/>
{#if editingLinkId === link.id}
{@const labelWidth = Math.max(80, editingLinkLabel.length * 6 + 16)}
<rect
x={(source.x + target.x) / 2 - labelWidth / 2}
y={(source.y + target.y) / 2 - 10}
width={labelWidth}
height="20"
rx="4"
fill="#171717"
stroke="#ef4444"
stroke-width="1"
/>
<foreignObject
x={(source.x + target.x) / 2 - labelWidth / 2}
y={(source.y + target.y) / 2 - 10}
width={labelWidth}
height="20"
>
<!-- svelte-ignore a11y-autofocus -->
<input
bind:this={linkEditInput}
type="text"
value={editingLinkLabel}
on:input={handleLinkLabelInput}
on:keydown={(e) => {
if (e.key === 'Enter') saveLinkEdit();
if (e.key === 'Escape') cancelLinkEdit();
}}
on:blur={saveLinkEdit}
class="w-full h-full bg-transparent border-none outline-none text-[10px] text-neutral-200 font-mono px-2 text-center"
style="color: rgb(229 231 235);"
/>
</foreignObject>
{:else}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<rect
x={(source.x + target.x) / 2 - (link.label.length * 3 + 4)}
y={(source.y + target.y) / 2 - 8}
width={link.label.length * 6 + 8}
height="16"
rx="4"
fill="#171717"
class="cursor-pointer"
role="button"
tabindex="0"
on:dblclick={(e) => startEditingLink(link.id, e)}
on:keydown={(e) => {
if (e.key === 'Enter') {
startEditingLink(link.id, e as unknown as MouseEvent);
}
}}
/>
<text
x={(source.x + target.x) / 2}
y={(source.y + target.y) / 2}
dy="3"
text-anchor="middle"
class="text-[10px] fill-neutral-400 font-mono pointer-events-none select-none"
>
{link.label}
</text>
{/if}
</g>
{/if}
{/each}
{#if isLinking && linkStartNodeId}
{@const start = nodes.find((n) => n.id === linkStartNodeId)}
{#if start}
<line
x1={start.x}
y1={start.y}
x2={mouseWorld.x}
y2={mouseWorld.y}
stroke="#ef4444"
stroke-width="2"
stroke-dasharray="4"
class="pointer-events-none opacity-50"
/>
{/if}
{/if}
{#each nodes as node (node.id)}
{@const isMatch =
searchQuery.trim() &&
(node.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
node.type.toLowerCase().includes(searchQuery.toLowerCase()) ||
(node.notes && node.notes.toLowerCase().includes(searchQuery.toLowerCase())))}
{@const isVisible = !searchQuery.trim() || isMatch}
{#if isVisible}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<g
transform="translate({node.x}, {node.y})"
role="button"
tabindex="0"
on:mousedown={(e) => handleNodeMouseDown(e, node.id)}
on:mouseenter={() => (hoverNodeId = node.id)}
on:mouseleave={() => (hoverNodeId = null)}
on:keydown={(e) => e.key === 'Enter' && (selectedNodeId = node.id)}
class="cursor-pointer"
opacity={searchQuery.trim() && !isMatch ? '0.2' : '1'}
>
{#if selectedNodeId === node.id}
<circle
r="28"
fill="none"
stroke="#fff"
stroke-width="2"
stroke-opacity="0.2"
class="animate-pulse"
/>
{/if}
{#if isMatch && searchQuery.trim()}
<circle
r="30"
fill="none"
stroke="#ef4444"
stroke-width="3"
stroke-opacity="0.5"
class="animate-pulse"
/>
{/if}
{#if node.imageUrl}
<defs>
<clipPath id={`node-clip-${node.id}`}>
<circle r="24" cx="0" cy="0" />
</clipPath>
</defs>
<image
href={node.imageUrl}
x="-24"
y="-24"
width="48"
height="48"
preserveAspectRatio="xMidYMid slice"
clip-path={`url(#node-clip-${node.id})`}
/>
<circle
r="24"
fill="transparent"
stroke={typeColors[node.type]}
stroke-width={selectedNodeId === node.id ? 3 : 2}
class="transition-all shadow-lg"
/>
{:else}
<circle
r="24"
fill="#171717"
stroke={typeColors[node.type]}
stroke-width={selectedNodeId === node.id ? 3 : 2}
class={'transition-all shadow-lg ' +
(node.showType !== false ? 'hover:brightness-110' : '')}
/>
{#if node.showType !== false}
<g transform="translate(-12, -12)">
<svelte:component
this={iconMap[node.type]}
size={24}
color={typeColors[node.type]}
/>
</g>
{/if}
{/if}
{#if node.showType !== false && node.imageUrl}
<g transform="translate(16, 16)">
<circle r="10" fill="#0a0a0a" stroke={typeColors[node.type]} stroke-width="2" />
<g transform="translate(-6, -6)">
<svelte:component
this={iconMap[node.type]}
size={12}
color={typeColors[node.type]}
/>
</g>
</g>
{/if}
{#if node.showLabel !== false}
<g transform="translate(0, 36)">
<rect
x={-(node.label.length * 3.5 + 6)}
y="-10"
width={node.label.length * 7 + 12}
height="20"
rx="4"
fill="#000000"
fill-opacity="0.5"
/>
<text
text-anchor="middle"
dy="4"
class="text-[11px] fill-neutral-200 font-medium select-none pointer-events-none"
>
{node.label}
</text>
</g>
{/if}
{#if node.showNotes !== false && node.notes}
{@const noteSnippet =
node.notes.length > 42 ? `${node.notes.slice(0, 42)}` : node.notes}
<g transform="translate(0, 62)">
<rect
x={-90}
y="-10"
width="180"
height="22"
rx="6"
fill="#1d1d1d"
stroke="rgba(251,191,36,0.3)"
/>
<text
text-anchor="middle"
dy="4"
class="text-[10px] fill-amber-200 font-mono select-none pointer-events-none"
>
{noteSnippet}
</text>
</g>
{/if}
</g>
{/if}
{/each}
</g>
</svg>
</div>
{#if selectedNode}
<div
class="absolute top-0 right-0 h-full w-full max-w-sm border-l border-neutral-900 bg-neutral-950/85 backdrop-blur pointer-events-auto z-20"
>
<div class="flex items-center justify-between border-b border-neutral-900 px-4 py-3">
<div>
<div class="text-xs uppercase tracking-[0.3em] text-neutral-500">Entity</div>
<div class="text-lg font-semibold text-white">{selectedNode.label || 'Untitled'}</div>
</div>
<button
class="rounded-lg border border-neutral-800 p-1.5 text-neutral-400 hover:text-white hover:bg-neutral-800"
on:click={closeInspector}
>
<X size={16} />
</button>
</div>
<div class="flex h-[calc(100%-64px)] flex-col overflow-y-auto px-4 py-4 gap-4">
<div class="flex items-center gap-3">
{#if selectedNode.imageUrl}
<img
src={selectedNode.imageUrl}
alt={selectedNode.label}
class="h-16 w-16 rounded-2xl border border-neutral-800 object-cover shadow-lg"
/>
{:else}
<div
class="h-16 w-16 rounded-2xl border border-neutral-800 bg-neutral-900 flex items-center justify-center shadow-lg"
>
<svelte:component
this={iconMap[selectedNode.type]}
size={28}
color={typeColors[selectedNode.type]}
/>
</div>
{/if}
<div>
<div class="text-xs uppercase tracking-[0.3em] text-neutral-500">Type</div>
<div class="text-sm font-semibold text-neutral-200 capitalize">{selectedNode.type}</div>
{#if selectedNode.notes}
<div class="mt-1 text-xs text-amber-200 break-words">{selectedNode.notes}</div>
{/if}
</div>
</div>
<div class="space-y-3">
<div>
<label
for="inspector-label-input"
class="block text-xs font-semibold uppercase tracking-[0.2em] text-neutral-500 mb-1"
>Label</label
>
<input
id="inspector-label-input"
class="w-full rounded-lg border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm text-gray-100 placeholder-neutral-600 focus:border-rose-500 focus:outline-none"
value={editLabel}
on:input={handleLabelInputEvent}
placeholder="Entity Label"
/>
</div>
<div>
<div class="text-xs font-semibold uppercase tracking-[0.2em] text-neutral-500 mb-1">
Type
</div>
<div class="grid grid-cols-4 gap-2">
{#each nodeTypes as type}
<button
class={'flex flex-col items-center gap-1 rounded-lg border px-2 py-2 text-[11px] capitalize transition ' +
(editType === type
? 'border-rose-500 bg-rose-500/10 text-rose-200'
: 'border-neutral-800 bg-neutral-900 text-neutral-400 hover:border-neutral-700')}
type="button"
on:click={() => handleTypeSelect(type)}
>
<svelte:component this={iconMap[type]} size={18} color={typeColors[type]} />
{type}
</button>
{/each}
</div>
</div>
<div>
<label
for="inspector-image-input"
class="block text-xs font-semibold uppercase tracking-[0.2em] text-neutral-500 mb-1"
>Custom Image URL</label
>
<input
id="inspector-image-input"
class="w-full rounded-lg border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm text-gray-100 placeholder-neutral-600 focus:border-rose-500 focus:outline-none"
value={editImageUrl}
on:input={handleImageUrlInputEvent}
placeholder="https://..."
/>
<div class="mt-2 flex gap-2 flex-wrap">
<button
class="rounded-lg border border-neutral-800 bg-neutral-900 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800"
type="button"
on:click={triggerImageUpload}
>
Upload Image
</button>
{#if selectedNode?.imageUrl}
<button
class="rounded-lg border border-neutral-800 bg-neutral-900 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800"
type="button"
on:click={clearImage}
>
Remove Image
</button>
{/if}
</div>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
class="hidden"
bind:this={imageUploadInput}
on:change={handleImageFileSelected}
/>
<p class="mt-1 text-[11px] text-neutral-500">
Paste a URL or upload a PNG/JPEG/WebP (2MB max).
</p>
{#if imageUploadError}
<p class="mt-1 text-[11px] text-rose-300">{imageUploadError}</p>
{/if}
</div>
<div>
<label
for="inspector-notes-input"
class="block text-xs font-semibold uppercase tracking-[0.2em] text-neutral-500 mb-1"
>Notes</label
>
<textarea
id="inspector-notes-input"
rows="4"
class="w-full rounded-lg border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm text-gray-100 placeholder-neutral-600 focus:border-rose-500 focus:outline-none resize-none"
value={editNotes}
on:input={handleNotesInputEvent}
placeholder="Add analyst notes, identifiers, context..."
></textarea>
</div>
<div>
<div class="text-xs font-semibold uppercase tracking-[0.2em] text-neutral-500 mb-2">
Display Options
</div>
<div class="space-y-2">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={editShowLabel}
on:change={toggleShowLabel}
class="rounded border-neutral-700 bg-neutral-800 text-rose-500 focus:ring-rose-500"
/>
<span class="text-sm text-neutral-300">Show Label</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={editShowType}
on:change={toggleShowType}
class="rounded border-neutral-700 bg-neutral-800 text-rose-500 focus:ring-rose-500"
/>
<span class="text-sm text-neutral-300">Show Type Icon</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={editShowNotes}
on:change={toggleShowNotes}
class="rounded border-neutral-700 bg-neutral-800 text-rose-500 focus:ring-rose-500"
/>
<span class="text-sm text-neutral-300">Show Notes</span>
</label>
</div>
</div>
</div>
<div class="border border-neutral-900 rounded-xl bg-neutral-900/60 p-3">
<div class="text-xs uppercase tracking-[0.3em] text-neutral-500 mb-2">Connections</div>
{#if connectedEdges.length > 0}
<div class="space-y-2">
{#each connectedEdges as edge}
<div class="rounded-lg border border-neutral-800 bg-neutral-900 px-3 py-2">
<div class="text-sm font-semibold text-neutral-100">
{edge.otherNode?.label || 'Unknown entity'}
</div>
<div class="text-xs text-neutral-500 capitalize">
{edge.otherNode?.type || 'entity'}{edge.label}
</div>
</div>
{/each}
</div>
{:else}
<div class="text-xs text-neutral-500">
No linked entities yet. Shift + drag from this node to create relationships.
</div>
{/if}
</div>
<div class="mt-auto pt-2 text-[11px] text-neutral-500 text-center">
Changes are applied and saved automatically.
</div>
<button
class="rounded-lg border border-rose-900/40 bg-rose-900/10 px-3 py-2 text-sm text-rose-200 hover:bg-rose-900/20 transition"
on:click={deleteSelected}
>
Delete Entity
</button>
</div>
</div>
{/if}
{#if showAddModal}
<!-- svelte-ignore a11y-interactive-supports-focus -->
<div
class="absolute inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
on:click|self={() => (showAddModal = false)}
on:keydown={(e) => e.key === 'Escape' && (showAddModal = false)}
role="dialog"
tabindex="-1"
aria-modal="true"
aria-label="Add Entity"
>
<div
class="w-full max-w-md rounded-xl border border-neutral-800 bg-neutral-900 p-6 shadow-2xl"
>
<h4 class="text-lg font-semibold text-gray-100 mb-4">Add Entity</h4>
<div class="space-y-4">
<div>
<label for="nodeLabel" class="block text-xs font-medium text-neutral-400 mb-1"
>Label / Name</label
>
<!-- svelte-ignore a11y-autofocus -->
<input
bind:this={addNodeInput}
id="nodeLabel"
class="w-full rounded-lg border border-neutral-700 bg-neutral-800 px-3 py-2 text-sm text-gray-100 focus:border-rose-500 focus:outline-none placeholder-neutral-600"
placeholder="e.g. John Doe"
bind:value={newNodeLabel}
on:keydown={(e) => e.key === 'Enter' && addNode()}
/>
</div>
<div>
<label for="nodeType" class="block text-xs font-medium text-neutral-400 mb-1"
>Type</label
>
<div class="grid grid-cols-4 gap-2">
{#each nodeTypes as type}
<button
class={'flex flex-col items-center justify-center gap-1 rounded-lg border p-2 transition ' +
(newNodeType === type
? 'border-rose-500 bg-rose-500/10'
: 'border-neutral-800 bg-neutral-800 hover:border-neutral-700')}
on:click={() => (newNodeType = type)}
>
<svelte:component this={iconMap[type]} size={20} color={typeColors[type]} />
<span class="text-[10px] text-neutral-400 capitalize">{type}</span>
</button>
{/each}
</div>
</div>
<div>
<label for="nodeImage" class="block text-xs font-medium text-neutral-400 mb-1"
>Custom Image URL</label
>
<input
id="nodeImage"
class="w-full rounded-lg border border-neutral-700 bg-neutral-800 px-3 py-2 text-sm text-gray-100 focus:border-rose-500 focus:outline-none placeholder-neutral-600"
placeholder="https://..."
bind:value={newNodeImage}
/>
<div class="mt-2 flex gap-2 flex-wrap">
<button
class="rounded-lg border border-neutral-700 bg-neutral-800 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-700"
type="button"
on:click={triggerNewNodeImageUpload}
>
Upload Image
</button>
{#if newNodeImage}
<button
class="rounded-lg border border-neutral-700 bg-neutral-800 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-700"
type="button"
on:click={clearNewNodeImage}
>
Remove
</button>
{/if}
</div>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
class="hidden"
bind:this={modalImageUploadInput}
on:change={handleNewNodeImageFileSelected}
/>
<p class="mt-1 text-[11px] text-neutral-500">
Paste a URL or upload a PNG/JPEG/WebP (2MB max).
</p>
{#if newNodeImageError}
<p class="mt-1 text-[11px] text-rose-300">{newNodeImageError}</p>
{/if}
</div>
<div>
<label for="nodeNotes" class="block text-xs font-medium text-neutral-400 mb-1"
>Notes</label
>
<textarea
id="nodeNotes"
rows="3"
class="w-full rounded-lg border border-neutral-700 bg-neutral-800 px-3 py-2 text-sm text-gray-100 focus:border-rose-500 focus:outline-none placeholder-neutral-600 resize-none"
placeholder="Short intel notes, context, identifiers..."
bind:value={newNodeNotes}
></textarea>
</div>
<div class="flex justify-end gap-2 mt-4">
<button
class="rounded-lg px-3 py-2 text-sm text-neutral-400 hover:text-white"
on:click={() => (showAddModal = false)}>Cancel</button
>
<button
class="rounded-lg bg-rose-600 px-4 py-2 text-sm font-medium text-white hover:bg-rose-500 shadow-lg shadow-rose-900/20"
on:click={addNode}>Add Entity</button
>
</div>
</div>
</div>
</div>
{/if}
</div>