Frontend: entiteter, graf-visualisering og #-autocomplete

- EntitiesBlock: liste med søk/filter, opprett, rediger, slett,
  relasjonsvisning med navigering mellom entiteter
- GraphBlock: SVG force-directed layout via traverse API,
  pan/zoom, drag noder, dobbeltklikk for å utforske
- EntityAutocomplete: #-mention med debounced søk, tastaturnavigering,
  dropdown med typefarger og aliaser
- Registrert entities block-type + kunnskapsgraf-side i seed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-15 15:46:34 +01:00
parent 3f8ef65c5f
commit 6edd1fa091
5 changed files with 1160 additions and 12 deletions

View file

@ -192,6 +192,16 @@ UPDATE workspaces SET settings = jsonb_set(
{"id": "notes-1", "type": "notes", "title": "Show notes", "props": {"noteId": "a0000000-0000-0000-0000-000000000040"}}
]
},
{
"slug": "kunnskapsgraf",
"title": "Kunnskapsgraf",
"icon": "🕸️",
"layout": "2-1",
"blocks": [
{"id": "graph-1", "type": "graph", "title": "Grafvisning"},
{"id": "entities-1", "type": "entities", "title": "Entiteter"}
]
},
{
"slug": "research",
"title": "Research",
@ -199,7 +209,7 @@ UPDATE workspaces SET settings = jsonb_set(
"layout": "2-col",
"blocks": [
{"id": "research-1", "type": "research", "title": "Research-klipp"},
{"id": "graph-1", "type": "graph", "title": "Kunnskapsgraf"}
{"id": "entities-2", "type": "entities", "title": "Entiteter"}
]
}
]'::jsonb

View file

@ -0,0 +1,535 @@
<script lang="ts">
import { onMount } from 'svelte';
let { props = {} }: { props?: Record<string, unknown> } = $props();
interface Entity {
id: string;
name: string;
type: string;
aliases: string[];
avatar_url: string | null;
edge_count?: string;
created_at: string;
}
interface Edge {
edge_id: string;
source_id: string;
target_id: string;
relation_type: string;
relation_label: string;
connected_name: string;
connected_type: string;
connected_id: string;
}
let entities = $state<Entity[]>([]);
let loading = $state(true);
let error = $state('');
let query = $state('');
let typeFilter = $state('');
// Opprett-skjema
let showCreate = $state(false);
let newName = $state('');
let newType = $state('person');
let newAliases = $state('');
// Detalj-visning
let selected = $state<Entity | null>(null);
let selectedEdges = $state<Edge[]>([]);
let loadingEdges = $state(false);
// Redigering
let editing = $state(false);
let editName = $state('');
let editType = $state('');
let editAliases = $state('');
const entityTypes = ['person', 'organisasjon', 'sted', 'tema', 'konsept'];
const typeColors: Record<string, string> = {
person: '#3b82f6',
organisasjon: '#f59e0b',
sted: '#10b981',
tema: '#8b5cf6',
konsept: '#ec4899'
};
let searchTimeout: ReturnType<typeof setTimeout>;
function debounceSearch() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(fetchEntities, 200);
}
async function fetchEntities() {
try {
const params = new URLSearchParams();
if (query) params.set('q', query);
if (typeFilter) params.set('type', typeFilter);
const res = await fetch(`/api/entities?${params}`);
if (!res.ok) throw new Error('Feil ved henting');
entities = await res.json();
} catch (e) {
error = (e as Error).message;
} finally {
loading = false;
}
}
async function createEntity() {
if (!newName.trim()) return;
const res = await fetch('/api/entities', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newName.trim(),
type: newType,
aliases: newAliases.split(',').map(a => a.trim()).filter(Boolean)
})
});
if (res.ok) {
newName = '';
newAliases = '';
showCreate = false;
fetchEntities();
}
}
async function selectEntity(entity: Entity) {
selected = entity;
editing = false;
loadingEdges = true;
try {
const res = await fetch(`/api/entities/${entity.id}/edges`);
selectedEdges = res.ok ? await res.json() : [];
} catch {
selectedEdges = [];
} finally {
loadingEdges = false;
}
}
function startEdit() {
if (!selected) return;
editing = true;
editName = selected.name;
editType = selected.type;
editAliases = selected.aliases.join(', ');
}
async function saveEdit() {
if (!selected) return;
const res = await fetch(`/api/entities/${selected.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: editName.trim(),
type: editType,
aliases: editAliases.split(',').map(a => a.trim()).filter(Boolean)
})
});
if (res.ok) {
const updated = await res.json();
selected = { ...selected, ...updated };
editing = false;
fetchEntities();
}
}
async function deleteEntity() {
if (!selected) return;
const res = await fetch(`/api/entities/${selected.id}`, { method: 'DELETE' });
if (res.ok) {
selected = null;
fetchEntities();
}
}
async function deleteEdge(edgeId: string) {
const res = await fetch(`/api/graph/edges/${edgeId}`, { method: 'DELETE' });
if (res.ok && selected) {
selectEntity(selected);
}
}
onMount(fetchEntities);
</script>
{#if selected}
<!-- Detalj-visning -->
<div class="detail">
<button type="button" class="back-btn" onclick={() => { selected = null; }}>
&larr; Tilbake
</button>
{#if editing}
<div class="edit-form">
<input type="text" bind:value={editName} placeholder="Navn" class="input" />
<select bind:value={editType} class="input">
{#each entityTypes as t}<option value={t}>{t}</option>{/each}
</select>
<input type="text" bind:value={editAliases} placeholder="Aliaser (kommaseparert)" class="input" />
<div class="actions">
<button type="button" class="btn primary" onclick={saveEdit}>Lagre</button>
<button type="button" class="btn" onclick={() => { editing = false; }}>Avbryt</button>
</div>
</div>
{:else}
<div class="entity-header">
<span class="type-badge" style:background={typeColors[selected.type] ?? '#8b92a5'}>
{selected.type}
</span>
<h3 class="entity-name">{selected.name}</h3>
{#if selected.aliases.length > 0}
<div class="aliases">
{#each selected.aliases as alias}
<span class="alias">{alias}</span>
{/each}
</div>
{/if}
<div class="actions">
<button type="button" class="btn" onclick={startEdit}>Rediger</button>
<button type="button" class="btn danger" onclick={deleteEntity}>Slett</button>
</div>
</div>
<div class="edges-section">
<h4 class="section-title">Relasjoner</h4>
{#if loadingEdges}
<p class="muted">Laster...</p>
{:else if selectedEdges.length === 0}
<p class="muted">Ingen relasjoner</p>
{:else}
{#each selectedEdges as edge (edge.edge_id)}
<div class="edge-row">
<span class="edge-label">{edge.relation_label}</span>
<button
type="button"
class="edge-target"
onclick={() => {
const target = entities.find(e => e.id === edge.connected_id);
if (target) selectEntity(target);
}}
>
{edge.connected_name}
</button>
<span class="edge-type-badge" style:background={typeColors[edge.connected_type] ?? '#8b92a5'}>
{edge.connected_type}
</span>
<button type="button" class="edge-delete" onclick={() => deleteEdge(edge.edge_id)}>&times;</button>
</div>
{/each}
{/if}
</div>
{/if}
</div>
{:else}
<!-- Liste-visning -->
<div class="list-view">
<div class="search-row">
<input
type="text"
placeholder="Søk entiteter..."
bind:value={query}
oninput={debounceSearch}
class="input search"
/>
<select bind:value={typeFilter} onchange={fetchEntities} class="input type-select">
<option value="">Alle typer</option>
{#each entityTypes as t}<option value={t}>{t}</option>{/each}
</select>
<button type="button" class="btn primary" onclick={() => { showCreate = !showCreate; }}>+</button>
</div>
{#if showCreate}
<div class="create-form">
<input type="text" bind:value={newName} placeholder="Navn" class="input" />
<select bind:value={newType} class="input">
{#each entityTypes as t}<option value={t}>{t}</option>{/each}
</select>
<input type="text" bind:value={newAliases} placeholder="Aliaser (kommaseparert)" class="input" />
<div class="actions">
<button type="button" class="btn primary" onclick={createEntity} disabled={!newName.trim()}>Opprett</button>
<button type="button" class="btn" onclick={() => { showCreate = false; }}>Avbryt</button>
</div>
</div>
{/if}
{#if loading}
<p class="muted center">Laster...</p>
{:else if entities.length === 0}
<p class="muted center">{query ? 'Ingen treff' : 'Ingen entiteter ennå'}</p>
{:else}
<div class="entity-list">
{#each entities as entity (entity.id)}
<button type="button" class="entity-row" onclick={() => selectEntity(entity)}>
<span class="type-dot" style:background={typeColors[entity.type] ?? '#8b92a5'}></span>
<span class="entity-row-name">{entity.name}</span>
{#if entity.aliases.length > 0}
<span class="entity-row-alias">{entity.aliases[0]}</span>
{/if}
<span class="entity-row-type">{entity.type}</span>
</button>
{/each}
</div>
{/if}
</div>
{/if}
{#if error}
<div class="error-msg">{error}</div>
{/if}
<style>
.list-view, .detail {
display: flex;
flex-direction: column;
gap: 0.5rem;
height: 100%;
min-height: 0;
}
.search-row {
display: flex;
gap: 0.35rem;
}
.search {
flex: 1;
}
.type-select {
width: 110px;
}
.input {
background: #0f1117;
border: 1px solid #2d3148;
border-radius: 6px;
color: #e1e4e8;
padding: 0.4rem 0.5rem;
font-size: 0.8rem;
font-family: inherit;
}
.input:focus {
outline: none;
border-color: #3b82f6;
}
.input::placeholder {
color: #8b92a5;
}
select.input {
cursor: pointer;
}
.btn {
background: #1e2235;
border: 1px solid #2d3148;
border-radius: 6px;
color: #8b92a5;
padding: 0.4rem 0.6rem;
font-size: 0.8rem;
cursor: pointer;
font-family: inherit;
white-space: nowrap;
}
.btn:hover { background: #2d3148; color: #e1e4e8; }
.btn.primary { background: #3b82f6; color: white; border-color: #3b82f6; }
.btn.primary:hover { background: #2563eb; }
.btn.primary:disabled { opacity: 0.5; cursor: default; }
.btn.danger { background: #dc2626; color: white; border-color: #dc2626; }
.btn.danger:hover { background: #b91c1c; }
.create-form, .edit-form {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.5rem;
background: #0f1117;
border-radius: 6px;
}
.actions {
display: flex;
gap: 0.35rem;
}
.entity-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
}
.entity-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.5rem;
border: none;
background: none;
color: #e1e4e8;
font-family: inherit;
font-size: 0.8rem;
cursor: pointer;
border-radius: 4px;
text-align: left;
width: 100%;
}
.entity-row:hover {
background: #1e2235;
}
.type-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.entity-row-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.entity-row-alias {
color: #8b92a5;
font-size: 0.7rem;
}
.entity-row-type {
color: #8b92a5;
font-size: 0.7rem;
flex-shrink: 0;
}
/* Detalj */
.back-btn {
background: none;
border: none;
color: #3b82f6;
cursor: pointer;
font-size: 0.8rem;
padding: 0;
font-family: inherit;
text-align: left;
}
.back-btn:hover { text-decoration: underline; }
.entity-header {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.type-badge {
display: inline-block;
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-size: 0.7rem;
color: white;
font-weight: 600;
text-transform: uppercase;
align-self: flex-start;
}
.entity-name {
font-size: 1.1rem;
color: #e1e4e8;
margin: 0;
font-weight: 600;
}
.aliases {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.alias {
background: #1e2235;
color: #8b92a5;
padding: 0.1rem 0.4rem;
border-radius: 4px;
font-size: 0.7rem;
}
.edges-section {
flex: 1;
overflow-y: auto;
margin-top: 0.5rem;
}
.section-title {
font-size: 0.75rem;
color: #8b92a5;
text-transform: uppercase;
letter-spacing: 0.03em;
margin: 0 0 0.35rem;
font-weight: 600;
}
.edge-row {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0;
font-size: 0.8rem;
border-bottom: 1px solid #1e2235;
}
.edge-label {
color: #8b92a5;
font-size: 0.7rem;
flex-shrink: 0;
}
.edge-target {
color: #3b82f6;
background: none;
border: none;
cursor: pointer;
font-size: 0.8rem;
font-family: inherit;
padding: 0;
flex: 1;
text-align: left;
}
.edge-target:hover { text-decoration: underline; }
.edge-type-badge {
padding: 0.1rem 0.3rem;
border-radius: 3px;
font-size: 0.6rem;
color: white;
flex-shrink: 0;
}
.edge-delete {
background: none;
border: none;
color: #8b92a5;
cursor: pointer;
font-size: 1rem;
padding: 0 0.2rem;
line-height: 1;
}
.edge-delete:hover { color: #f87171; }
.muted { color: #8b92a5; font-size: 0.8rem; margin: 0; }
.center { text-align: center; padding: 2rem 0; }
.error-msg { color: #f87171; font-size: 0.75rem; padding: 0.25rem; }
</style>

View file

@ -1,25 +1,388 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
let { props = {} }: { props?: Record<string, unknown> } = $props();
interface GraphNode {
id: string;
depth: number;
node_type: string;
name: string | null;
entity_type: string | null;
avatar_url: string | null;
x: number;
y: number;
vx: number;
vy: number;
}
interface GraphEdge {
id: string;
source_id: string;
target_id: string;
relation_type: string;
relation_label: string;
}
let nodes = $state<GraphNode[]>([]);
let edges = $state<GraphEdge[]>([]);
let loading = $state(false);
let error = $state('');
let searchQuery = $state('');
let selectedNodeId = $state<string | null>(null);
let svgEl: SVGSVGElement;
let animFrame: number;
// Pan/zoom
let viewBox = $state({ x: -300, y: -300, w: 600, h: 600 });
let dragging = $state<GraphNode | null>(null);
let panning = $state(false);
let panStart = { x: 0, y: 0 };
const typeColors: Record<string, string> = {
person: '#3b82f6',
organisasjon: '#f59e0b',
sted: '#10b981',
tema: '#8b5cf6',
konsept: '#ec4899'
};
async function loadGraph(nodeId: string) {
loading = true;
error = '';
try {
const res = await fetch(`/api/graph/traverse/${nodeId}?depth=2`);
if (!res.ok) throw new Error('Feil ved henting av graf');
const data = await res.json();
// Initialiser posisjoner i sirkel
const count = data.nodes.length;
nodes = data.nodes.map((n: Omit<GraphNode, 'x' | 'y' | 'vx' | 'vy'>, i: number) => ({
...n,
x: Math.cos((2 * Math.PI * i) / count) * 120 * (n.depth + 1),
y: Math.sin((2 * Math.PI * i) / count) * 120 * (n.depth + 1),
vx: 0,
vy: 0
}));
edges = data.edges;
selectedNodeId = nodeId;
startSimulation();
} catch (e) {
error = (e as Error).message;
} finally {
loading = false;
}
}
async function searchAndLoad() {
if (!searchQuery.trim()) return;
try {
const res = await fetch(`/api/entities?q=${encodeURIComponent(searchQuery.trim())}&limit=1`);
const results = await res.json();
if (results.length > 0) {
loadGraph(results[0].id);
} else {
error = 'Ingen treff';
}
} catch {
error = 'Søkefeil';
}
}
// Enkel force-directed layout
function startSimulation() {
let iterations = 0;
const maxIterations = 200;
function tick() {
if (iterations >= maxIterations) return;
iterations++;
const alpha = 1 - iterations / maxIterations;
const k = alpha * 0.3;
// Repulsion mellom alle noder
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const dx = nodes[j].x - nodes[i].x;
const dy = nodes[j].y - nodes[i].y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = (150 * 150) / dist;
const fx = (dx / dist) * force * k;
const fy = (dy / dist) * force * k;
nodes[i].vx -= fx;
nodes[i].vy -= fy;
nodes[j].vx += fx;
nodes[j].vy += fy;
}
}
// Attraction langs edges
for (const edge of edges) {
const source = nodes.find(n => n.id === edge.source_id);
const target = nodes.find(n => n.id === edge.target_id);
if (!source || !target) continue;
const dx = target.x - source.x;
const dy = target.y - source.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = (dist - 120) * 0.01 * k;
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
source.vx += fx;
source.vy += fy;
target.vx -= fx;
target.vy -= fy;
}
// Center gravity
for (const node of nodes) {
node.vx -= node.x * 0.005 * k;
node.vy -= node.y * 0.005 * k;
}
// Apply velocity
for (const node of nodes) {
if (dragging && node.id === dragging.id) continue;
node.vx *= 0.6;
node.vy *= 0.6;
node.x += node.vx;
node.y += node.vy;
}
// Trigger reactivity
nodes = [...nodes];
if (alpha > 0.01) {
animFrame = requestAnimationFrame(tick);
}
}
cancelAnimationFrame(animFrame);
tick();
}
function handleNodeClick(node: GraphNode) {
if (node.node_type === 'entitet') {
loadGraph(node.id);
}
}
function handleMouseDown(e: MouseEvent, node: GraphNode) {
e.preventDefault();
dragging = node;
}
function handleSvgMouseMove(e: MouseEvent) {
if (dragging && svgEl) {
const rect = svgEl.getBoundingClientRect();
const scaleX = viewBox.w / rect.width;
const scaleY = viewBox.h / rect.height;
dragging.x = viewBox.x + (e.clientX - rect.left) * scaleX;
dragging.y = viewBox.y + (e.clientY - rect.top) * scaleY;
nodes = [...nodes];
} else if (panning) {
const dx = (e.clientX - panStart.x) * (viewBox.w / svgEl.getBoundingClientRect().width);
const dy = (e.clientY - panStart.y) * (viewBox.h / svgEl.getBoundingClientRect().height);
viewBox = { ...viewBox, x: viewBox.x - dx, y: viewBox.y - dy };
panStart = { x: e.clientX, y: e.clientY };
}
}
function handleSvgMouseUp() {
dragging = null;
panning = false;
}
function handleSvgMouseDown(e: MouseEvent) {
if (e.target === svgEl || (e.target as Element).tagName === 'line') {
panning = true;
panStart = { x: e.clientX, y: e.clientY };
}
}
function handleWheel(e: WheelEvent) {
e.preventDefault();
const factor = e.deltaY > 0 ? 1.1 : 0.9;
const rect = svgEl.getBoundingClientRect();
const mx = viewBox.x + ((e.clientX - rect.left) / rect.width) * viewBox.w;
const my = viewBox.y + ((e.clientY - rect.top) / rect.height) * viewBox.h;
const nw = viewBox.w * factor;
const nh = viewBox.h * factor;
viewBox = {
x: mx - ((mx - viewBox.x) / viewBox.w) * nw,
y: my - ((my - viewBox.y) / viewBox.h) * nh,
w: nw,
h: nh
};
}
function getNodeColor(node: GraphNode): string {
return typeColors[node.entity_type ?? ''] ?? '#8b92a5';
}
onMount(() => {
// Last initial graf fra første entitet om ingen er valgt
fetch('/api/entities?limit=1').then(r => r.json()).then(data => {
if (data.length > 0) loadGraph(data[0].id);
});
});
onDestroy(() => cancelAnimationFrame(animFrame));
</script>
<div class="placeholder">
<span class="icon">🕸️</span>
<p class="label">Graph</p>
<p class="hint">Kommer snart</p>
<div class="graph-container">
<div class="graph-toolbar">
<input
type="text"
placeholder="Søk entitet..."
bind:value={searchQuery}
onkeydown={(e) => { if (e.key === 'Enter') searchAndLoad(); }}
class="input"
/>
<button type="button" class="btn" onclick={searchAndLoad}>Vis</button>
</div>
{#if loading}
<div class="center-msg">Laster graf...</div>
{:else if error}
<div class="center-msg error">{error}</div>
{:else if nodes.length === 0}
<div class="center-msg">Søk etter en entitet for å visualisere grafen</div>
{:else}
<svg
bind:this={svgEl}
viewBox="{viewBox.x} {viewBox.y} {viewBox.w} {viewBox.h}"
class="graph-svg"
onmousemove={handleSvgMouseMove}
onmouseup={handleSvgMouseUp}
onmouseleave={handleSvgMouseUp}
onmousedown={handleSvgMouseDown}
onwheel={handleWheel}
>
<!-- Edges -->
{#each edges as edge (edge.id)}
{@const source = nodes.find(n => n.id === edge.source_id)}
{@const target = nodes.find(n => n.id === edge.target_id)}
{#if source && target}
<line
x1={source.x} y1={source.y}
x2={target.x} y2={target.y}
class="edge-line"
/>
<text
x={(source.x + target.x) / 2}
y={(source.y + target.y) / 2 - 6}
class="edge-label"
>{edge.relation_label}</text>
{/if}
{/each}
<!-- Nodes -->
{#each nodes as node (node.id)}
<g
class="graph-node"
class:selected={node.id === selectedNodeId}
onmousedown={(e) => handleMouseDown(e, node)}
ondblclick={() => handleNodeClick(node)}
>
<circle
cx={node.x} cy={node.y} r={node.id === selectedNodeId ? 22 : 16}
fill={getNodeColor(node)}
opacity={0.85}
/>
<text
x={node.x} y={node.y + 28}
class="node-label"
>{node.name ?? node.id.slice(0, 8)}</text>
</g>
{/each}
</svg>
{/if}
</div>
<style>
.placeholder {
.graph-container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.graph-toolbar {
display: flex;
gap: 0.35rem;
padding-bottom: 0.5rem;
flex-shrink: 0;
}
.input {
flex: 1;
background: #0f1117;
border: 1px solid #2d3148;
border-radius: 6px;
color: #e1e4e8;
padding: 0.4rem 0.5rem;
font-size: 0.8rem;
font-family: inherit;
}
.input:focus { outline: none; border-color: #3b82f6; }
.input::placeholder { color: #8b92a5; }
.btn {
background: #1e2235;
border: 1px solid #2d3148;
border-radius: 6px;
color: #8b92a5;
padding: 0.4rem 0.6rem;
font-size: 0.8rem;
cursor: pointer;
font-family: inherit;
}
.btn:hover { background: #2d3148; color: #e1e4e8; }
.graph-svg {
flex: 1;
width: 100%;
min-height: 0;
cursor: grab;
user-select: none;
}
.graph-svg:active { cursor: grabbing; }
.edge-line {
stroke: #2d3148;
stroke-width: 1.5;
}
.edge-label {
fill: #8b92a5;
font-size: 8px;
text-anchor: middle;
pointer-events: none;
}
.graph-node { cursor: pointer; }
.graph-node:hover circle { opacity: 1; }
.graph-node.selected circle { stroke: white; stroke-width: 2; }
.node-label {
fill: #e1e4e8;
font-size: 10px;
text-anchor: middle;
pointer-events: none;
}
.center-msg {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
flex: 1;
color: #8b92a5;
gap: 0.5rem;
font-size: 0.85rem;
}
.icon { font-size: 2rem; }
.label { font-weight: 600; color: #e1e4e8; }
.hint { font-size: 0.8rem; }
.center-msg.error { color: #f87171; }
</style>

View file

@ -27,6 +27,11 @@ export const blockRegistry: Record<string, BlockMeta> = {
icon: '🕸️',
component: () => import('./GraphBlock.svelte')
},
entities: {
label: 'Entiteter',
icon: '#️⃣',
component: () => import('./EntitiesBlock.svelte')
},
research: {
label: 'Research',
icon: '🔍',

View file

@ -0,0 +1,235 @@
<script lang="ts">
/**
* EntityAutocomplete — #-mention-komponent.
* Aktiveres når brukeren skriver # i en textarea/input.
* Søker i entities API og lar brukeren velge fra en dropdown.
*
* Bruk:
* <EntityAutocomplete bind:text onMention={(entity) => ...} />
*/
interface Entity {
id: string;
name: string;
type: string;
aliases: string[];
}
let {
text = $bindable(''),
placeholder = '',
onMention = (_entity: Entity) => {}
}: {
text?: string;
placeholder?: string;
onMention?: (entity: Entity) => void;
} = $props();
let suggestions = $state<Entity[]>([]);
let showDropdown = $state(false);
let selectedIndex = $state(0);
let hashStart = $state(-1);
let inputEl: HTMLTextAreaElement;
const typeColors: Record<string, string> = {
person: '#3b82f6',
organisasjon: '#f59e0b',
sted: '#10b981',
tema: '#8b5cf6',
konsept: '#ec4899'
};
let searchTimeout: ReturnType<typeof setTimeout>;
function handleInput() {
const pos = inputEl.selectionStart ?? 0;
const textBefore = text.slice(0, pos);
// Finn siste # som ikke er escaped
const hashMatch = textBefore.match(/#([^\s#]*)$/);
if (hashMatch) {
hashStart = pos - hashMatch[1].length - 1;
const query = hashMatch[1];
if (query.length >= 1) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => searchEntities(query), 150);
} else {
suggestions = [];
showDropdown = true;
}
} else {
showDropdown = false;
suggestions = [];
}
}
async function searchEntities(query: string) {
try {
const res = await fetch(`/api/entities?q=${encodeURIComponent(query)}&limit=8`);
if (res.ok) {
suggestions = await res.json();
selectedIndex = 0;
showDropdown = suggestions.length > 0;
}
} catch {
suggestions = [];
showDropdown = false;
}
}
function selectSuggestion(entity: Entity) {
const pos = inputEl.selectionStart ?? 0;
const before = text.slice(0, hashStart);
const after = text.slice(pos);
text = `${before}#${entity.name}${after}`;
showDropdown = false;
suggestions = [];
onMention(entity);
// Sett cursor etter mention
const newPos = hashStart + 1 + entity.name.length;
requestAnimationFrame(() => {
inputEl.focus();
inputEl.setSelectionRange(newPos, newPos);
});
}
function handleKeydown(e: KeyboardEvent) {
if (!showDropdown || suggestions.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = (selectedIndex + 1) % suggestions.length;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex = (selectedIndex - 1 + suggestions.length) % suggestions.length;
} else if (e.key === 'Enter' || e.key === 'Tab') {
if (showDropdown && suggestions.length > 0) {
e.preventDefault();
selectSuggestion(suggestions[selectedIndex]);
}
} else if (e.key === 'Escape') {
showDropdown = false;
}
}
</script>
<div class="autocomplete-wrapper">
<textarea
bind:this={inputEl}
bind:value={text}
{placeholder}
oninput={handleInput}
onkeydown={handleKeydown}
onblur={() => { setTimeout(() => { showDropdown = false; }, 200); }}
rows="3"
class="textarea"
></textarea>
{#if showDropdown && suggestions.length > 0}
<div class="dropdown">
{#each suggestions as entity, i (entity.id)}
<button
type="button"
class="suggestion"
class:selected={i === selectedIndex}
onmousedown|preventDefault={() => selectSuggestion(entity)}
>
<span class="dot" style:background={typeColors[entity.type] ?? '#8b92a5'}></span>
<span class="name">{entity.name}</span>
{#if entity.aliases.length > 0}
<span class="alias">{entity.aliases[0]}</span>
{/if}
<span class="type">{entity.type}</span>
</button>
{/each}
</div>
{/if}
</div>
<style>
.autocomplete-wrapper {
position: relative;
}
.textarea {
width: 100%;
background: #0f1117;
border: 1px solid #2d3148;
border-radius: 6px;
color: #e1e4e8;
padding: 0.5rem;
font-size: 0.85rem;
font-family: inherit;
resize: vertical;
line-height: 1.4;
box-sizing: border-box;
}
.textarea:focus {
outline: none;
border-color: #3b82f6;
}
.textarea::placeholder {
color: #8b92a5;
}
.dropdown {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
background: #161822;
border: 1px solid #2d3148;
border-radius: 6px;
margin-bottom: 4px;
max-height: 200px;
overflow-y: auto;
z-index: 50;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.3);
}
.suggestion {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.5rem;
width: 100%;
border: none;
background: none;
color: #e1e4e8;
font-family: inherit;
font-size: 0.8rem;
cursor: pointer;
text-align: left;
}
.suggestion:hover, .suggestion.selected {
background: #1e2235;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.alias {
color: #8b92a5;
font-size: 0.7rem;
}
.type {
color: #8b92a5;
font-size: 0.7rem;
flex-shrink: 0;
}
</style>