Trait-administrasjon: admin-UI for å administrere traits på samlinger (oppgave 13.4)

Legger til et admin-panel på samlingssiden der man kan legge til/fjerne
traits fra katalogen og konfigurere per-trait-innstillinger. Endringene
sendes via updateNode til maskinrommet som validerer mot VALID_TRAITS.

- Ny updateNode-funksjon i api.ts
- Delt trait-katalog i lib/traits.ts (brukes av collection/new og TraitAdmin)
- TraitAdmin.svelte: toggle traits, rediger config-nøkler, lagre
- Integrasjon i collection/[id] med Traits-knapp i header

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 00:32:31 +00:00
parent b05b4f6e58
commit 51eb089f0c
6 changed files with 549 additions and 228 deletions

View file

@ -66,6 +66,30 @@ export function createEdge(
return post(accessToken, '/intentions/create_edge', data);
}
// =============================================================================
// Node-oppdatering
// =============================================================================
export interface UpdateNodeRequest {
node_id: string;
node_kind?: string;
title?: string;
content?: string;
visibility?: string;
metadata?: Record<string, unknown>;
}
export interface UpdateNodeResponse {
node_id: string;
}
export function updateNode(
accessToken: string,
data: UpdateNodeRequest
): Promise<UpdateNodeResponse> {
return post(accessToken, '/intentions/update_node', data);
}
// =============================================================================
// Edge-oppdatering
// =============================================================================

View file

@ -0,0 +1,265 @@
<script lang="ts">
import { updateNode } from '$lib/api';
import { traitCatalog } from '$lib/traits';
interface Props {
collectionId: string;
accessToken: string;
/** Current traits object from collection metadata */
traits: Record<string, Record<string, unknown>>;
/** Current full metadata (so we preserve non-trait fields) */
metadata: Record<string, unknown>;
onclose: () => void;
}
let { collectionId, accessToken, traits, metadata, onclose }: Props = $props();
// Local working copy of traits — start from current state
let workingTraits: Record<string, Record<string, unknown>> = $state({});
let isSaving = $state(false);
let error: string | null = $state(null);
let successMsg: string | null = $state(null);
// Editing state for per-trait config
let editingTrait: string | null = $state(null);
let configKey = $state('');
let configValue = $state('');
// Initialize working copy from props
$effect(() => {
workingTraits = structuredClone(traits);
});
const activeTraitNames = $derived(Object.keys(workingTraits).sort());
const hasChanges = $derived(() => {
const currentKeys = Object.keys(traits).sort();
const workingKeys = Object.keys(workingTraits).sort();
if (currentKeys.length !== workingKeys.length) return true;
if (currentKeys.join(',') !== workingKeys.join(',')) return true;
// Check config changes
for (const key of currentKeys) {
if (JSON.stringify(traits[key]) !== JSON.stringify(workingTraits[key])) return true;
}
return false;
});
function addTrait(trait: string) {
if (!(trait in workingTraits)) {
workingTraits = { ...workingTraits, [trait]: {} };
}
}
function removeTrait(trait: string) {
const next = { ...workingTraits };
delete next[trait];
workingTraits = next;
if (editingTrait === trait) editingTrait = null;
}
function startEditConfig(trait: string) {
editingTrait = editingTrait === trait ? null : trait;
configKey = '';
configValue = '';
}
function addConfigEntry(trait: string) {
if (!configKey.trim()) return;
const config = { ...workingTraits[trait] };
// Try to parse value as JSON, fall back to string
let parsed: unknown;
try {
parsed = JSON.parse(configValue);
} catch {
parsed = configValue;
}
config[configKey.trim()] = parsed;
workingTraits = { ...workingTraits, [trait]: config };
configKey = '';
configValue = '';
}
function removeConfigEntry(trait: string, key: string) {
const config = { ...workingTraits[trait] };
delete config[key];
workingTraits = { ...workingTraits, [trait]: config };
}
async function save() {
isSaving = true;
error = null;
successMsg = null;
try {
const newMetadata = { ...metadata, traits: workingTraits };
await updateNode(accessToken, {
node_id: collectionId,
metadata: newMetadata,
});
successMsg = 'Traits oppdatert';
// Auto-clear success message
setTimeout(() => { successMsg = null; }, 2000);
} catch (e) {
error = e instanceof Error ? e.message : 'Ukjent feil';
} finally {
isSaving = false;
}
}
</script>
<div class="rounded-lg border border-gray-200 bg-white shadow-sm">
<!-- Header -->
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-3">
<h2 class="text-sm font-semibold text-gray-900">Administrer traits</h2>
<button
onclick={onclose}
class="text-sm text-gray-400 hover:text-gray-600"
>
Lukk
</button>
</div>
<div class="p-4">
<!-- Active traits -->
{#if activeTraitNames.length > 0}
<div class="mb-4">
<h3 class="mb-2 text-xs font-semibold tracking-wide text-gray-400 uppercase">
Aktive traits ({activeTraitNames.length})
</h3>
<div class="space-y-2">
{#each activeTraitNames as trait (trait)}
<div class="rounded-lg border border-gray-100 bg-gray-50 px-3 py-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-800">{trait}</span>
{#if Object.keys(workingTraits[trait]).length > 0}
<span class="text-[10px] text-gray-400">
({Object.keys(workingTraits[trait]).length} config)
</span>
{/if}
</div>
<div class="flex items-center gap-1">
<button
onclick={() => startEditConfig(trait)}
class="rounded px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-200 hover:text-gray-700"
>
{editingTrait === trait ? 'Skjul' : 'Config'}
</button>
<button
onclick={() => removeTrait(trait)}
class="rounded px-2 py-0.5 text-xs text-red-500 hover:bg-red-50 hover:text-red-700"
>
Fjern
</button>
</div>
</div>
<!-- Per-trait config editor -->
{#if editingTrait === trait}
<div class="mt-2 border-t border-gray-200 pt-2">
<!-- Existing config entries -->
{#each Object.entries(workingTraits[trait]) as [key, value] (key)}
<div class="flex items-center justify-between py-1">
<span class="text-xs text-gray-600">
<span class="font-mono font-medium">{key}</span>: {JSON.stringify(value)}
</span>
<button
onclick={() => removeConfigEntry(trait, key)}
class="text-[10px] text-red-400 hover:text-red-600"
>
slett
</button>
</div>
{/each}
<!-- Add new config entry -->
<div class="mt-1 flex gap-1">
<input
type="text"
bind:value={configKey}
placeholder="Nøkkel"
class="w-28 rounded border border-gray-300 px-2 py-1 text-xs focus:border-indigo-500 focus:outline-none"
/>
<input
type="text"
bind:value={configValue}
placeholder="Verdi"
class="flex-1 rounded border border-gray-300 px-2 py-1 text-xs focus:border-indigo-500 focus:outline-none"
/>
<button
onclick={() => addConfigEntry(trait)}
disabled={!configKey.trim()}
class="rounded bg-gray-100 px-2 py-1 text-xs text-gray-600 hover:bg-gray-200 disabled:opacity-50"
>
Legg til
</button>
</div>
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Add traits from catalog -->
<div class="mb-4">
<h3 class="mb-2 text-xs font-semibold tracking-wide text-gray-400 uppercase">
Legg til traits
</h3>
<div class="space-y-3">
{#each traitCatalog as category (category.label)}
{@const availableTraits = category.traits.filter(t => !(t in workingTraits))}
{#if availableTraits.length > 0}
<div>
<h4 class="mb-1 text-[11px] font-medium text-gray-500">{category.label}</h4>
<div class="flex flex-wrap gap-1">
{#each availableTraits as trait (trait)}
<button
onclick={() => addTrait(trait)}
class="rounded-full border border-gray-200 bg-white px-2.5 py-0.5 text-xs text-gray-600 hover:border-indigo-300 hover:bg-indigo-50 hover:text-indigo-700"
>
+ {trait}
</button>
{/each}
</div>
</div>
{/if}
{/each}
</div>
</div>
<!-- Status messages -->
{#if error}
<div class="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
{/if}
{#if successMsg}
<div class="mb-3 rounded border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700">
{successMsg}
</div>
{/if}
<!-- Save button -->
<div class="flex items-center justify-end gap-2 border-t border-gray-100 pt-3">
<button
onclick={onclose}
class="rounded-lg px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-100"
>
Avbryt
</button>
<button
onclick={save}
disabled={!hasChanges() || isSaving}
class="rounded-lg bg-indigo-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if isSaving}
Lagrer...
{:else}
Lagre endringer
{/if}
</button>
</div>
</div>
</div>

215
frontend/src/lib/traits.ts Normal file
View file

@ -0,0 +1,215 @@
/**
* Shared trait catalog and package definitions.
* Used by collection/new (package selector) and TraitAdmin (trait management).
*/
export interface TraitCategory {
label: string;
traits: string[];
}
export const traitCatalog: TraitCategory[] = [
{ label: 'Innhold & redigering', traits: ['editor', 'versioning', 'collaboration', 'translation', 'templates'] },
{ label: 'Publisering & distribusjon', traits: ['publishing', 'rss', 'newsletter', 'custom_domain', 'analytics', 'embed', 'api'] },
{ label: 'Lyd & video', traits: ['podcast', 'recording', 'transcription', 'tts', 'clips', 'playlist'] },
{ label: 'Kommunikasjon', traits: ['chat', 'forum', 'comments', 'guest_input', 'announcements', 'polls', 'qa'] },
{ label: 'Organisering', traits: ['kanban', 'calendar', 'timeline', 'table', 'gallery', 'bookmarks', 'tags'] },
{ label: 'Kunnskap', traits: ['knowledge_graph', 'wiki', 'glossary', 'faq', 'bibliography'] },
{ label: 'Automatisering & AI', traits: ['auto_tag', 'auto_summarize', 'digest', 'bridge', 'moderation'] },
{ label: 'Tilgang & fellesskap', traits: ['membership', 'roles', 'invites', 'paywall', 'directory'] },
{ label: 'Ekstern integrasjon', traits: ['webhook', 'import', 'export', 'ical_sync'] },
];
export interface Package {
id: string;
name: string;
description: string;
icon: string;
traits: Record<string, Record<string, unknown>>;
}
export const packages: Package[] = [
{
id: 'nettmagasin',
name: 'Nettmagasin',
description: 'Publiser artikler med RSS, kommentarer og nyhetsbrev',
icon: '📰',
traits: {
editor: { preset: 'longform' },
publishing: {},
rss: {},
comments: {},
analytics: {},
custom_domain: {},
newsletter: {},
},
},
{
id: 'podcaststudio',
name: 'Podcaststudio',
description: 'Podcast med opptak, transkripsjon og kunnskapsgraf',
icon: '🎙️',
traits: {
podcast: {},
recording: {},
transcription: {},
editor: { preset: 'shownotes' },
rss: {},
analytics: {},
clips: {},
knowledge_graph: {},
},
},
{
id: 'nyhetsbrev',
name: 'Nyhetsbrev',
description: 'Skriv og distribuer nyhetsbrev med analyse',
icon: '✉️',
traits: {
editor: { preset: 'longform' },
newsletter: {},
analytics: {},
versioning: {},
},
},
{
id: 'wiki',
name: 'Wiki',
description: 'Samarbeidende kunnskapsbase med versjonering',
icon: '📚',
traits: {
wiki: {},
editor: { preset: 'longform' },
collaboration: {},
versioning: {},
knowledge_graph: {},
glossary: {},
},
},
{
id: 'diskusjonsklubb',
name: 'Diskusjonsklubb',
description: 'Forum, chat og avstemninger for en gruppe',
icon: '💬',
traits: {
forum: {},
chat: {},
polls: {},
membership: {},
roles: {},
directory: {},
},
},
{
id: 'kursplattform',
name: 'Kursplattform',
description: 'Kursinnhold med spillelister, Q&A og betaling',
icon: '🎓',
traits: {
editor: { preset: 'longform' },
playlist: {},
qa: {},
membership: {},
paywall: {},
templates: {},
},
},
{
id: 'moteplass',
name: 'Møteplass',
description: 'Opptak, chat, kanban og kalender for møter',
icon: '🤝',
traits: {
recording: {},
chat: {},
kanban: {},
calendar: {},
auto_summarize: {},
guest_input: {},
},
},
{
id: 'fotoblogg',
name: 'Fotoblogg',
description: 'Bildegalleri med publisering og kommentarer',
icon: '📷',
traits: {
gallery: {},
publishing: {},
comments: {},
custom_domain: {},
rss: {},
},
},
{
id: 'prosjektstyring',
name: 'Prosjektstyring',
description: 'Kanban, kalender og chat for teamarbeid',
icon: '📋',
traits: {
kanban: {},
calendar: {},
chat: {},
table: {},
tags: {},
roles: {},
},
},
{
id: 'forskning',
name: 'Åpen forskning',
description: 'Akademisk publisering med versjonering og bibliografi',
icon: '🔬',
traits: {
editor: { preset: 'longform' },
versioning: {},
bibliography: {},
publishing: {},
comments: {},
collaboration: {},
api: {},
},
},
{
id: 'community-radio',
name: 'Community radio',
description: 'Opptak, podcast, chat og avstemninger',
icon: '📻',
traits: {
recording: {},
podcast: {},
chat: {},
polls: {},
membership: {},
clips: {},
playlist: {},
},
},
{
id: 'bokmerke-vegg',
name: 'Bokmerke-vegg',
description: 'Kuraterte lenker med tags og kommentarer',
icon: '🔖',
traits: {
bookmarks: {},
tags: {},
publishing: {},
rss: {},
comments: {},
},
},
{
id: 'redaksjon',
name: 'Redaksjon',
description: 'Redaksjonelt arbeid med chat, kanban og kalender',
icon: '🗞️',
traits: {
chat: {},
kanban: {},
calendar: {},
editor: { preset: 'longform' },
knowledge_graph: {},
guest_input: {},
},
},
];

View file

@ -14,24 +14,31 @@
import RecordingTrait from '$lib/components/traits/RecordingTrait.svelte';
import TranscriptionTrait from '$lib/components/traits/TranscriptionTrait.svelte';
import GenericTrait from '$lib/components/traits/GenericTrait.svelte';
import TraitAdmin from '$lib/components/traits/TraitAdmin.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);
let showTraitAdmin = $state(false);
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>> => {
/** Parse full metadata */
const parsedMetadata = $derived.by((): 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 JSON.parse(collectionNode.metadata ?? '{}') as Record<string, unknown>;
} catch { return {}; }
});
/** Parse traits from collection metadata */
const traits = $derived.by((): Record<string, Record<string, unknown>> => {
const meta = parsedMetadata;
if (meta.traits && typeof meta.traits === 'object' && !Array.isArray(meta.traits)) {
return meta.traits as Record<string, Record<string, unknown>>;
}
return {};
});
@ -80,11 +87,32 @@
<span class="text-xs text-gray-400">{traitNames.length} traits</span>
{/if}
<span class="text-xs text-gray-400">{childCount} noder</span>
{#if connected && collectionNode && accessToken}
<button
onclick={() => { showTraitAdmin = !showTraitAdmin; }}
class="rounded-lg px-2 py-1 text-xs font-medium transition-colors {showTraitAdmin ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}"
>
Traits
</button>
{/if}
</div>
</div>
</header>
<main class="mx-auto max-w-4xl px-4 py-6">
<!-- Trait admin panel -->
{#if showTraitAdmin && accessToken}
<div class="mb-6">
<TraitAdmin
{accessToken}
collectionId={collectionId}
{traits}
metadata={parsedMetadata}
onclose={() => { showTraitAdmin = false; }}
/>
</div>
{/if}
{#if !connected}
<p class="text-sm text-gray-400">Venter på tilkobling…</p>
{:else if !collectionNode}
@ -97,6 +125,14 @@
<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>
{#if accessToken && !showTraitAdmin}
<button
onclick={() => { showTraitAdmin = true; }}
class="mt-3 rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700"
>
Legg til traits
</button>
{/if}
</div>
{:else}
<!-- Active traits as pills -->

View file

@ -3,231 +3,13 @@
import { goto } from '$app/navigation';
import { connectionState } from '$lib/spacetime';
import { createNode, createEdge } from '$lib/api';
import { traitCatalog, packages, type Package } from '$lib/traits';
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');
// =========================================================================
// Trait-katalog (kategorisert)
// =========================================================================
interface TraitCategory {
label: string;
traits: string[];
}
const traitCatalog: TraitCategory[] = [
{ label: 'Innhold & redigering', traits: ['editor', 'versioning', 'collaboration', 'translation', 'templates'] },
{ label: 'Publisering & distribusjon', traits: ['publishing', 'rss', 'newsletter', 'custom_domain', 'analytics', 'embed', 'api'] },
{ label: 'Lyd & video', traits: ['podcast', 'recording', 'transcription', 'tts', 'clips', 'playlist'] },
{ label: 'Kommunikasjon', traits: ['chat', 'forum', 'comments', 'guest_input', 'announcements', 'polls', 'qa'] },
{ label: 'Organisering', traits: ['kanban', 'calendar', 'timeline', 'table', 'gallery', 'bookmarks', 'tags'] },
{ label: 'Kunnskap', traits: ['knowledge_graph', 'wiki', 'glossary', 'faq', 'bibliography'] },
{ label: 'Automatisering & AI', traits: ['auto_tag', 'auto_summarize', 'digest', 'bridge', 'moderation'] },
{ label: 'Tilgang & fellesskap', traits: ['membership', 'roles', 'invites', 'paywall', 'directory'] },
{ label: 'Ekstern integrasjon', traits: ['webhook', 'import', 'export', 'ical_sync'] },
];
// =========================================================================
// Pakkedefinisjoner
// =========================================================================
interface Package {
id: string;
name: string;
description: string;
icon: string;
traits: Record<string, Record<string, unknown>>;
}
const packages: Package[] = [
{
id: 'nettmagasin',
name: 'Nettmagasin',
description: 'Publiser artikler med RSS, kommentarer og nyhetsbrev',
icon: '📰',
traits: {
editor: { preset: 'longform' },
publishing: {},
rss: {},
comments: {},
analytics: {},
custom_domain: {},
newsletter: {},
},
},
{
id: 'podcaststudio',
name: 'Podcaststudio',
description: 'Podcast med opptak, transkripsjon og kunnskapsgraf',
icon: '🎙️',
traits: {
podcast: {},
recording: {},
transcription: {},
editor: { preset: 'shownotes' },
rss: {},
analytics: {},
clips: {},
knowledge_graph: {},
},
},
{
id: 'nyhetsbrev',
name: 'Nyhetsbrev',
description: 'Skriv og distribuer nyhetsbrev med analyse',
icon: '✉️',
traits: {
editor: { preset: 'longform' },
newsletter: {},
analytics: {},
versioning: {},
},
},
{
id: 'wiki',
name: 'Wiki',
description: 'Samarbeidende kunnskapsbase med versjonering',
icon: '📚',
traits: {
wiki: {},
editor: { preset: 'longform' },
collaboration: {},
versioning: {},
knowledge_graph: {},
glossary: {},
},
},
{
id: 'diskusjonsklubb',
name: 'Diskusjonsklubb',
description: 'Forum, chat og avstemninger for en gruppe',
icon: '💬',
traits: {
forum: {},
chat: {},
polls: {},
membership: {},
roles: {},
directory: {},
},
},
{
id: 'kursplattform',
name: 'Kursplattform',
description: 'Kursinnhold med spillelister, Q&A og betaling',
icon: '🎓',
traits: {
editor: { preset: 'longform' },
playlist: {},
qa: {},
membership: {},
paywall: {},
templates: {},
},
},
{
id: 'moteplass',
name: 'Møteplass',
description: 'Opptak, chat, kanban og kalender for møter',
icon: '🤝',
traits: {
recording: {},
chat: {},
kanban: {},
calendar: {},
auto_summarize: {},
guest_input: {},
},
},
{
id: 'fotoblogg',
name: 'Fotoblogg',
description: 'Bildegalleri med publisering og kommentarer',
icon: '📷',
traits: {
gallery: {},
publishing: {},
comments: {},
custom_domain: {},
rss: {},
},
},
{
id: 'prosjektstyring',
name: 'Prosjektstyring',
description: 'Kanban, kalender og chat for teamarbeid',
icon: '📋',
traits: {
kanban: {},
calendar: {},
chat: {},
table: {},
tags: {},
roles: {},
},
},
{
id: 'forskning',
name: 'Åpen forskning',
description: 'Akademisk publisering med versjonering og bibliografi',
icon: '🔬',
traits: {
editor: { preset: 'longform' },
versioning: {},
bibliography: {},
publishing: {},
comments: {},
collaboration: {},
api: {},
},
},
{
id: 'community-radio',
name: 'Community radio',
description: 'Opptak, podcast, chat og avstemninger',
icon: '📻',
traits: {
recording: {},
podcast: {},
chat: {},
polls: {},
membership: {},
clips: {},
playlist: {},
},
},
{
id: 'bokmerke-vegg',
name: 'Bokmerke-vegg',
description: 'Kuraterte lenker med tags og kommentarer',
icon: '🔖',
traits: {
bookmarks: {},
tags: {},
publishing: {},
rss: {},
comments: {},
},
},
{
id: 'redaksjon',
name: 'Redaksjon',
description: 'Redaksjonelt arbeid med chat, kanban og kalender',
icon: '🗞️',
traits: {
chat: {},
kanban: {},
calendar: {},
editor: { preset: 'longform' },
knowledge_graph: {},
guest_input: {},
},
},
];
// =========================================================================
// State
// =========================================================================

View file

@ -133,8 +133,7 @@ Uavhengige faser kan fortsatt plukkes.
- [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.2 Trait-aware frontend: samlingssider leser `traits` fra metadata og rendrer kun aktive komponenter. Dynamisk komponent-lasting basert på trait-liste.
- [x] 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.
> Påbegynt: 2026-03-18T00:27
- [x] 13.4 Trait-administrasjon: admin-UI for å legge til/fjerne traits på eksisterende samlinger med konfigurasjon per trait.
## Fase 14: Publisering