Node Explorer trait + adm.synops.no viser arbeidsflaten
Ny trait: NodeExplorerTrait — søk og utforsk noder med edges. Split-visning: nodeliste til venstre, detaljer til høyre. Filtrer på node_kind, søk i tittel/innhold/ID. Klikk edges for å navigere i grafen. adm.synops.no setter isAdminHost flag via hooks/layout. Registrert i TRAIT_PANEL_INFO som 'node_explorer'. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
be7f7ed9ec
commit
c2ddd5a933
5 changed files with 315 additions and 6 deletions
|
|
@ -16,11 +16,9 @@ const authorizationHandle: Handle = async ({ event, resolve }) => {
|
|||
throw redirect(303, '/auth/signin');
|
||||
}
|
||||
|
||||
// adm.synops.no → redirect til admin-arbeidsflaten
|
||||
// Sett isAdmin-flag basert på hostname (brukes av +page.svelte)
|
||||
const host = event.url.hostname;
|
||||
if (host === 'adm.synops.no' && path === '/') {
|
||||
throw redirect(303, '/admin');
|
||||
}
|
||||
event.locals.isAdminHost = host === 'adm.synops.no';
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
|
|
|
|||
303
frontend/src/lib/components/traits/NodeExplorerTrait.svelte
Normal file
303
frontend/src/lib/components/traits/NodeExplorerTrait.svelte
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
<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.
|
||||
*/
|
||||
import { nodeStore, edgeStore } from '$lib/realtime';
|
||||
import type { Node } from '$lib/realtime';
|
||||
|
||||
interface Props {
|
||||
collection: Node | undefined;
|
||||
config: Record<string, unknown>;
|
||||
userId?: string;
|
||||
accessToken?: string;
|
||||
}
|
||||
|
||||
let { collection, config, userId, accessToken }: Props = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
let filterKind = $state('');
|
||||
let selectedNode = $state<Node | undefined>(undefined);
|
||||
let limit = $state(50);
|
||||
|
||||
const nodeKinds = $derived.by(() => {
|
||||
const kinds = new Set<string>();
|
||||
for (const node of nodeStore.all) {
|
||||
kinds.add(node.nodeKind);
|
||||
}
|
||||
return [...kinds].sort();
|
||||
});
|
||||
|
||||
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 =>
|
||||
(n.title ?? '').toLowerCase().includes(q) ||
|
||||
(n.content ?? '').toLowerCase().includes(q) ||
|
||||
n.id.toLowerCase().includes(q) ||
|
||||
n.nodeKind.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
nodes.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
|
||||
return nodes.slice(0, limit);
|
||||
});
|
||||
|
||||
const totalCount = $derived(nodeStore.count);
|
||||
|
||||
const selectedEdgesOut = $derived.by(() => {
|
||||
if (!selectedNode) return [];
|
||||
return edgeStore.bySource(selectedNode.id);
|
||||
});
|
||||
|
||||
const selectedEdgesIn = $derived.by(() => {
|
||||
if (!selectedNode) return [];
|
||||
return edgeStore.byTarget(selectedNode.id);
|
||||
});
|
||||
|
||||
function selectNode(node: Node) {
|
||||
selectedNode = node;
|
||||
}
|
||||
|
||||
function navigateToNode(id: string) {
|
||||
const node = nodeStore.get(id);
|
||||
if (node) selectedNode = node;
|
||||
}
|
||||
|
||||
function formatDate(ts: number | undefined): string {
|
||||
if (!ts) return '—';
|
||||
return new Date(ts).toLocaleString('no-NO', {
|
||||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function truncate(s: string | undefined, max: number): string {
|
||||
if (!s) return '';
|
||||
return s.length > max ? s.slice(0, max) + '…' : s;
|
||||
}
|
||||
|
||||
function parseMetadata(s: string | undefined): string {
|
||||
if (!s) return '{}';
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(s), null, 2);
|
||||
} catch {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="ne-body">
|
||||
<!-- Resultat-liste -->
|
||||
<div class="ne-list">
|
||||
{#each results as node (node.id)}
|
||||
<button
|
||||
class="ne-item"
|
||||
class:ne-item-selected={selectedNode?.id === node.id}
|
||||
onclick={() => selectNode(node)}
|
||||
>
|
||||
<div class="ne-item-header">
|
||||
<span class="ne-item-kind">{node.nodeKind}</span>
|
||||
<span class="ne-item-date">{formatDate(node.createdAt)}</span>
|
||||
</div>
|
||||
<div class="ne-item-title">{node.title || node.id.slice(0, 8)}</div>
|
||||
{#if node.content}
|
||||
<div class="ne-item-excerpt">{truncate(node.content, 80)}</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if results.length === 0}
|
||||
<div class="ne-empty">Ingen noder funnet</div>
|
||||
{/if}
|
||||
{#if results.length >= limit}
|
||||
<button class="ne-more" onclick={() => { limit += 50; }}>
|
||||
Vis flere...
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Detaljer -->
|
||||
{#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>
|
||||
|
||||
<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">
|
||||
<div class="ne-detail-label">Innhold</div>
|
||||
<pre class="ne-detail-content">{truncate(selectedNode.content, 500)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#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}
|
||||
|
||||
<!-- 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}
|
||||
|
||||
<!-- 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}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="ne-detail ne-detail-empty">
|
||||
Velg en node for å se detaljer
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ne { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
|
||||
.ne-toolbar {
|
||||
display: flex; gap: 6px; padding: 8px;
|
||||
border-bottom: 1px solid var(--color-border, #2a2a2e);
|
||||
}
|
||||
.ne-search {
|
||||
flex: 1; padding: 6px 10px; border-radius: 6px; font-size: 13px;
|
||||
}
|
||||
.ne-filter {
|
||||
width: 140px; padding: 6px 8px; border-radius: 6px; font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ne-body { display: flex; flex: 1; min-height: 0; overflow: hidden; }
|
||||
|
||||
.ne-list {
|
||||
width: 45%; overflow-y: auto; border-right: 1px solid var(--color-border, #2a2a2e);
|
||||
}
|
||||
.ne-item {
|
||||
display: block; width: 100%; padding: 8px 10px; border: none;
|
||||
background: transparent; text-align: left; cursor: pointer;
|
||||
border-bottom: 1px solid var(--color-border, #2a2a2e);
|
||||
font-size: 12px; color: var(--color-text-muted, #8a8a96);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.ne-item:hover { background: var(--color-surface-hover, #242428); }
|
||||
.ne-item-selected { background: var(--color-accent-glow, rgba(99,102,241,0.15)); }
|
||||
.ne-item-header { display: flex; justify-content: space-between; margin-bottom: 2px; }
|
||||
.ne-item-kind {
|
||||
font-size: 10px; font-weight: 600; text-transform: uppercase;
|
||||
color: var(--color-accent, #6366f1); letter-spacing: 0.03em;
|
||||
}
|
||||
.ne-item-date { font-size: 10px; color: var(--color-text-dim, #5a5a66); }
|
||||
.ne-item-title { font-size: 13px; font-weight: 500; color: var(--color-text, #e8e8ec); }
|
||||
.ne-item-excerpt { font-size: 11px; color: var(--color-text-dim, #5a5a66); margin-top: 2px; }
|
||||
|
||||
.ne-detail { width: 55%; overflow-y: auto; padding: 10px; }
|
||||
.ne-detail-empty {
|
||||
display: flex; align-items: center; justify-content: center; height: 100%;
|
||||
color: var(--color-text-dim, #5a5a66); font-size: 13px;
|
||||
}
|
||||
.ne-detail-header {
|
||||
display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;
|
||||
}
|
||||
.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-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; }
|
||||
|
||||
.ne-detail-section { margin-bottom: 12px; }
|
||||
.ne-detail-label {
|
||||
font-size: 10px; font-weight: 600; text-transform: uppercase;
|
||||
color: var(--color-text-dim, #5a5a66); letter-spacing: 0.05em; margin-bottom: 4px;
|
||||
}
|
||||
.ne-detail-content {
|
||||
font-size: 11px; font-family: monospace; white-space: pre-wrap; word-break: break-all;
|
||||
background: var(--color-bg, #0a0a0b); border: 1px solid var(--color-border, #2a2a2e);
|
||||
border-radius: 4px; padding: 8px; max-height: 200px; overflow-y: auto;
|
||||
color: var(--color-text-muted, #8a8a96);
|
||||
}
|
||||
|
||||
.ne-edge {
|
||||
display: flex; align-items: center; gap: 6px; width: 100%;
|
||||
padding: 4px 8px; border: none; background: transparent;
|
||||
cursor: pointer; font-size: 12px; color: var(--color-text-muted, #8a8a96);
|
||||
border-radius: 4px; text-align: left; transition: background 0.1s;
|
||||
}
|
||||
.ne-edge:hover { background: var(--color-surface-hover, #242428); }
|
||||
.ne-edge-type { font-weight: 500; min-width: 100px; color: var(--color-accent, #6366f1); }
|
||||
.ne-edge-target { flex: 1; }
|
||||
.ne-edge-system { font-size: 10px; color: var(--color-text-dim, #5a5a66); }
|
||||
|
||||
.ne-empty { padding: 20px; text-align: center; font-size: 13px; color: var(--color-text-dim, #5a5a66); }
|
||||
.ne-more {
|
||||
width: 100%; padding: 8px; border: none; background: transparent;
|
||||
cursor: pointer; font-size: 12px; color: var(--color-accent, #6366f1);
|
||||
}
|
||||
.ne-more:hover { background: var(--color-surface-hover, #242428); }
|
||||
</style>
|
||||
|
|
@ -57,6 +57,7 @@ export const TRAIT_PANEL_INFO: Record<string, TraitPanelInfo> = {
|
|||
orchestration: { title: 'Orkestrering', icon: '⚡', defaultWidth: 550, defaultHeight: 500 },
|
||||
mindmap: { title: 'Tankekart', icon: '🧠', defaultWidth: 600, defaultHeight: 500 },
|
||||
ai: { title: 'AI-verktøy', icon: '🤖', defaultWidth: 420, defaultHeight: 500 },
|
||||
node_explorer: { title: 'Nodeutforsker', icon: '🔍', defaultWidth: 600, defaultHeight: 500 },
|
||||
usage: { title: 'Ressursforbruk', icon: '📊', defaultWidth: 380, defaultHeight: 350 },
|
||||
storyboard: { title: 'Storyboard', icon: '🎬', defaultWidth: 500, defaultHeight: 450 },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { LayoutServerLoad } from './$types';
|
|||
|
||||
export const load: LayoutServerLoad = async (event) => {
|
||||
return {
|
||||
session: await event.locals.auth()
|
||||
session: await event.locals.auth(),
|
||||
isAdminHost: (event.locals as Record<string, unknown>).isAdminHost === true,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
import GenericTrait from '$lib/components/traits/GenericTrait.svelte';
|
||||
import AiToolPanel from '$lib/components/AiToolPanel.svelte';
|
||||
import NodeUsage from '$lib/components/NodeUsage.svelte';
|
||||
import NodeExplorerTrait from '$lib/components/traits/NodeExplorerTrait.svelte';
|
||||
|
||||
import { createBlockReceiver, executeTransfer, resolveTransferMode, type DragPayload } from '$lib/transfer';
|
||||
import type { BlockReceiver } from '$lib/components/blockshell/types';
|
||||
|
|
@ -50,6 +51,7 @@
|
|||
const nodeId = $derived(session?.nodeId as string | undefined);
|
||||
const accessToken = $derived(session?.accessToken as string | undefined);
|
||||
const connected = $derived(connectionState.current === 'connected');
|
||||
const isAdminHost = $derived(($page.data as Record<string, unknown>).isAdminHost === true);
|
||||
|
||||
// =========================================================================
|
||||
// Workspace node (fetched from backend)
|
||||
|
|
@ -300,7 +302,7 @@
|
|||
const knownTraits = new Set([
|
||||
'editor', 'chat', 'kanban', 'podcast', 'publishing',
|
||||
'rss', 'calendar', 'recording', 'transcription', 'studio', 'mixer', 'mindmap',
|
||||
'ai', 'usage'
|
||||
'ai', 'usage', 'node_explorer'
|
||||
]);
|
||||
|
||||
// =========================================================================
|
||||
|
|
@ -453,6 +455,8 @@
|
|||
{#if nodeId && accessToken}
|
||||
<NodeUsage nodeId={nodeId} {accessToken} />
|
||||
{/if}
|
||||
{:else if panel.trait === 'node_explorer'}
|
||||
<NodeExplorerTrait collection={undefined} config={{}} userId={nodeId} {accessToken} />
|
||||
{/if}
|
||||
{:else}
|
||||
<GenericTrait name={panel.trait} config={{}} />
|
||||
|
|
@ -519,6 +523,8 @@
|
|||
{#if nodeId && accessToken}
|
||||
<NodeUsage nodeId={nodeId} {accessToken} />
|
||||
{/if}
|
||||
{:else if trait === 'node_explorer'}
|
||||
<NodeExplorerTrait collection={undefined} config={{}} userId={nodeId} {accessToken} />
|
||||
{/if}
|
||||
{:else}
|
||||
<GenericTrait name={trait} config={{}} />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue