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:
parent
3f8ef65c5f
commit
6edd1fa091
5 changed files with 1160 additions and 12 deletions
|
|
@ -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"}}
|
{"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",
|
"slug": "research",
|
||||||
"title": "Research",
|
"title": "Research",
|
||||||
|
|
@ -199,7 +209,7 @@ UPDATE workspaces SET settings = jsonb_set(
|
||||||
"layout": "2-col",
|
"layout": "2-col",
|
||||||
"blocks": [
|
"blocks": [
|
||||||
{"id": "research-1", "type": "research", "title": "Research-klipp"},
|
{"id": "research-1", "type": "research", "title": "Research-klipp"},
|
||||||
{"id": "graph-1", "type": "graph", "title": "Kunnskapsgraf"}
|
{"id": "entities-2", "type": "entities", "title": "Entiteter"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]'::jsonb
|
]'::jsonb
|
||||||
|
|
|
||||||
535
web/src/lib/blocks/EntitiesBlock.svelte
Normal file
535
web/src/lib/blocks/EntitiesBlock.svelte
Normal 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; }}>
|
||||||
|
← 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>
|
||||||
|
|
@ -1,25 +1,388 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
|
||||||
let { props = {} }: { props?: Record<string, unknown> } = $props();
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="placeholder">
|
<div class="graph-container">
|
||||||
<span class="icon">🕸️</span>
|
<div class="graph-toolbar">
|
||||||
<p class="label">Graph</p>
|
<input
|
||||||
<p class="hint">Kommer snart</p>
|
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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.placeholder {
|
.graph-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100%;
|
flex: 1;
|
||||||
min-height: 200px;
|
|
||||||
color: #8b92a5;
|
color: #8b92a5;
|
||||||
gap: 0.5rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.icon { font-size: 2rem; }
|
|
||||||
.label { font-weight: 600; color: #e1e4e8; }
|
.center-msg.error { color: #f87171; }
|
||||||
.hint { font-size: 0.8rem; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,11 @@ export const blockRegistry: Record<string, BlockMeta> = {
|
||||||
icon: '🕸️',
|
icon: '🕸️',
|
||||||
component: () => import('./GraphBlock.svelte')
|
component: () => import('./GraphBlock.svelte')
|
||||||
},
|
},
|
||||||
|
entities: {
|
||||||
|
label: 'Entiteter',
|
||||||
|
icon: '#️⃣',
|
||||||
|
component: () => import('./EntitiesBlock.svelte')
|
||||||
|
},
|
||||||
research: {
|
research: {
|
||||||
label: 'Research',
|
label: 'Research',
|
||||||
icon: '🔍',
|
icon: '🔍',
|
||||||
|
|
|
||||||
235
web/src/lib/components/EntityAutocomplete.svelte
Normal file
235
web/src/lib/components/EntityAutocomplete.svelte
Normal 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>
|
||||||
Loading…
Add table
Reference in a new issue