Node Explorer: clear-knapper, inline redigering av noder

✕ på søk/filter for rask reset. ✏️ for inline redigering av
tittel, innhold og metadata (JSON). Lagre via updateNode API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-20 03:22:05 +00:00
parent 3cc17c5784
commit 7974c9d53a

View file

@ -1,12 +1,10 @@
<script lang="ts">
/**
* Node Explorer — utforsk grafen.
*
* Søk noder etter kind/tittel/innhold. Vis en node med alle edges.
* Admin: full tilgang. Brukere: lesemodus for egne noder.
* Node Explorer — utforsk og rediger noder i grafen.
*/
import { nodeStore, edgeStore } from '$lib/realtime';
import type { Node } from '$lib/realtime';
import { updateNode } from '$lib/api';
interface Props {
collection: Node | undefined;
@ -22,6 +20,14 @@
let selectedNode = $state<Node | undefined>(undefined);
let limit = $state(50);
// Editing state
let editing = $state(false);
let editTitle = $state('');
let editContent = $state('');
let editMetadata = $state('');
let saving = $state(false);
let saveError = $state('');
const nodeKinds = $derived.by(() => {
const kinds = new Set<string>();
for (const node of nodeStore.all) {
@ -32,11 +38,9 @@
const results = $derived.by(() => {
let nodes = [...nodeStore.all];
if (filterKind) {
nodes = nodes.filter(n => n.nodeKind === filterKind);
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase().trim();
nodes = nodes.filter(n =>
@ -46,7 +50,6 @@
n.nodeKind.toLowerCase().includes(q)
);
}
nodes.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
return nodes.slice(0, limit);
});
@ -65,11 +68,52 @@
function selectNode(node: Node) {
selectedNode = node;
editing = false;
saveError = '';
}
function navigateToNode(id: string) {
const node = nodeStore.get(id);
if (node) selectedNode = node;
if (node) selectNode(node);
}
function startEditing() {
if (!selectedNode) return;
editTitle = selectedNode.title || '';
editContent = selectedNode.content || '';
editMetadata = parseMetadata(selectedNode.metadata);
editing = true;
saveError = '';
}
async function saveEdits() {
if (!selectedNode || !accessToken) return;
saving = true;
saveError = '';
try {
// Validér metadata JSON
let metadataObj: Record<string, unknown> | undefined;
if (editMetadata.trim() && editMetadata.trim() !== '{}') {
metadataObj = JSON.parse(editMetadata);
}
await updateNode(accessToken, {
node_id: selectedNode.id,
title: editTitle,
content: editContent,
...(metadataObj !== undefined ? { metadata: metadataObj } : {}),
});
editing = false;
} catch (e) {
saveError = String(e);
} finally {
saving = false;
}
}
function cancelEditing() {
editing = false;
saveError = '';
}
function formatDate(ts: number | undefined): string {
@ -98,18 +142,28 @@
<div class="ne">
<!-- Søk og filter -->
<div class="ne-toolbar">
<div class="ne-search-wrap">
<input
class="ne-search"
type="text"
placeholder="Søk noder (tittel, innhold, ID)..."
placeholder="Søk noder..."
bind:value={searchQuery}
/>
{#if searchQuery}
<button class="ne-clear" onclick={() => { searchQuery = ''; }} title="Tøm søk"></button>
{/if}
</div>
<div class="ne-filter-wrap">
<select class="ne-filter" bind:value={filterKind}>
<option value="">Alle typer ({totalCount})</option>
<option value="">Alle ({totalCount})</option>
{#each nodeKinds as kind (kind)}
<option value={kind}>{kind}</option>
{/each}
</select>
{#if filterKind}
<button class="ne-clear" onclick={() => { filterKind = ''; }} title="Vis alle typer"></button>
{/if}
</div>
</div>
<div class="ne-body">
@ -141,14 +195,44 @@
{/if}
</div>
<!-- Detaljer -->
<!-- Detaljer / redigering -->
{#if selectedNode}
<div class="ne-detail">
<div class="ne-detail-header">
<span class="ne-detail-kind">{selectedNode.nodeKind}</span>
<span class="ne-detail-id">{selectedNode.id}</span>
<div class="ne-detail-actions">
{#if !editing}
<button class="ne-action-btn" onclick={startEditing} title="Rediger">✏️</button>
{/if}
</div>
</div>
<div class="ne-detail-id">{selectedNode.id}</div>
{#if editing}
<!-- Redigeringsmodus -->
<div class="ne-edit-section">
<div class="ne-detail-label">Tittel</div>
<input class="ne-edit-input" bind:value={editTitle} />
</div>
<div class="ne-edit-section">
<div class="ne-detail-label">Innhold</div>
<textarea class="ne-edit-textarea" bind:value={editContent} rows="6"></textarea>
</div>
<div class="ne-edit-section">
<div class="ne-detail-label">Metadata (JSON)</div>
<textarea class="ne-edit-textarea ne-edit-mono" bind:value={editMetadata} rows="8"></textarea>
</div>
{#if saveError}
<div class="ne-error">{saveError}</div>
{/if}
<div class="ne-edit-actions">
<button class="ne-save-btn" onclick={saveEdits} disabled={saving}>
{saving ? 'Lagrer...' : 'Lagre'}
</button>
<button class="ne-cancel-btn" onclick={cancelEditing}>Avbryt</button>
</div>
{:else}
<!-- Visningsmodus -->
<h3 class="ne-detail-title">{selectedNode.title || 'Uten tittel'}</h3>
<div class="ne-detail-meta">
@ -171,15 +255,11 @@
</div>
{/if}
<!-- Edges ut -->
{#if selectedEdgesOut.length > 0}
<div class="ne-detail-section">
<div class="ne-detail-label">Edges ut ({selectedEdgesOut.length})</div>
{#each selectedEdgesOut as edge (edge.id)}
<button
class="ne-edge"
onclick={() => navigateToNode(edge.targetId)}
>
<button class="ne-edge" onclick={() => navigateToNode(edge.targetId)}>
<span class="ne-edge-type">{edge.edgeType}</span>
<span class="ne-edge-target">
{nodeStore.get(edge.targetId)?.title || edge.targetId.slice(0, 8)}
@ -190,15 +270,11 @@
</div>
{/if}
<!-- Edges inn -->
{#if selectedEdgesIn.length > 0}
<div class="ne-detail-section">
<div class="ne-detail-label">Edges inn ({selectedEdgesIn.length})</div>
{#each selectedEdgesIn as edge (edge.id)}
<button
class="ne-edge"
onclick={() => navigateToNode(edge.sourceId)}
>
<button class="ne-edge" onclick={() => navigateToNode(edge.sourceId)}>
<span class="ne-edge-type">{edge.edgeType}</span>
<span class="ne-edge-target">
{nodeStore.get(edge.sourceId)?.title || edge.sourceId.slice(0, 8)}
@ -208,6 +284,7 @@
{/each}
</div>
{/if}
{/if}
</div>
{:else}
<div class="ne-detail ne-detail-empty">
@ -224,13 +301,24 @@
display: flex; gap: 6px; padding: 8px;
border-bottom: 1px solid var(--color-border, #2a2a2e);
}
.ne-search-wrap, .ne-filter-wrap {
position: relative; display: flex; align-items: center;
}
.ne-search-wrap { flex: 1; }
.ne-search {
flex: 1; padding: 6px 10px; border-radius: 6px; font-size: 13px;
width: 100%; padding: 6px 28px 6px 10px; border-radius: 6px; font-size: 13px;
}
.ne-filter {
width: 140px; padding: 6px 8px; border-radius: 6px; font-size: 12px;
width: 140px; padding: 6px 24px 6px 8px; border-radius: 6px; font-size: 12px;
cursor: pointer;
}
.ne-clear {
position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
border: none; background: transparent; cursor: pointer;
font-size: 12px; color: var(--color-text-dim, #5a5a66);
padding: 2px 4px; border-radius: 3px; line-height: 1;
}
.ne-clear:hover { color: var(--color-text, #e8e8ec); background: var(--color-surface-hover, #242428); }
.ne-body { display: flex; flex: 1; min-height: 0; overflow: hidden; }
@ -261,13 +349,20 @@
color: var(--color-text-dim, #5a5a66); font-size: 13px;
}
.ne-detail-header {
display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;
display: flex; justify-content: space-between; align-items: center; margin-bottom: 2px;
}
.ne-detail-kind {
font-size: 11px; font-weight: 600; text-transform: uppercase;
color: var(--color-accent, #6366f1); letter-spacing: 0.03em;
}
.ne-detail-id { font-size: 10px; color: var(--color-text-dim, #5a5a66); font-family: monospace; }
.ne-detail-actions { display: flex; gap: 4px; }
.ne-action-btn {
border: none; background: var(--color-surface-hover, #242428);
cursor: pointer; padding: 3px 8px; border-radius: 4px; font-size: 12px;
transition: background 0.1s;
}
.ne-action-btn:hover { background: var(--color-border, #2a2a2e); }
.ne-detail-id { font-size: 10px; color: var(--color-text-dim, #5a5a66); font-family: monospace; margin-bottom: 6px; }
.ne-detail-title { font-size: 16px; font-weight: 600; color: var(--color-text, #e8e8ec); margin-bottom: 8px; }
.ne-detail-meta { font-size: 11px; color: var(--color-text-dim, #5a5a66); margin-bottom: 10px; line-height: 1.6; }
@ -300,4 +395,31 @@
cursor: pointer; font-size: 12px; color: var(--color-accent, #6366f1);
}
.ne-more:hover { background: var(--color-surface-hover, #242428); }
/* Editing */
.ne-edit-section { margin-bottom: 10px; }
.ne-edit-input {
width: 100%; padding: 6px 8px; border-radius: 4px; font-size: 14px; font-weight: 500;
}
.ne-edit-textarea {
width: 100%; padding: 6px 8px; border-radius: 4px; font-size: 12px;
resize: vertical; min-height: 60px;
}
.ne-edit-mono { font-family: monospace; font-size: 11px; }
.ne-edit-actions { display: flex; gap: 6px; margin-top: 8px; }
.ne-save-btn {
padding: 5px 14px; border: none; border-radius: 4px; font-size: 12px;
background: var(--color-accent, #6366f1); color: white; cursor: pointer;
}
.ne-save-btn:hover { background: var(--color-accent-hover, #7577f5); }
.ne-save-btn:disabled { opacity: 0.5; cursor: default; }
.ne-cancel-btn {
padding: 5px 14px; border: none; border-radius: 4px; font-size: 12px;
background: var(--color-surface-hover, #242428); color: var(--color-text-muted, #8a8a96); cursor: pointer;
}
.ne-cancel-btn:hover { background: var(--color-border, #2a2a2e); }
.ne-error {
font-size: 11px; color: var(--color-error, #ef4444); margin-top: 6px;
padding: 4px 8px; background: rgba(239, 68, 68, 0.1); border-radius: 4px;
}
</style>