Samlingsnoder med `metadata.traits` rendres nå som egne sider på /collection/[id]. Hvert trait-navn mappes til en dedikert Svelte-komponent som viser relevant UI. Traits uten egen komponent vises med et generisk panel. Komponenter for 9 traits: editor, chat, kanban, podcast, publishing, rss, calendar, recording, transcription. Mottak-siden viser traits som pills og lenker til samlingssiden. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
529 lines
17 KiB
Svelte
529 lines
17 KiB
Svelte
<script lang="ts">
|
|
import { page } from '$app/stores';
|
|
import { signOut } from '@auth/sveltekit/client';
|
|
import { connectionState, nodeStore, edgeStore, nodeAccessStore, nodeVisibility } from '$lib/spacetime';
|
|
import type { Node } from '$lib/spacetime';
|
|
import NodeEditor from '$lib/components/NodeEditor.svelte';
|
|
import NewChatDialog from '$lib/components/NewChatDialog.svelte';
|
|
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
|
|
import { createNode, createEdge, createCommunication, casUrl } from '$lib/api';
|
|
|
|
const session = $derived($page.data.session as Record<string, unknown> | undefined);
|
|
const nodeId = $derived(session?.nodeId as string | undefined);
|
|
const accessToken = $derived(session?.accessToken as string | undefined);
|
|
const connected = $derived(connectionState.current === 'connected');
|
|
|
|
/**
|
|
* Find all nodes visible to the user, applying visibility filtering.
|
|
*
|
|
* A node is visible if:
|
|
* - User created it (created_by)
|
|
* - User has explicit access (node_access)
|
|
* - Node is 'readable' or 'open' (public)
|
|
* - Node is 'discoverable' (shown with limited info)
|
|
*
|
|
* Among visible nodes, the mottak shows:
|
|
* - Nodes connected via edges (either direction, non-system)
|
|
* - Readable/open/discoverable nodes
|
|
*
|
|
* Sorted by created_at descending (newest first).
|
|
*/
|
|
const mottaksnoder = $derived.by(() => {
|
|
if (!nodeId || !connected) return [];
|
|
|
|
// Collect node IDs connected to the user via edges
|
|
const connectedNodeIds = new Set<string>();
|
|
|
|
for (const edge of edgeStore.bySource(nodeId)) {
|
|
if (!edge.system) connectedNodeIds.add(edge.targetId);
|
|
}
|
|
for (const edge of edgeStore.byTarget(nodeId)) {
|
|
if (!edge.system) connectedNodeIds.add(edge.sourceId);
|
|
}
|
|
|
|
// Also include all nodes user has explicit access to
|
|
const accessibleIds = nodeAccessStore.objectsForSubject(nodeId);
|
|
for (const id of accessibleIds) {
|
|
connectedNodeIds.add(id);
|
|
}
|
|
|
|
// Resolve to nodes, applying visibility filter
|
|
const nodes: Node[] = [];
|
|
for (const id of connectedNodeIds) {
|
|
if (id === nodeId) continue;
|
|
const node = nodeStore.get(id);
|
|
if (node && nodeVisibility(node, nodeId) !== 'hidden') {
|
|
nodes.push(node);
|
|
}
|
|
}
|
|
|
|
// Also add public nodes (readable/open) that aren't connected
|
|
for (const node of nodeStore.all) {
|
|
if (node.id === nodeId) continue;
|
|
if (connectedNodeIds.has(node.id)) continue;
|
|
if (node.visibility === 'readable' || node.visibility === 'open') {
|
|
nodes.push(node);
|
|
}
|
|
}
|
|
|
|
// Sort by created_at descending
|
|
nodes.sort((a, b) => {
|
|
const ta = a.createdAt?.microsSinceUnixEpoch ?? 0n;
|
|
const tb = b.createdAt?.microsSinceUnixEpoch ?? 0n;
|
|
return tb > ta ? 1 : tb < ta ? -1 : 0;
|
|
});
|
|
|
|
return nodes;
|
|
});
|
|
|
|
/** Truncate content to a short excerpt */
|
|
function excerpt(content: string, maxLen = 140): string {
|
|
if (!content) return '';
|
|
if (content.length <= maxLen) return content;
|
|
return content.slice(0, maxLen).trimEnd() + '…';
|
|
}
|
|
|
|
/** Format node_kind as a readable label */
|
|
function kindLabel(kind: string): string {
|
|
const labels: Record<string, string> = {
|
|
content: 'Innhold',
|
|
person: 'Person',
|
|
team: 'Team',
|
|
collection: 'Samling',
|
|
communication: 'Samtale',
|
|
topic: 'Tema',
|
|
media: 'Media'
|
|
};
|
|
return labels[kind] ?? kind;
|
|
}
|
|
|
|
/** Get edge types between the user and a node */
|
|
function edgeTypes(targetNodeId: string): string[] {
|
|
if (!nodeId) return [];
|
|
const edges = edgeStore.between(nodeId, targetNodeId);
|
|
return edges.filter((e) => !e.system).map((e) => e.edgeType);
|
|
}
|
|
|
|
/** Check if a node is an audio media node and extract its metadata */
|
|
function audioMeta(node: Node): { src: string; duration?: number; hasSegments: boolean } | null {
|
|
if (node.nodeKind !== 'media') return null;
|
|
try {
|
|
const meta = JSON.parse(node.metadata ?? '{}');
|
|
if (typeof meta.mime === 'string' && meta.mime.startsWith('audio/') && meta.cas_hash) {
|
|
return {
|
|
src: casUrl(meta.cas_hash),
|
|
duration: meta.transcription?.duration_ms ? meta.transcription.duration_ms / 1000 : undefined,
|
|
hasSegments: (meta.transcription?.segment_count ?? 0) > 0,
|
|
};
|
|
}
|
|
} catch { /* ignore */ }
|
|
return null;
|
|
}
|
|
|
|
/** Check if a node is a kanban board (collection with board metadata, no traits) */
|
|
function isBoard(node: Node): boolean {
|
|
if (node.nodeKind !== 'collection') return false;
|
|
try {
|
|
const meta = JSON.parse(node.metadata ?? '{}');
|
|
return meta.board === true && !meta.traits;
|
|
} catch { return false; }
|
|
}
|
|
|
|
/** Check if a collection has traits */
|
|
function hasTraits(node: Node): boolean {
|
|
if (node.nodeKind !== 'collection') return false;
|
|
try {
|
|
const meta = JSON.parse(node.metadata ?? '{}');
|
|
return meta.traits && typeof meta.traits === 'object' && Object.keys(meta.traits).length > 0;
|
|
} catch { return false; }
|
|
}
|
|
|
|
/** Get trait names from a collection node */
|
|
function traitNames(node: Node): string[] {
|
|
try {
|
|
const meta = JSON.parse(node.metadata ?? '{}');
|
|
if (meta.traits && typeof meta.traits === 'object') return Object.keys(meta.traits);
|
|
} catch { /* ignore */ }
|
|
return [];
|
|
}
|
|
|
|
/** Count scheduled events for badge display */
|
|
const scheduledCount = $derived.by(() => {
|
|
if (!connected) return 0;
|
|
return edgeStore.byType('scheduled').length;
|
|
});
|
|
|
|
/** Count private diary entries for badge display */
|
|
const diaryCount = $derived.by(() => {
|
|
if (!connected || !nodeId) return 0;
|
|
let count = 0;
|
|
for (const node of nodeStore.all) {
|
|
if (node.createdBy !== nodeId) continue;
|
|
if (node.nodeKind === 'communication' || node.nodeKind === 'agent' ||
|
|
node.nodeKind === 'person' || node.nodeKind === 'team') continue;
|
|
// Check for shared edges (same logic as diary page)
|
|
let shared = false;
|
|
for (const edge of edgeStore.bySource(node.id)) {
|
|
if (edge.system || edge.targetId === node.id || edge.targetId === nodeId) continue;
|
|
shared = true; break;
|
|
}
|
|
if (!shared) {
|
|
for (const edge of edgeStore.byTarget(node.id)) {
|
|
if (edge.system || edge.sourceId === node.id || edge.sourceId === nodeId) continue;
|
|
shared = true; break;
|
|
}
|
|
}
|
|
if (!shared) count++;
|
|
}
|
|
return count;
|
|
});
|
|
|
|
let isCreatingBoard = $state(false);
|
|
|
|
/** Create a new kanban board */
|
|
async function handleNewBoard() {
|
|
if (!accessToken || !nodeId || isCreatingBoard) return;
|
|
isCreatingBoard = true;
|
|
try {
|
|
const { node_id } = await createNode(accessToken, {
|
|
node_kind: 'collection',
|
|
title: 'Nytt brett',
|
|
visibility: 'hidden',
|
|
metadata: {
|
|
board: true,
|
|
columns: ['todo', 'in_progress', 'done']
|
|
}
|
|
});
|
|
|
|
await createEdge(accessToken, {
|
|
source_id: nodeId,
|
|
target_id: node_id,
|
|
edge_type: 'owner'
|
|
});
|
|
|
|
window.location.href = `/board/${node_id}`;
|
|
} catch (e) {
|
|
console.error('Feil ved oppretting av brett:', e);
|
|
} finally {
|
|
isCreatingBoard = false;
|
|
}
|
|
}
|
|
|
|
let showNewChatDialog = $state(false);
|
|
|
|
/** Open the new chat dialog to pick a participant */
|
|
function handleNewChat() {
|
|
showNewChatDialog = true;
|
|
}
|
|
|
|
/** Create a 1:1 communication with the selected person */
|
|
async function handleStartChat(personId: string) {
|
|
if (!accessToken || !nodeId) return;
|
|
showNewChatDialog = false;
|
|
|
|
try {
|
|
// Check if there's already a communication with this person
|
|
const existingId = findExistingChat(personId);
|
|
if (existingId) {
|
|
window.location.href = `/chat/${existingId}`;
|
|
return;
|
|
}
|
|
|
|
// Get the other person's name for the chat title
|
|
const person = nodeStore.get(personId);
|
|
const myNode = nodeStore.get(nodeId);
|
|
const title = [myNode?.title, person?.title].filter(Boolean).join(' & ') || 'Samtale';
|
|
|
|
const { node_id } = await createCommunication(accessToken, {
|
|
title,
|
|
participants: [personId],
|
|
});
|
|
window.location.href = `/chat/${node_id}`;
|
|
} catch (e) {
|
|
console.error('Feil ved oppretting av samtale:', e);
|
|
}
|
|
}
|
|
|
|
/** Find an existing 1:1 communication between current user and another person */
|
|
function findExistingChat(personId: string): string | undefined {
|
|
if (!nodeId) return undefined;
|
|
|
|
// Collect communication nodes where current user is owner or member
|
|
const userComms = new Set<string>();
|
|
for (const edge of edgeStore.bySource(nodeId)) {
|
|
if (edge.edgeType === 'owner' || edge.edgeType === 'member_of') {
|
|
const target = nodeStore.get(edge.targetId);
|
|
if (target?.nodeKind === 'communication') {
|
|
userComms.add(edge.targetId);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if the other person is also in any of these communications
|
|
for (const edge of edgeStore.bySource(personId)) {
|
|
if (edge.edgeType === 'owner' || edge.edgeType === 'member_of') {
|
|
if (userComms.has(edge.targetId)) {
|
|
return edge.targetId;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/** Submit a new node via maskinrommet */
|
|
async function handleCreateNode(data: { title: string; content: string; html: string }) {
|
|
if (!accessToken) throw new Error('Ikke innlogget');
|
|
|
|
const metadata: Record<string, unknown> = {};
|
|
if (data.html && data.html !== '<p></p>') {
|
|
metadata.document = data.html;
|
|
}
|
|
|
|
const { node_id } = await createNode(accessToken, {
|
|
node_kind: 'content',
|
|
title: data.title,
|
|
content: data.content,
|
|
visibility: 'hidden',
|
|
metadata
|
|
});
|
|
|
|
// Create owner edge so the node appears in the user's mottak
|
|
await createEdge(accessToken, {
|
|
source_id: nodeId!,
|
|
target_id: node_id,
|
|
edge_type: 'owner'
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<div class="min-h-screen bg-gray-50">
|
|
<!-- Header -->
|
|
<header class="border-b border-gray-200 bg-white">
|
|
<div class="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
|
|
<h1 class="text-lg font-semibold text-gray-900">Synops</h1>
|
|
<div class="flex items-center gap-3">
|
|
{#if connected}
|
|
<span class="text-xs text-green-600">Tilkoblet</span>
|
|
{:else if connectionState.current === 'connecting'}
|
|
<span class="text-xs text-yellow-600">Kobler til…</span>
|
|
{:else}
|
|
<span class="text-xs text-gray-400">{connectionState.current}</span>
|
|
{/if}
|
|
{#if $page.data.session?.user}
|
|
<span class="text-sm text-gray-500">{$page.data.session.user.name}</span>
|
|
<button
|
|
onclick={() => signOut()}
|
|
class="rounded bg-gray-100 px-3 py-1 text-xs text-gray-600 hover:bg-gray-200"
|
|
>
|
|
Logg ut
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Main content -->
|
|
<main class="mx-auto max-w-3xl px-4 py-6">
|
|
<div class="mb-4 flex items-center justify-between">
|
|
<h2 class="text-xl font-semibold text-gray-800">Mottak</h2>
|
|
{#if connected && accessToken}
|
|
<div class="flex gap-2">
|
|
<a
|
|
href="/diary"
|
|
class="rounded-lg bg-violet-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-violet-700"
|
|
>
|
|
Dagbok{#if diaryCount > 0} ({diaryCount}){/if}
|
|
</a>
|
|
<a
|
|
href="/calendar"
|
|
class="rounded-lg bg-amber-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-amber-700"
|
|
>
|
|
Kalender{#if scheduledCount > 0} ({scheduledCount}){/if}
|
|
</a>
|
|
<a
|
|
href="/graph"
|
|
class="rounded-lg bg-purple-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-purple-700"
|
|
>
|
|
Graf
|
|
</a>
|
|
<button
|
|
onclick={handleNewBoard}
|
|
disabled={isCreatingBoard}
|
|
class="rounded-lg bg-gray-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-50"
|
|
>
|
|
{isCreatingBoard ? 'Oppretter…' : 'Nytt brett'}
|
|
</button>
|
|
<button
|
|
onclick={handleNewChat}
|
|
class="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
|
|
>
|
|
Ny samtale
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if connected && accessToken}
|
|
<div class="mb-6">
|
|
<NodeEditor onsubmit={handleCreateNode} disabled={!connected} accessToken={accessToken} />
|
|
</div>
|
|
{/if}
|
|
|
|
{#if !connected}
|
|
<p class="text-sm text-gray-400">Venter på tilkobling…</p>
|
|
{:else if !nodeId}
|
|
<div class="rounded-lg border border-yellow-200 bg-yellow-50 p-4 text-sm text-yellow-800">
|
|
<p class="font-medium">Bruker-ID ikke tilgjengelig</p>
|
|
<p class="mt-1">
|
|
Kunne ikke hente node-ID fra maskinrommet. Prøv å logge ut og inn igjen.
|
|
</p>
|
|
</div>
|
|
{:else if mottaksnoder.length === 0}
|
|
<p class="text-sm text-gray-400">Ingen noder å vise ennå.</p>
|
|
{:else}
|
|
<ul class="space-y-2">
|
|
{#each mottaksnoder as node (node.id)}
|
|
{@const vis = nodeVisibility(node, nodeId)}
|
|
{#if hasTraits(node)}
|
|
<li class="rounded-lg border border-gray-200 bg-white shadow-sm transition-colors hover:border-indigo-300">
|
|
<a href="/collection/{node.id}" class="block p-4">
|
|
<div class="flex items-start justify-between gap-2">
|
|
<div class="min-w-0 flex-1">
|
|
<h3 class="font-medium text-gray-900">
|
|
{node.title || 'Uten tittel'}
|
|
<span class="ml-1 text-xs text-indigo-500">→ Åpne samling</span>
|
|
</h3>
|
|
{#if vis === 'full' && node.content}
|
|
<p class="mt-1 text-sm text-gray-500">
|
|
{excerpt(node.content)}
|
|
</p>
|
|
{/if}
|
|
<div class="mt-1.5 flex flex-wrap gap-1">
|
|
{#each traitNames(node) as trait (trait)}
|
|
<span class="rounded-full bg-indigo-50 px-2 py-0.5 text-[10px] font-medium text-indigo-600">
|
|
{trait}
|
|
</span>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
<div class="flex shrink-0 flex-col items-end gap-1">
|
|
<span class="rounded-full bg-indigo-100 px-2 py-0.5 text-xs text-indigo-700">
|
|
Samling
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
</li>
|
|
{:else if isBoard(node)}
|
|
<li class="rounded-lg border border-gray-200 bg-white shadow-sm transition-colors hover:border-gray-400">
|
|
<a href="/board/{node.id}" class="block p-4">
|
|
<div class="flex items-start justify-between gap-2">
|
|
<div class="min-w-0 flex-1">
|
|
<h3 class="font-medium text-gray-900">
|
|
{node.title || 'Uten tittel'}
|
|
<span class="ml-1 text-xs text-gray-500">→ Åpne brett</span>
|
|
</h3>
|
|
{#if vis === 'full' && node.content}
|
|
<p class="mt-1 text-sm text-gray-500">
|
|
{excerpt(node.content)}
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
<div class="flex shrink-0 flex-col items-end gap-1">
|
|
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
|
|
Brett
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
</li>
|
|
{:else if node.nodeKind === 'communication'}
|
|
<li class="rounded-lg border border-gray-200 bg-white shadow-sm transition-colors hover:border-blue-300">
|
|
<a href="/chat/{node.id}" class="block p-4">
|
|
<div class="flex items-start justify-between gap-2">
|
|
<div class="min-w-0 flex-1">
|
|
<h3 class="font-medium text-gray-900">
|
|
{node.title || 'Uten tittel'}
|
|
<span class="ml-1 text-xs text-blue-500">→ Åpne chat</span>
|
|
</h3>
|
|
{#if vis === 'full' && node.content}
|
|
<p class="mt-1 text-sm text-gray-500">
|
|
{excerpt(node.content)}
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
<div class="flex shrink-0 flex-col items-end gap-1">
|
|
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
|
|
{kindLabel(node.nodeKind)}
|
|
</span>
|
|
{#each edgeTypes(node.id) as et}
|
|
<span class="rounded-full bg-blue-50 px-2 py-0.5 text-xs text-blue-600">
|
|
{et}
|
|
</span>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</a>
|
|
</li>
|
|
{:else}
|
|
{@const audio = audioMeta(node)}
|
|
<li class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
|
<div class="flex items-start justify-between gap-2">
|
|
<div class="min-w-0 flex-1">
|
|
<h3 class="font-medium text-gray-900">
|
|
{node.title || 'Uten tittel'}
|
|
</h3>
|
|
{#if audio}
|
|
<div class="mt-2">
|
|
<AudioPlayer
|
|
src={audio.src}
|
|
duration={audio.duration}
|
|
transcript={vis === 'full' && !audio.hasSegments ? (node.content || undefined) : undefined}
|
|
nodeId={audio.hasSegments ? node.id : undefined}
|
|
accessToken={audio.hasSegments ? accessToken : undefined}
|
|
/>
|
|
</div>
|
|
{:else if vis === 'full' && node.content}
|
|
<p class="mt-1 text-sm text-gray-500">
|
|
{excerpt(node.content)}
|
|
</p>
|
|
{:else if vis === 'discoverable'}
|
|
<p class="mt-1 text-xs italic text-gray-400">
|
|
Begrenset tilgang — be om tilgang for å se innholdet
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
<div class="flex shrink-0 flex-col items-end gap-1">
|
|
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
|
|
{kindLabel(node.nodeKind)}
|
|
</span>
|
|
{#each edgeTypes(node.id) as et}
|
|
<span class="rounded-full bg-blue-50 px-2 py-0.5 text-xs text-blue-600">
|
|
{et}
|
|
</span>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</li>
|
|
{/if}
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
|
|
<!-- Debug info (small, bottom) -->
|
|
{#if connected}
|
|
<p class="mt-8 text-xs text-gray-300">
|
|
{nodeStore.count} noder · {edgeStore.count} edges · {nodeAccessStore.count} access
|
|
{#if nodeId}· node: {nodeId.slice(0, 8)}…{/if}
|
|
</p>
|
|
{/if}
|
|
</main>
|
|
|
|
{#if showNewChatDialog && nodeId}
|
|
<NewChatDialog
|
|
currentUserId={nodeId}
|
|
onselect={handleStartChat}
|
|
onclose={() => showNewChatDialog = false}
|
|
/>
|
|
{/if}
|
|
</div>
|