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:
parent
b05b4f6e58
commit
51eb089f0c
6 changed files with 549 additions and 228 deletions
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
265
frontend/src/lib/components/traits/TraitAdmin.svelte
Normal file
265
frontend/src/lib/components/traits/TraitAdmin.svelte
Normal 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
215
frontend/src/lib/traits.ts
Normal 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: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =========================================================================
|
||||
|
|
|
|||
3
tasks.md
3
tasks.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue