- «Mottak» → «Arbeidsflaten» i alle tilbake-lenker
- goto('/workspace') → goto('/') i ContextHeader
- Slettet NodeEditor.svelte og NewChatDialog.svelte (kun brukt av mottak)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
668 lines
20 KiB
Svelte
668 lines
20 KiB
Svelte
<script lang="ts">
|
|
import { page } from '$app/stores';
|
|
import { onMount } from 'svelte';
|
|
import * as d3 from 'd3';
|
|
import { fetchGraph, createNode, createEdge, type GraphNode, type GraphEdge } from '$lib/api';
|
|
|
|
const session = $derived($page.data.session as Record<string, unknown> | undefined);
|
|
const nodeId = $derived(session?.nodeId as string | undefined);
|
|
const accessToken = $derived(session?.accessToken as string | undefined);
|
|
|
|
// =========================================================================
|
|
// State
|
|
// =========================================================================
|
|
|
|
let svgEl: SVGSVGElement | undefined = $state();
|
|
let graphNodes = $state<GraphNode[]>([]);
|
|
let graphEdges = $state<GraphEdge[]>([]);
|
|
let loading = $state(true);
|
|
let error = $state<string | null>(null);
|
|
let focusId = $state<string | null>(null);
|
|
let depth = $state(2);
|
|
|
|
// Filters
|
|
let filterKinds = $state<string[]>([]);
|
|
let filterEdgeTypes = $state<string[]>([]);
|
|
|
|
// Topic creation
|
|
let showTopicForm = $state(false);
|
|
let newTopicTitle = $state('');
|
|
let isCreatingTopic = $state(false);
|
|
|
|
// Mentions edge creation
|
|
let showMentionsForm = $state(false);
|
|
let mentionsSourceId = $state('');
|
|
let mentionsTargetId = $state('');
|
|
let isCreatingMention = $state(false);
|
|
|
|
// Selected node for info panel
|
|
let selectedNode = $state<GraphNode | null>(null);
|
|
|
|
// =========================================================================
|
|
// Node kind colors and labels
|
|
// =========================================================================
|
|
|
|
const kindColors: Record<string, string> = {
|
|
topic: '#8b5cf6',
|
|
person: '#3b82f6',
|
|
team: '#10b981',
|
|
content: '#f59e0b',
|
|
communication: '#06b6d4',
|
|
collection: '#84cc16',
|
|
media: '#ec4899',
|
|
agent: '#6b7280'
|
|
};
|
|
|
|
const kindLabels: Record<string, string> = {
|
|
topic: 'Tema',
|
|
person: 'Person',
|
|
team: 'Team',
|
|
content: 'Innhold',
|
|
communication: 'Samtale',
|
|
collection: 'Samling',
|
|
media: 'Media',
|
|
agent: 'Agent'
|
|
};
|
|
|
|
const edgeTypeLabels: Record<string, string> = {
|
|
mentions: 'Nevner',
|
|
belongs_to: 'Tilhører',
|
|
owner: 'Eier',
|
|
member_of: 'Medlem av',
|
|
has_media: 'Har media',
|
|
scheduled: 'Planlagt',
|
|
status: 'Status',
|
|
part_of: 'Del av'
|
|
};
|
|
|
|
function nodeColor(kind: string): string {
|
|
return kindColors[kind] ?? '#9ca3af';
|
|
}
|
|
|
|
function nodeRadius(kind: string): number {
|
|
if (kind === 'topic') return 12;
|
|
if (kind === 'person' || kind === 'team') return 10;
|
|
return 8;
|
|
}
|
|
|
|
// =========================================================================
|
|
// Data loading
|
|
// =========================================================================
|
|
|
|
async function loadGraph() {
|
|
if (!accessToken) return;
|
|
loading = true;
|
|
error = null;
|
|
try {
|
|
const params: Record<string, unknown> = { depth };
|
|
if (focusId) params.focusId = focusId;
|
|
if (filterKinds.length) params.nodeKinds = filterKinds;
|
|
if (filterEdgeTypes.length) params.edgeTypes = filterEdgeTypes;
|
|
|
|
const resp = await fetchGraph(accessToken, params as any);
|
|
graphNodes = resp.nodes;
|
|
graphEdges = resp.edges;
|
|
} catch (e: any) {
|
|
error = e.message;
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
// Load on mount and when filters change
|
|
onMount(() => {
|
|
// Check URL for focus parameter
|
|
const url = new URL(window.location.href);
|
|
const fid = url.searchParams.get('focus');
|
|
if (fid) focusId = fid;
|
|
|
|
loadGraph();
|
|
});
|
|
|
|
// =========================================================================
|
|
// D3 Force Graph
|
|
// =========================================================================
|
|
|
|
interface SimNode extends d3.SimulationNodeDatum {
|
|
id: string;
|
|
node_kind: string;
|
|
title: string | null;
|
|
visibility: string;
|
|
}
|
|
|
|
interface SimLink extends d3.SimulationLinkDatum<SimNode> {
|
|
id: string;
|
|
edge_type: string;
|
|
}
|
|
|
|
let simulation: d3.Simulation<SimNode, SimLink> | null = null;
|
|
|
|
function renderGraph() {
|
|
if (!svgEl || graphNodes.length === 0) return;
|
|
|
|
// Clear previous
|
|
d3.select(svgEl).selectAll('*').remove();
|
|
if (simulation) simulation.stop();
|
|
|
|
const width = svgEl.clientWidth;
|
|
const height = svgEl.clientHeight;
|
|
|
|
const svg = d3.select(svgEl);
|
|
|
|
// Zoom container
|
|
const g = svg.append('g');
|
|
|
|
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
|
.scaleExtent([0.1, 4])
|
|
.on('zoom', (event) => {
|
|
g.attr('transform', event.transform);
|
|
});
|
|
|
|
svg.call(zoom);
|
|
|
|
// Build data
|
|
const nodeMap = new Map(graphNodes.map(n => [n.id, n]));
|
|
const simNodes: SimNode[] = graphNodes.map(n => ({
|
|
id: n.id,
|
|
node_kind: n.node_kind,
|
|
title: n.title,
|
|
visibility: n.visibility
|
|
}));
|
|
|
|
const simLinks: SimLink[] = graphEdges
|
|
.filter(e => nodeMap.has(e.source_id) && nodeMap.has(e.target_id))
|
|
.map(e => ({
|
|
id: e.id,
|
|
source: e.source_id,
|
|
target: e.target_id,
|
|
edge_type: e.edge_type
|
|
}));
|
|
|
|
// Simulation
|
|
simulation = d3.forceSimulation(simNodes)
|
|
.force('link', d3.forceLink<SimNode, SimLink>(simLinks).id(d => d.id).distance(80))
|
|
.force('charge', d3.forceManyBody().strength(-200))
|
|
.force('center', d3.forceCenter(width / 2, height / 2))
|
|
.force('collision', d3.forceCollide().radius(20));
|
|
|
|
// Edge type colors
|
|
function edgeColor(type: string): string {
|
|
if (type === 'mentions') return '#8b5cf6';
|
|
if (type === 'belongs_to') return '#9ca3af';
|
|
if (type === 'owner') return '#3b82f6';
|
|
if (type === 'member_of') return '#10b981';
|
|
if (type === 'part_of') return '#f59e0b';
|
|
return '#d1d5db';
|
|
}
|
|
|
|
// Draw edges
|
|
const link = g.append('g')
|
|
.selectAll('line')
|
|
.data(simLinks)
|
|
.join('line')
|
|
.attr('stroke', d => edgeColor(d.edge_type))
|
|
.attr('stroke-width', d => d.edge_type === 'mentions' ? 2.5 : 1.5)
|
|
.attr('stroke-opacity', 0.6)
|
|
.attr('stroke-dasharray', d => d.edge_type === 'mentions' ? '' : '4,2');
|
|
|
|
// Edge labels
|
|
const linkLabel = g.append('g')
|
|
.selectAll('text')
|
|
.data(simLinks)
|
|
.join('text')
|
|
.attr('font-size', '9px')
|
|
.attr('fill', '#9ca3af')
|
|
.attr('text-anchor', 'middle')
|
|
.text(d => edgeTypeLabels[d.edge_type] ?? d.edge_type);
|
|
|
|
// Draw nodes
|
|
const node = g.append('g')
|
|
.selectAll<SVGCircleElement, SimNode>('circle')
|
|
.data(simNodes)
|
|
.join('circle')
|
|
.attr('r', d => nodeRadius(d.node_kind))
|
|
.attr('fill', d => nodeColor(d.node_kind))
|
|
.attr('stroke', d => focusId === d.id ? '#1f2937' : '#fff')
|
|
.attr('stroke-width', d => focusId === d.id ? 3 : 2)
|
|
.attr('cursor', 'pointer')
|
|
.on('click', (_event, d) => {
|
|
selectedNode = graphNodes.find(n => n.id === d.id) ?? null;
|
|
})
|
|
.on('dblclick', (_event, d) => {
|
|
focusId = d.id;
|
|
loadGraph();
|
|
})
|
|
.call(d3.drag<SVGCircleElement, SimNode>()
|
|
.on('start', (event, d) => {
|
|
if (!event.active) simulation!.alphaTarget(0.3).restart();
|
|
d.fx = d.x;
|
|
d.fy = d.y;
|
|
})
|
|
.on('drag', (event, d) => {
|
|
d.fx = event.x;
|
|
d.fy = event.y;
|
|
})
|
|
.on('end', (event, d) => {
|
|
if (!event.active) simulation!.alphaTarget(0);
|
|
d.fx = null;
|
|
d.fy = null;
|
|
})
|
|
);
|
|
|
|
// Node labels
|
|
const label = g.append('g')
|
|
.selectAll('text')
|
|
.data(simNodes)
|
|
.join('text')
|
|
.attr('font-size', '11px')
|
|
.attr('font-weight', d => d.node_kind === 'topic' ? '600' : '400')
|
|
.attr('fill', '#374151')
|
|
.attr('text-anchor', 'middle')
|
|
.attr('dy', d => nodeRadius(d.node_kind) + 14)
|
|
.text(d => {
|
|
const t = d.title || 'Uten tittel';
|
|
return t.length > 20 ? t.slice(0, 18) + '…' : t;
|
|
});
|
|
|
|
// Tick
|
|
simulation.on('tick', () => {
|
|
link
|
|
.attr('x1', d => (d.source as SimNode).x!)
|
|
.attr('y1', d => (d.source as SimNode).y!)
|
|
.attr('x2', d => (d.target as SimNode).x!)
|
|
.attr('y2', d => (d.target as SimNode).y!);
|
|
|
|
linkLabel
|
|
.attr('x', d => ((d.source as SimNode).x! + (d.target as SimNode).x!) / 2)
|
|
.attr('y', d => ((d.source as SimNode).y! + (d.target as SimNode).y!) / 2 - 6);
|
|
|
|
node
|
|
.attr('cx', d => d.x!)
|
|
.attr('cy', d => d.y!);
|
|
|
|
label
|
|
.attr('x', d => d.x!)
|
|
.attr('y', d => d.y!);
|
|
});
|
|
|
|
// Auto-zoom to fit
|
|
simulation.on('end', () => {
|
|
const bounds = (g.node() as SVGGElement)?.getBBox();
|
|
if (!bounds || bounds.width === 0) return;
|
|
const padding = 40;
|
|
const scale = Math.min(
|
|
width / (bounds.width + padding * 2),
|
|
height / (bounds.height + padding * 2),
|
|
1.5
|
|
);
|
|
const tx = width / 2 - (bounds.x + bounds.width / 2) * scale;
|
|
const ty = height / 2 - (bounds.y + bounds.height / 2) * scale;
|
|
svg.transition().duration(500).call(
|
|
zoom.transform,
|
|
d3.zoomIdentity.translate(tx, ty).scale(scale)
|
|
);
|
|
});
|
|
}
|
|
|
|
// Re-render when data changes
|
|
$effect(() => {
|
|
if (graphNodes.length > 0 && svgEl) {
|
|
renderGraph();
|
|
}
|
|
});
|
|
|
|
// =========================================================================
|
|
// Topic creation
|
|
// =========================================================================
|
|
|
|
async function handleCreateTopic() {
|
|
if (!accessToken || !nodeId || !newTopicTitle.trim() || isCreatingTopic) return;
|
|
isCreatingTopic = true;
|
|
try {
|
|
const { node_id } = await createNode(accessToken, {
|
|
node_kind: 'topic',
|
|
title: newTopicTitle.trim(),
|
|
visibility: 'readable'
|
|
});
|
|
|
|
await createEdge(accessToken, {
|
|
source_id: nodeId,
|
|
target_id: node_id,
|
|
edge_type: 'owner'
|
|
});
|
|
|
|
newTopicTitle = '';
|
|
showTopicForm = false;
|
|
await loadGraph();
|
|
} catch (e: any) {
|
|
error = e.message;
|
|
} finally {
|
|
isCreatingTopic = false;
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Mentions edge creation
|
|
// =========================================================================
|
|
|
|
async function handleCreateMention() {
|
|
if (!accessToken || !mentionsSourceId || !mentionsTargetId || isCreatingMention) return;
|
|
if (mentionsSourceId === mentionsTargetId) return;
|
|
isCreatingMention = true;
|
|
try {
|
|
await createEdge(accessToken, {
|
|
source_id: mentionsSourceId,
|
|
target_id: mentionsTargetId,
|
|
edge_type: 'mentions'
|
|
});
|
|
|
|
mentionsSourceId = '';
|
|
mentionsTargetId = '';
|
|
showMentionsForm = false;
|
|
await loadGraph();
|
|
} catch (e: any) {
|
|
error = e.message;
|
|
} finally {
|
|
isCreatingMention = false;
|
|
}
|
|
}
|
|
|
|
function resetFocus() {
|
|
focusId = null;
|
|
selectedNode = null;
|
|
loadGraph();
|
|
}
|
|
|
|
// =========================================================================
|
|
// Unique kinds/types in current data (for filters)
|
|
// =========================================================================
|
|
|
|
const availableKinds = $derived([...new Set(graphNodes.map(n => n.node_kind))].sort());
|
|
const availableEdgeTypes = $derived([...new Set(graphEdges.map(e => e.edge_type))].sort());
|
|
|
|
function toggleKindFilter(kind: string) {
|
|
if (filterKinds.includes(kind)) {
|
|
filterKinds = filterKinds.filter(k => k !== kind);
|
|
} else {
|
|
filterKinds = [...filterKinds, kind];
|
|
}
|
|
loadGraph();
|
|
}
|
|
|
|
function toggleEdgeTypeFilter(type: string) {
|
|
if (filterEdgeTypes.includes(type)) {
|
|
filterEdgeTypes = filterEdgeTypes.filter(t => t !== type);
|
|
} else {
|
|
filterEdgeTypes = [...filterEdgeTypes, type];
|
|
}
|
|
loadGraph();
|
|
}
|
|
</script>
|
|
|
|
<div class="flex flex-col h-screen bg-gray-50">
|
|
<!-- Header -->
|
|
<header class="border-b border-gray-200 bg-white shrink-0">
|
|
<div class="flex items-center justify-between px-4 py-3">
|
|
<div class="flex items-center gap-3">
|
|
<a href="/" class="text-sm text-gray-400 hover:text-gray-600">← Arbeidsflaten</a>
|
|
<h1 class="text-lg font-semibold text-gray-900">Kunnskapsgraf</h1>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
{#if focusId}
|
|
<button
|
|
onclick={resetFocus}
|
|
class="rounded bg-gray-100 px-2 py-1 text-xs text-gray-600 hover:bg-gray-200"
|
|
>
|
|
Vis alle
|
|
</button>
|
|
{/if}
|
|
<span class="text-xs text-gray-400">
|
|
{graphNodes.length} noder, {graphEdges.length} kanter
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Toolbar -->
|
|
<div class="border-b border-gray-200 bg-white px-4 py-2 shrink-0">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<!-- Depth control -->
|
|
<label class="flex items-center gap-1 text-xs text-gray-500">
|
|
Dybde:
|
|
<select
|
|
bind:value={depth}
|
|
onchange={() => loadGraph()}
|
|
class="rounded border border-gray-200 bg-white px-1 py-0.5 text-xs"
|
|
>
|
|
<option value={1}>1</option>
|
|
<option value={2}>2</option>
|
|
<option value={3}>3</option>
|
|
</select>
|
|
</label>
|
|
|
|
<div class="h-4 w-px bg-gray-200"></div>
|
|
|
|
<!-- Create topic -->
|
|
<button
|
|
onclick={() => { showTopicForm = !showTopicForm; showMentionsForm = false; }}
|
|
class="rounded bg-violet-100 px-2 py-1 text-xs font-medium text-violet-700 hover:bg-violet-200"
|
|
>
|
|
+ Nytt tema
|
|
</button>
|
|
|
|
<!-- Create mention -->
|
|
<button
|
|
onclick={() => { showMentionsForm = !showMentionsForm; showTopicForm = false; }}
|
|
class="rounded bg-purple-100 px-2 py-1 text-xs font-medium text-purple-700 hover:bg-purple-200"
|
|
>
|
|
+ Kobling
|
|
</button>
|
|
|
|
<button
|
|
onclick={() => loadGraph()}
|
|
class="rounded bg-gray-100 px-2 py-1 text-xs text-gray-600 hover:bg-gray-200"
|
|
>
|
|
Oppdater
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Topic creation form -->
|
|
{#if showTopicForm}
|
|
<div class="mt-2 flex items-center gap-2">
|
|
<input
|
|
type="text"
|
|
bind:value={newTopicTitle}
|
|
placeholder="Temanavn…"
|
|
class="rounded border border-gray-200 px-2 py-1 text-sm focus:border-violet-400 focus:outline-none"
|
|
onkeydown={(e) => e.key === 'Enter' && handleCreateTopic()}
|
|
/>
|
|
<button
|
|
onclick={handleCreateTopic}
|
|
disabled={isCreatingTopic || !newTopicTitle.trim()}
|
|
class="rounded bg-violet-600 px-3 py-1 text-xs font-medium text-white hover:bg-violet-700 disabled:opacity-50"
|
|
>
|
|
{isCreatingTopic ? '…' : 'Opprett'}
|
|
</button>
|
|
<button
|
|
onclick={() => { showTopicForm = false; newTopicTitle = ''; }}
|
|
class="text-xs text-gray-500 hover:text-gray-700"
|
|
>
|
|
Avbryt
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Mentions edge creation form -->
|
|
{#if showMentionsForm}
|
|
<div class="mt-2 flex flex-wrap items-center gap-2">
|
|
<select
|
|
bind:value={mentionsSourceId}
|
|
class="rounded border border-gray-200 px-2 py-1 text-xs focus:border-purple-400 focus:outline-none"
|
|
>
|
|
<option value="">Fra node…</option>
|
|
{#each graphNodes.sort((a, b) => (a.title ?? '').localeCompare(b.title ?? '')) as n}
|
|
<option value={n.id}>{n.title || n.id.slice(0, 8)} ({kindLabels[n.node_kind] ?? n.node_kind})</option>
|
|
{/each}
|
|
</select>
|
|
<span class="text-xs text-gray-400">nevner</span>
|
|
<select
|
|
bind:value={mentionsTargetId}
|
|
class="rounded border border-gray-200 px-2 py-1 text-xs focus:border-purple-400 focus:outline-none"
|
|
>
|
|
<option value="">Til node…</option>
|
|
{#each graphNodes.filter(n => n.id !== mentionsSourceId).sort((a, b) => (a.title ?? '').localeCompare(b.title ?? '')) as n}
|
|
<option value={n.id}>{n.title || n.id.slice(0, 8)} ({kindLabels[n.node_kind] ?? n.node_kind})</option>
|
|
{/each}
|
|
</select>
|
|
<button
|
|
onclick={handleCreateMention}
|
|
disabled={isCreatingMention || !mentionsSourceId || !mentionsTargetId}
|
|
class="rounded bg-purple-600 px-3 py-1 text-xs font-medium text-white hover:bg-purple-700 disabled:opacity-50"
|
|
>
|
|
{isCreatingMention ? '…' : 'Opprett'}
|
|
</button>
|
|
<button
|
|
onclick={() => { showMentionsForm = false; mentionsSourceId = ''; mentionsTargetId = ''; }}
|
|
class="text-xs text-gray-500 hover:text-gray-700"
|
|
>
|
|
Avbryt
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Main content: graph + side panel -->
|
|
<div class="flex flex-1 overflow-hidden">
|
|
<!-- Graph SVG -->
|
|
<div class="flex-1 relative">
|
|
{#if loading}
|
|
<div class="absolute inset-0 flex items-center justify-center">
|
|
<p class="text-sm text-gray-400">Laster graf…</p>
|
|
</div>
|
|
{:else if error}
|
|
<div class="absolute inset-0 flex items-center justify-center">
|
|
<p class="text-sm text-red-500">{error}</p>
|
|
</div>
|
|
{:else if graphNodes.length === 0}
|
|
<div class="absolute inset-0 flex items-center justify-center">
|
|
<div class="text-center">
|
|
<p class="text-sm text-gray-400">Ingen noder å vise.</p>
|
|
<p class="mt-1 text-xs text-gray-300">Opprett et tema for å komme i gang.</p>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
<svg bind:this={svgEl} class="w-full h-full"></svg>
|
|
|
|
<!-- Legend -->
|
|
<div class="absolute bottom-3 left-3 rounded-lg border border-gray-200 bg-white/90 p-2 text-xs backdrop-blur">
|
|
<div class="mb-1 font-medium text-gray-500">Nodetyper</div>
|
|
<div class="flex flex-wrap gap-x-3 gap-y-1">
|
|
{#each Object.entries(kindColors) as [kind, color]}
|
|
<button
|
|
onclick={() => toggleKindFilter(kind)}
|
|
class="flex items-center gap-1 {filterKinds.includes(kind) ? 'opacity-100 font-medium' : filterKinds.length > 0 ? 'opacity-40' : 'opacity-80'}"
|
|
>
|
|
<span class="inline-block h-2.5 w-2.5 rounded-full" style="background:{color}"></span>
|
|
{kindLabels[kind] ?? kind}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
<div class="mt-1.5 mb-1 font-medium text-gray-500">Kanttyper</div>
|
|
<div class="flex flex-wrap gap-x-3 gap-y-1">
|
|
<button
|
|
onclick={() => toggleEdgeTypeFilter('mentions')}
|
|
class="flex items-center gap-1 {filterEdgeTypes.includes('mentions') ? 'font-medium' : filterEdgeTypes.length > 0 ? 'opacity-40' : 'opacity-80'}"
|
|
>
|
|
<span class="inline-block h-0.5 w-4 bg-violet-500"></span>
|
|
Nevner
|
|
</button>
|
|
<button
|
|
onclick={() => toggleEdgeTypeFilter('belongs_to')}
|
|
class="flex items-center gap-1 {filterEdgeTypes.includes('belongs_to') ? 'font-medium' : filterEdgeTypes.length > 0 ? 'opacity-40' : 'opacity-80'}"
|
|
>
|
|
<span class="inline-block h-0.5 w-4 bg-gray-400" style="border-top: 1px dashed"></span>
|
|
Tilhører
|
|
</button>
|
|
<button
|
|
onclick={() => toggleEdgeTypeFilter('part_of')}
|
|
class="flex items-center gap-1 {filterEdgeTypes.includes('part_of') ? 'font-medium' : filterEdgeTypes.length > 0 ? 'opacity-40' : 'opacity-80'}"
|
|
>
|
|
<span class="inline-block h-0.5 w-4 bg-amber-500" style="border-top: 1px dashed"></span>
|
|
Del av
|
|
</button>
|
|
</div>
|
|
<p class="mt-1.5 text-gray-300">Dobbeltklikk node for å fokusere. Dra for å flytte.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Side panel: selected node info -->
|
|
{#if selectedNode}
|
|
<div class="w-72 shrink-0 border-l border-gray-200 bg-white p-4 overflow-y-auto">
|
|
<div class="flex items-start justify-between">
|
|
<h3 class="font-medium text-gray-900">{selectedNode.title || 'Uten tittel'}</h3>
|
|
<button
|
|
onclick={() => selectedNode = null}
|
|
class="text-gray-400 hover:text-gray-600"
|
|
>
|
|
×
|
|
</button>
|
|
</div>
|
|
<span
|
|
class="mt-1 inline-block rounded-full px-2 py-0.5 text-xs text-white"
|
|
style="background:{nodeColor(selectedNode.node_kind)}"
|
|
>
|
|
{kindLabels[selectedNode.node_kind] ?? selectedNode.node_kind}
|
|
</span>
|
|
|
|
<!-- Edges for this node -->
|
|
{#if graphEdges.filter(e => e.source_id === selectedNode?.id || e.target_id === selectedNode?.id).length > 0}
|
|
{@const nodeEdges = graphEdges.filter(e => e.source_id === selectedNode?.id || e.target_id === selectedNode?.id)}
|
|
<div class="mt-4">
|
|
<h4 class="text-xs font-medium text-gray-500">Koblinger ({nodeEdges.length})</h4>
|
|
<ul class="mt-1 space-y-1">
|
|
{#each nodeEdges as edge}
|
|
{@const otherId = edge.source_id === selectedNode?.id ? edge.target_id : edge.source_id}
|
|
{@const otherNode = graphNodes.find(n => n.id === otherId)}
|
|
{@const direction = edge.source_id === selectedNode?.id ? '→' : '←'}
|
|
<li class="text-xs text-gray-600">
|
|
<span class="text-gray-400">{direction}</span>
|
|
<span class="font-medium">{edgeTypeLabels[edge.edge_type] ?? edge.edge_type}</span>
|
|
<button
|
|
onclick={() => {
|
|
focusId = otherId;
|
|
loadGraph();
|
|
}}
|
|
class="ml-1 text-blue-600 hover:underline"
|
|
>
|
|
{otherNode?.title || otherId.slice(0, 8) + '…'}
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="mt-4 flex flex-col gap-1">
|
|
<button
|
|
onclick={() => { focusId = selectedNode?.id ?? null; loadGraph(); }}
|
|
class="rounded bg-gray-100 px-2 py-1 text-xs text-gray-700 hover:bg-gray-200"
|
|
>
|
|
Fokuser på denne
|
|
</button>
|
|
<button
|
|
onclick={() => {
|
|
showMentionsForm = true;
|
|
showTopicForm = false;
|
|
mentionsSourceId = selectedNode?.id ?? '';
|
|
}}
|
|
class="rounded bg-purple-100 px-2 py-1 text-xs text-purple-700 hover:bg-purple-200"
|
|
>
|
|
Opprett kobling fra denne
|
|
</button>
|
|
</div>
|
|
|
|
<p class="mt-4 text-xs text-gray-300 break-all">ID: {selectedNode.id}</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|