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:
parent
3cc17c5784
commit
7974c9d53a
1 changed files with 200 additions and 78 deletions
|
|
@ -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">
|
||||
<input
|
||||
class="ne-search"
|
||||
type="text"
|
||||
placeholder="Søk noder (tittel, innhold, ID)..."
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
<select class="ne-filter" bind:value={filterKind}>
|
||||
<option value="">Alle typer ({totalCount})</option>
|
||||
{#each nodeKinds as kind (kind)}
|
||||
<option value={kind}>{kind}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div class="ne-search-wrap">
|
||||
<input
|
||||
class="ne-search"
|
||||
type="text"
|
||||
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 ({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,72 +195,95 @@
|
|||
{/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>
|
||||
|
||||
<h3 class="ne-detail-title">{selectedNode.title || 'Uten tittel'}</h3>
|
||||
|
||||
<div class="ne-detail-meta">
|
||||
<div>Opprettet: {formatDate(selectedNode.createdAt)}</div>
|
||||
<div>Av: {selectedNode.createdBy?.slice(0, 8) ?? '—'}</div>
|
||||
<div>Synlighet: {selectedNode.visibility}</div>
|
||||
</div>
|
||||
|
||||
{#if selectedNode.content}
|
||||
<div class="ne-detail-section">
|
||||
{#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>
|
||||
<pre class="ne-detail-content">{truncate(selectedNode.content, 500)}</pre>
|
||||
<textarea class="ne-edit-textarea" bind:value={editContent} rows="6"></textarea>
|
||||
</div>
|
||||
{/if}
|
||||
<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>
|
||||
|
||||
{#if selectedNode.metadata && selectedNode.metadata !== '{}'}
|
||||
<div class="ne-detail-section">
|
||||
<div class="ne-detail-label">Metadata</div>
|
||||
<pre class="ne-detail-content">{parseMetadata(selectedNode.metadata)}</pre>
|
||||
<div class="ne-detail-meta">
|
||||
<div>Opprettet: {formatDate(selectedNode.createdAt)}</div>
|
||||
<div>Av: {selectedNode.createdBy?.slice(0, 8) ?? '—'}</div>
|
||||
<div>Synlighet: {selectedNode.visibility}</div>
|
||||
</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)}
|
||||
>
|
||||
<span class="ne-edge-type">→ {edge.edgeType}</span>
|
||||
<span class="ne-edge-target">
|
||||
{nodeStore.get(edge.targetId)?.title || edge.targetId.slice(0, 8)}
|
||||
</span>
|
||||
{#if edge.system}<span class="ne-edge-system">system</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if selectedNode.content}
|
||||
<div class="ne-detail-section">
|
||||
<div class="ne-detail-label">Innhold</div>
|
||||
<pre class="ne-detail-content">{truncate(selectedNode.content, 500)}</pre>
|
||||
</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)}
|
||||
>
|
||||
<span class="ne-edge-type">← {edge.edgeType}</span>
|
||||
<span class="ne-edge-target">
|
||||
{nodeStore.get(edge.sourceId)?.title || edge.sourceId.slice(0, 8)}
|
||||
</span>
|
||||
{#if edge.system}<span class="ne-edge-system">system</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if selectedNode.metadata && selectedNode.metadata !== '{}'}
|
||||
<div class="ne-detail-section">
|
||||
<div class="ne-detail-label">Metadata</div>
|
||||
<pre class="ne-detail-content">{parseMetadata(selectedNode.metadata)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#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)}>
|
||||
<span class="ne-edge-type">→ {edge.edgeType}</span>
|
||||
<span class="ne-edge-target">
|
||||
{nodeStore.get(edge.targetId)?.title || edge.targetId.slice(0, 8)}
|
||||
</span>
|
||||
{#if edge.system}<span class="ne-edge-system">system</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#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)}>
|
||||
<span class="ne-edge-type">← {edge.edgeType}</span>
|
||||
<span class="ne-edge-target">
|
||||
{nodeStore.get(edge.sourceId)?.title || edge.sourceId.slice(0, 8)}
|
||||
</span>
|
||||
{#if edge.system}<span class="ne-edge-system">system</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue