- 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>
535 lines
12 KiB
Svelte
535 lines
12 KiB
Svelte
<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; }}>
|
|
← 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)}>×</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>
|