Trait-aware frontend: samlingssider med dynamiske trait-paneler (oppgave 13.2)
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>
This commit is contained in:
parent
a404751fe4
commit
263f63bec8
14 changed files with 644 additions and 5 deletions
55
frontend/src/lib/components/traits/CalendarTrait.svelte
Normal file
55
frontend/src/lib/components/traits/CalendarTrait.svelte
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Node } from '$lib/spacetime';
|
||||||
|
import { edgeStore, nodeStore } from '$lib/spacetime';
|
||||||
|
import TraitPanel from './TraitPanel.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
collection: Node;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { collection, config, userId }: Props = $props();
|
||||||
|
|
||||||
|
/** Scheduled events connected to this collection */
|
||||||
|
const events = $derived.by(() => {
|
||||||
|
const items: { node: Node; when: string }[] = [];
|
||||||
|
for (const edge of edgeStore.byTarget(collection.id)) {
|
||||||
|
if (edge.edgeType !== 'scheduled') continue;
|
||||||
|
const node = nodeStore.get(edge.sourceId);
|
||||||
|
if (!node) continue;
|
||||||
|
let when = '';
|
||||||
|
try {
|
||||||
|
const meta = JSON.parse(edge.metadata ?? '{}');
|
||||||
|
when = meta.scheduled_at ?? meta.date ?? '';
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
items.push({ node, when });
|
||||||
|
}
|
||||||
|
items.sort((a, b) => a.when.localeCompare(b.when));
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TraitPanel name="calendar" label="Kalender" icon="📅">
|
||||||
|
{#snippet children()}
|
||||||
|
<a
|
||||||
|
href="/calendar"
|
||||||
|
class="mb-3 inline-flex items-center gap-1.5 rounded bg-amber-100 px-3 py-1.5 text-xs font-medium text-amber-800 hover:bg-amber-200"
|
||||||
|
>
|
||||||
|
Åpne kalender →
|
||||||
|
</a>
|
||||||
|
{#if events.length > 0}
|
||||||
|
<ul class="mt-2 space-y-1">
|
||||||
|
{#each events.slice(0, 5) as ev (ev.node.id)}
|
||||||
|
<li class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="text-xs text-gray-400">{ev.when || '—'}</span>
|
||||||
|
<span class="text-gray-900">{ev.node.title || 'Hendelse'}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{#if events.length > 5}
|
||||||
|
<li class="text-xs text-gray-400">+{events.length - 5} flere</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</TraitPanel>
|
||||||
55
frontend/src/lib/components/traits/ChatTrait.svelte
Normal file
55
frontend/src/lib/components/traits/ChatTrait.svelte
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Node } from '$lib/spacetime';
|
||||||
|
import { edgeStore, nodeStore } from '$lib/spacetime';
|
||||||
|
import TraitPanel from './TraitPanel.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
collection: Node;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { collection, config, userId }: Props = $props();
|
||||||
|
|
||||||
|
/** Communication nodes linked to this collection */
|
||||||
|
const chatNodes = $derived.by(() => {
|
||||||
|
const nodes: Node[] = [];
|
||||||
|
for (const edge of edgeStore.byTarget(collection.id)) {
|
||||||
|
if (edge.edgeType !== 'belongs_to') continue;
|
||||||
|
const node = nodeStore.get(edge.sourceId);
|
||||||
|
if (node && node.nodeKind === 'communication') {
|
||||||
|
nodes.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also check source edges (collection --has_channel--> communication)
|
||||||
|
for (const edge of edgeStore.bySource(collection.id)) {
|
||||||
|
if (edge.edgeType !== 'has_channel') continue;
|
||||||
|
const node = nodeStore.get(edge.targetId);
|
||||||
|
if (node && node.nodeKind === 'communication') {
|
||||||
|
nodes.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TraitPanel name="chat" label="Samtaler" icon="💬">
|
||||||
|
{#snippet children()}
|
||||||
|
{#if chatNodes.length === 0}
|
||||||
|
<p class="text-sm text-gray-400">Ingen samtaler knyttet til denne samlingen.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{#each chatNodes as node (node.id)}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/chat/{node.id}"
|
||||||
|
class="block rounded border border-gray-100 px-3 py-2 transition-colors hover:border-blue-300 hover:bg-blue-50"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium text-gray-900">{node.title || 'Samtale'}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</TraitPanel>
|
||||||
58
frontend/src/lib/components/traits/EditorTrait.svelte
Normal file
58
frontend/src/lib/components/traits/EditorTrait.svelte
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Node } from '$lib/spacetime';
|
||||||
|
import { edgeStore, nodeStore, nodeVisibility } from '$lib/spacetime';
|
||||||
|
import TraitPanel from './TraitPanel.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
collection: Node;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { collection, config, userId }: Props = $props();
|
||||||
|
|
||||||
|
const preset = $derived((config.preset as string) ?? 'longform');
|
||||||
|
|
||||||
|
/** Content nodes belonging to this collection */
|
||||||
|
const contentNodes = $derived.by(() => {
|
||||||
|
const nodes: Node[] = [];
|
||||||
|
for (const edge of edgeStore.byTarget(collection.id)) {
|
||||||
|
if (edge.edgeType !== 'belongs_to') continue;
|
||||||
|
const node = nodeStore.get(edge.sourceId);
|
||||||
|
if (node && node.nodeKind === 'content' && nodeVisibility(node, userId) !== 'hidden') {
|
||||||
|
nodes.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TraitPanel name="editor" label="Innhold" icon="📝">
|
||||||
|
{#snippet children()}
|
||||||
|
<p class="mb-3 text-xs text-gray-500">
|
||||||
|
Preset: <span class="font-medium">{preset}</span>
|
||||||
|
{#if config.allow_collaborators}
|
||||||
|
· Samarbeid aktivert
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{#if contentNodes.length === 0}
|
||||||
|
<p class="text-sm text-gray-400">Ingen innholdsnoder ennå.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{#each contentNodes as node (node.id)}
|
||||||
|
<li class="rounded border border-gray-100 px-3 py-2">
|
||||||
|
<h4 class="text-sm font-medium text-gray-900">{node.title || 'Uten tittel'}</h4>
|
||||||
|
{#if node.content}
|
||||||
|
<p class="mt-0.5 text-xs text-gray-500 line-clamp-2">{node.content.slice(0, 140)}</p>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</TraitPanel>
|
||||||
29
frontend/src/lib/components/traits/GenericTrait.svelte
Normal file
29
frontend/src/lib/components/traits/GenericTrait.svelte
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import TraitPanel from './TraitPanel.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
name: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { name, config }: Props = $props();
|
||||||
|
|
||||||
|
const configKeys = $derived(Object.keys(config));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TraitPanel {name} label={name}>
|
||||||
|
{#snippet children()}
|
||||||
|
{#if configKeys.length > 0}
|
||||||
|
<dl class="space-y-1 text-sm">
|
||||||
|
{#each configKeys as key (key)}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<dt class="text-xs text-gray-500">{key}:</dt>
|
||||||
|
<dd class="text-xs text-gray-700">{JSON.stringify(config[key])}</dd>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</dl>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-gray-400">Aktivert, ingen konfigurasjon.</p>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</TraitPanel>
|
||||||
23
frontend/src/lib/components/traits/KanbanTrait.svelte
Normal file
23
frontend/src/lib/components/traits/KanbanTrait.svelte
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Node } from '$lib/spacetime';
|
||||||
|
import TraitPanel from './TraitPanel.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
collection: Node;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { collection, config, userId }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TraitPanel name="kanban" label="Kanban-brett" icon="📋">
|
||||||
|
{#snippet children()}
|
||||||
|
<a
|
||||||
|
href="/board/{collection.id}"
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
Åpne brett →
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
|
</TraitPanel>
|
||||||
80
frontend/src/lib/components/traits/PodcastTrait.svelte
Normal file
80
frontend/src/lib/components/traits/PodcastTrait.svelte
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Node } from '$lib/spacetime';
|
||||||
|
import { edgeStore, nodeStore, nodeVisibility } from '$lib/spacetime';
|
||||||
|
import { casUrl } from '$lib/api';
|
||||||
|
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
|
||||||
|
import TraitPanel from './TraitPanel.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
collection: Node;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
userId?: string;
|
||||||
|
accessToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { collection, config, userId, accessToken }: Props = $props();
|
||||||
|
|
||||||
|
/** Media nodes (episodes) belonging to this collection */
|
||||||
|
const episodes = $derived.by(() => {
|
||||||
|
const nodes: Node[] = [];
|
||||||
|
for (const edge of edgeStore.byTarget(collection.id)) {
|
||||||
|
if (edge.edgeType !== 'belongs_to') continue;
|
||||||
|
const node = nodeStore.get(edge.sourceId);
|
||||||
|
if (!node || nodeVisibility(node, userId) === 'hidden') continue;
|
||||||
|
if (node.nodeKind === 'media') {
|
||||||
|
try {
|
||||||
|
const meta = JSON.parse(node.metadata ?? '{}');
|
||||||
|
if (typeof meta.mime === 'string' && meta.mime.startsWith('audio/')) {
|
||||||
|
nodes.push(node);
|
||||||
|
}
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
function audioSrc(node: Node): string {
|
||||||
|
try {
|
||||||
|
const meta = JSON.parse(node.metadata ?? '{}');
|
||||||
|
return casUrl(meta.cas_hash);
|
||||||
|
} catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function audioDuration(node: Node): number | undefined {
|
||||||
|
try {
|
||||||
|
const meta = JSON.parse(node.metadata ?? '{}');
|
||||||
|
return meta.transcription?.duration_ms ? meta.transcription.duration_ms / 1000 : undefined;
|
||||||
|
} catch { return undefined; }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TraitPanel name="podcast" label="Podcast" icon="🎙️">
|
||||||
|
{#snippet children()}
|
||||||
|
{#if episodes.length === 0}
|
||||||
|
<p class="text-sm text-gray-400">Ingen episoder ennå.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="space-y-3">
|
||||||
|
{#each episodes as ep (ep.id)}
|
||||||
|
<li class="rounded border border-gray-100 p-3">
|
||||||
|
<h4 class="text-sm font-medium text-gray-900">{ep.title || 'Uten tittel'}</h4>
|
||||||
|
{#if ep.content}
|
||||||
|
<p class="mt-0.5 text-xs text-gray-500">{ep.content.slice(0, 100)}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="mt-2">
|
||||||
|
<AudioPlayer
|
||||||
|
src={audioSrc(ep)}
|
||||||
|
duration={audioDuration(ep)}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</TraitPanel>
|
||||||
38
frontend/src/lib/components/traits/PublishingTrait.svelte
Normal file
38
frontend/src/lib/components/traits/PublishingTrait.svelte
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Node } from '$lib/spacetime';
|
||||||
|
import TraitPanel from './TraitPanel.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
collection: Node;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { collection, config }: Props = $props();
|
||||||
|
|
||||||
|
const slug = $derived((config.slug as string) ?? '');
|
||||||
|
const theme = $derived((config.theme as string) ?? 'default');
|
||||||
|
const customDomain = $derived((config.custom_domain as string) ?? '');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TraitPanel name="publishing" label="Publisering" icon="🌐">
|
||||||
|
{#snippet children()}
|
||||||
|
<dl class="space-y-2 text-sm">
|
||||||
|
{#if slug}
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs text-gray-500">Slug</dt>
|
||||||
|
<dd class="font-mono text-gray-900">{slug}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs text-gray-500">Tema</dt>
|
||||||
|
<dd class="text-gray-900">{theme}</dd>
|
||||||
|
</div>
|
||||||
|
{#if customDomain}
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs text-gray-500">Domene</dt>
|
||||||
|
<dd class="font-mono text-gray-900">{customDomain}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</dl>
|
||||||
|
{/snippet}
|
||||||
|
</TraitPanel>
|
||||||
17
frontend/src/lib/components/traits/RecordingTrait.svelte
Normal file
17
frontend/src/lib/components/traits/RecordingTrait.svelte
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Node } from '$lib/spacetime';
|
||||||
|
import TraitPanel from './TraitPanel.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
collection: Node;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { collection, config }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TraitPanel name="recording" label="Opptak" icon="🎤">
|
||||||
|
{#snippet children()}
|
||||||
|
<p class="text-sm text-gray-500">LiveKit-studio for opptak og sanntidslyd.</p>
|
||||||
|
{/snippet}
|
||||||
|
</TraitPanel>
|
||||||
49
frontend/src/lib/components/traits/RssTrait.svelte
Normal file
49
frontend/src/lib/components/traits/RssTrait.svelte
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Node } from '$lib/spacetime';
|
||||||
|
import TraitPanel from './TraitPanel.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
collection: Node;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { collection, config }: Props = $props();
|
||||||
|
|
||||||
|
const format = $derived((config.format as string) ?? 'rss');
|
||||||
|
const maxItems = $derived((config.max_items as number) ?? 50);
|
||||||
|
|
||||||
|
/** Build the feed URL from publishing slug if available */
|
||||||
|
const feedUrl = $derived.by(() => {
|
||||||
|
try {
|
||||||
|
const meta = JSON.parse(collection.metadata ?? '{}');
|
||||||
|
const slug = meta.traits?.publishing?.slug;
|
||||||
|
if (slug) return `/pub/${slug}/feed.xml`;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return `/api/rss/${collection.id}`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TraitPanel name="rss" label="RSS-feed" icon="📡">
|
||||||
|
{#snippet children()}
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-gray-500">Format:</span>
|
||||||
|
<span class="rounded bg-orange-50 px-2 py-0.5 text-xs font-medium text-orange-700">{format.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500">Maks elementer:</span>
|
||||||
|
<span class="ml-1 text-gray-900">{maxItems}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<a
|
||||||
|
href={feedUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded bg-orange-100 px-3 py-1.5 text-xs font-medium text-orange-800 hover:bg-orange-200"
|
||||||
|
>
|
||||||
|
Feed-URL →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</TraitPanel>
|
||||||
30
frontend/src/lib/components/traits/TraitPanel.svelte
Normal file
30
frontend/src/lib/components/traits/TraitPanel.svelte
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Generic wrapper for trait panels.
|
||||||
|
* Provides consistent styling and a header with trait name.
|
||||||
|
*/
|
||||||
|
interface Props {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
icon?: string;
|
||||||
|
children?: import('svelte').Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { name, label, icon, children }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="rounded-lg border border-gray-200 bg-white shadow-sm" data-trait={name}>
|
||||||
|
<div class="flex items-center gap-2 border-b border-gray-100 px-4 py-3">
|
||||||
|
{#if icon}
|
||||||
|
<span class="text-lg" aria-hidden="true">{icon}</span>
|
||||||
|
{/if}
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700">{label}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-gray-400">Ingen innhold ennå.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
17
frontend/src/lib/components/traits/TranscriptionTrait.svelte
Normal file
17
frontend/src/lib/components/traits/TranscriptionTrait.svelte
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Node } from '$lib/spacetime';
|
||||||
|
import TraitPanel from './TraitPanel.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
collection: Node;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { collection, config }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TraitPanel name="transcription" label="Transkripsjon" icon="📜">
|
||||||
|
{#snippet children()}
|
||||||
|
<p class="text-sm text-gray-500">Automatisk tale-til-tekst via Whisper.</p>
|
||||||
|
{/snippet}
|
||||||
|
</TraitPanel>
|
||||||
|
|
@ -120,15 +120,33 @@
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if a node is a kanban board (collection with board metadata) */
|
/** Check if a node is a kanban board (collection with board metadata, no traits) */
|
||||||
function isBoard(node: Node): boolean {
|
function isBoard(node: Node): boolean {
|
||||||
if (node.nodeKind !== 'collection') return false;
|
if (node.nodeKind !== 'collection') return false;
|
||||||
try {
|
try {
|
||||||
const meta = JSON.parse(node.metadata ?? '{}');
|
const meta = JSON.parse(node.metadata ?? '{}');
|
||||||
return meta.board === true;
|
return meta.board === true && !meta.traits;
|
||||||
} catch { return false; }
|
} 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 */
|
/** Count scheduled events for badge display */
|
||||||
const scheduledCount = $derived.by(() => {
|
const scheduledCount = $derived.by(() => {
|
||||||
if (!connected) return 0;
|
if (!connected) return 0;
|
||||||
|
|
@ -366,7 +384,37 @@
|
||||||
<ul class="space-y-2">
|
<ul class="space-y-2">
|
||||||
{#each mottaksnoder as node (node.id)}
|
{#each mottaksnoder as node (node.id)}
|
||||||
{@const vis = nodeVisibility(node, nodeId)}
|
{@const vis = nodeVisibility(node, nodeId)}
|
||||||
{#if isBoard(node)}
|
{#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">
|
<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">
|
<a href="/board/{node.id}" class="block p-4">
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
|
|
||||||
141
frontend/src/routes/collection/[id]/+page.svelte
Normal file
141
frontend/src/routes/collection/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { connectionState, nodeStore, edgeStore, nodeVisibility } from '$lib/spacetime';
|
||||||
|
import type { Node } from '$lib/spacetime';
|
||||||
|
|
||||||
|
// Trait components
|
||||||
|
import EditorTrait from '$lib/components/traits/EditorTrait.svelte';
|
||||||
|
import ChatTrait from '$lib/components/traits/ChatTrait.svelte';
|
||||||
|
import KanbanTrait from '$lib/components/traits/KanbanTrait.svelte';
|
||||||
|
import PodcastTrait from '$lib/components/traits/PodcastTrait.svelte';
|
||||||
|
import PublishingTrait from '$lib/components/traits/PublishingTrait.svelte';
|
||||||
|
import RssTrait from '$lib/components/traits/RssTrait.svelte';
|
||||||
|
import CalendarTrait from '$lib/components/traits/CalendarTrait.svelte';
|
||||||
|
import RecordingTrait from '$lib/components/traits/RecordingTrait.svelte';
|
||||||
|
import TranscriptionTrait from '$lib/components/traits/TranscriptionTrait.svelte';
|
||||||
|
import GenericTrait from '$lib/components/traits/GenericTrait.svelte';
|
||||||
|
|
||||||
|
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');
|
||||||
|
const collectionId = $derived($page.params.id ?? '');
|
||||||
|
|
||||||
|
const collectionNode = $derived(connected ? nodeStore.get(collectionId) : undefined);
|
||||||
|
|
||||||
|
/** Parse traits from collection metadata */
|
||||||
|
const traits = $derived.by((): Record<string, Record<string, unknown>> => {
|
||||||
|
if (!collectionNode) return {};
|
||||||
|
try {
|
||||||
|
const meta = JSON.parse(collectionNode.metadata ?? '{}');
|
||||||
|
if (meta.traits && typeof meta.traits === 'object' && !Array.isArray(meta.traits)) {
|
||||||
|
return meta.traits as Record<string, Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const traitNames = $derived(Object.keys(traits));
|
||||||
|
|
||||||
|
/** Traits with dedicated components */
|
||||||
|
const knownTraits = new Set([
|
||||||
|
'editor', 'chat', 'kanban', 'podcast', 'publishing',
|
||||||
|
'rss', 'calendar', 'recording', 'transcription'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Traits that have a dedicated component */
|
||||||
|
const renderedTraits = $derived(traitNames.filter(t => knownTraits.has(t)));
|
||||||
|
|
||||||
|
/** Traits without a dedicated component — shown with generic panel */
|
||||||
|
const genericTraits = $derived(traitNames.filter(t => !knownTraits.has(t)));
|
||||||
|
|
||||||
|
/** Count of child nodes */
|
||||||
|
const childCount = $derived.by(() => {
|
||||||
|
if (!connected || !collectionId) return 0;
|
||||||
|
let count = 0;
|
||||||
|
for (const edge of edgeStore.byTarget(collectionId)) {
|
||||||
|
if (edge.edgeType === 'belongs_to') count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
});
|
||||||
|
</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-4xl items-center justify-between px-4 py-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<a href="/" class="text-sm text-gray-400 hover:text-gray-600">← Mottak</a>
|
||||||
|
<h1 class="text-lg font-semibold text-gray-900">
|
||||||
|
{collectionNode?.title || 'Samling'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{#if connected}
|
||||||
|
<span class="text-xs text-green-600">Tilkoblet</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-xs text-gray-400">{connectionState.current}</span>
|
||||||
|
{/if}
|
||||||
|
{#if traitNames.length > 0}
|
||||||
|
<span class="text-xs text-gray-400">{traitNames.length} traits</span>
|
||||||
|
{/if}
|
||||||
|
<span class="text-xs text-gray-400">{childCount} noder</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="mx-auto max-w-4xl px-4 py-6">
|
||||||
|
{#if !connected}
|
||||||
|
<p class="text-sm text-gray-400">Venter på tilkobling…</p>
|
||||||
|
{:else if !collectionNode}
|
||||||
|
<div class="rounded-lg border border-yellow-200 bg-yellow-50 p-4 text-sm text-yellow-800">
|
||||||
|
<p class="font-medium">Samling ikke funnet</p>
|
||||||
|
<p class="mt-1">Samlingsnoden med ID {collectionId} finnes ikke eller er ikke tilgjengelig.</p>
|
||||||
|
<a href="/" class="mt-2 inline-block text-blue-600 hover:underline">Tilbake til mottak</a>
|
||||||
|
</div>
|
||||||
|
{:else if traitNames.length === 0}
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-white p-6 text-center">
|
||||||
|
<p class="text-sm text-gray-500">Denne samlingen har ingen aktive traits.</p>
|
||||||
|
<p class="mt-1 text-xs text-gray-400">Traits bestemmer hvilke verktøy og visninger som er tilgjengelige.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Active traits as pills -->
|
||||||
|
<div class="mb-6 flex flex-wrap gap-2">
|
||||||
|
{#each traitNames as trait (trait)}
|
||||||
|
<span class="rounded-full bg-indigo-50 px-3 py-1 text-xs font-medium text-indigo-700">
|
||||||
|
{trait}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Trait panels -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each renderedTraits as trait (trait)}
|
||||||
|
{#if trait === 'editor'}
|
||||||
|
<EditorTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
|
||||||
|
{:else if trait === 'chat'}
|
||||||
|
<ChatTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
|
||||||
|
{:else if trait === 'kanban'}
|
||||||
|
<KanbanTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
|
||||||
|
{:else if trait === 'podcast'}
|
||||||
|
<PodcastTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
|
||||||
|
{:else if trait === 'publishing'}
|
||||||
|
<PublishingTrait collection={collectionNode} config={traits[trait]} />
|
||||||
|
{:else if trait === 'rss'}
|
||||||
|
<RssTrait collection={collectionNode} config={traits[trait]} />
|
||||||
|
{:else if trait === 'calendar'}
|
||||||
|
<CalendarTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
|
||||||
|
{:else if trait === 'recording'}
|
||||||
|
<RecordingTrait collection={collectionNode} config={traits[trait]} />
|
||||||
|
{:else if trait === 'transcription'}
|
||||||
|
<TranscriptionTrait collection={collectionNode} config={traits[trait]} />
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#each genericTraits as trait (trait)}
|
||||||
|
<GenericTrait name={trait} config={traits[trait]} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -131,8 +131,7 @@ Uavhengige faser kan fortsatt plukkes.
|
||||||
## Fase 13: Trait-system
|
## Fase 13: Trait-system
|
||||||
|
|
||||||
- [x] 13.1 Trait-metadata på samlingsnoder: maskinrommet validerer `metadata.traits`-objektet ved `create_node` og `update_node` for samlingsnoder. Avvis ukjente trait-navn. Ref: `docs/primitiver/traits.md`.
|
- [x] 13.1 Trait-metadata på samlingsnoder: maskinrommet validerer `metadata.traits`-objektet ved `create_node` og `update_node` for samlingsnoder. Avvis ukjente trait-navn. Ref: `docs/primitiver/traits.md`.
|
||||||
- [~] 13.2 Trait-aware frontend: samlingssider leser `traits` fra metadata og rendrer kun aktive komponenter. Dynamisk komponent-lasting basert på trait-liste.
|
- [x] 13.2 Trait-aware frontend: samlingssider leser `traits` fra metadata og rendrer kun aktive komponenter. Dynamisk komponent-lasting basert på trait-liste.
|
||||||
> Påbegynt: 2026-03-18T00:15
|
|
||||||
- [ ] 13.3 Pakkevelger: UI for å opprette ny samling med forhåndsdefinert pakke (nettmagasin, podcaststudio, redaksjon osv.) eller manuelt valg av traits.
|
- [ ] 13.3 Pakkevelger: UI for å opprette ny samling med forhåndsdefinert pakke (nettmagasin, podcaststudio, redaksjon osv.) eller manuelt valg av traits.
|
||||||
- [ ] 13.4 Trait-administrasjon: admin-UI for å legge til/fjerne traits på eksisterende samlinger med konfigurasjon per trait.
|
- [ ] 13.4 Trait-administrasjon: admin-UI for å legge til/fjerne traits på eksisterende samlinger med konfigurasjon per trait.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue