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);
|
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
|
// 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 RecordingTrait from '$lib/components/traits/RecordingTrait.svelte';
|
||||||
import TranscriptionTrait from '$lib/components/traits/TranscriptionTrait.svelte';
|
import TranscriptionTrait from '$lib/components/traits/TranscriptionTrait.svelte';
|
||||||
import GenericTrait from '$lib/components/traits/GenericTrait.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 session = $derived($page.data.session as Record<string, unknown> | undefined);
|
||||||
const nodeId = $derived(session?.nodeId as string | undefined);
|
const nodeId = $derived(session?.nodeId as string | undefined);
|
||||||
const accessToken = $derived(session?.accessToken as string | undefined);
|
const accessToken = $derived(session?.accessToken as string | undefined);
|
||||||
|
let showTraitAdmin = $state(false);
|
||||||
const connected = $derived(connectionState.current === 'connected');
|
const connected = $derived(connectionState.current === 'connected');
|
||||||
const collectionId = $derived($page.params.id ?? '');
|
const collectionId = $derived($page.params.id ?? '');
|
||||||
|
|
||||||
const collectionNode = $derived(connected ? nodeStore.get(collectionId) : undefined);
|
const collectionNode = $derived(connected ? nodeStore.get(collectionId) : undefined);
|
||||||
|
|
||||||
/** Parse traits from collection metadata */
|
/** Parse full metadata */
|
||||||
const traits = $derived.by((): Record<string, Record<string, unknown>> => {
|
const parsedMetadata = $derived.by((): Record<string, unknown> => {
|
||||||
if (!collectionNode) return {};
|
if (!collectionNode) return {};
|
||||||
try {
|
try {
|
||||||
const meta = JSON.parse(collectionNode.metadata ?? '{}');
|
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)) {
|
if (meta.traits && typeof meta.traits === 'object' && !Array.isArray(meta.traits)) {
|
||||||
return meta.traits as Record<string, Record<string, unknown>>;
|
return meta.traits as Record<string, Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
|
||||||
return {};
|
return {};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -80,11 +87,32 @@
|
||||||
<span class="text-xs text-gray-400">{traitNames.length} traits</span>
|
<span class="text-xs text-gray-400">{traitNames.length} traits</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="text-xs text-gray-400">{childCount} noder</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="mx-auto max-w-4xl px-4 py-6">
|
<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}
|
{#if !connected}
|
||||||
<p class="text-sm text-gray-400">Venter på tilkobling…</p>
|
<p class="text-sm text-gray-400">Venter på tilkobling…</p>
|
||||||
{:else if !collectionNode}
|
{:else if !collectionNode}
|
||||||
|
|
@ -97,6 +125,14 @@
|
||||||
<div class="rounded-lg border border-gray-200 bg-white p-6 text-center">
|
<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="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>
|
<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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Active traits as pills -->
|
<!-- Active traits as pills -->
|
||||||
|
|
|
||||||
|
|
@ -3,231 +3,13 @@
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { connectionState } from '$lib/spacetime';
|
import { connectionState } from '$lib/spacetime';
|
||||||
import { createNode, createEdge } from '$lib/api';
|
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 session = $derived($page.data.session as Record<string, unknown> | undefined);
|
||||||
const nodeId = $derived(session?.nodeId as string | undefined);
|
const nodeId = $derived(session?.nodeId as string | undefined);
|
||||||
const accessToken = $derived(session?.accessToken as string | undefined);
|
const accessToken = $derived(session?.accessToken as string | undefined);
|
||||||
const connected = $derived(connectionState.current === 'connected');
|
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
|
// 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.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.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.
|
- [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.
|
- [x] 13.4 Trait-administrasjon: admin-UI for å legge til/fjerne traits på eksisterende samlinger med konfigurasjon per trait.
|
||||||
> Påbegynt: 2026-03-18T00:27
|
|
||||||
|
|
||||||
## Fase 14: Publisering
|
## Fase 14: Publisering
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue