Pakkevelger: UI for ny samling med pakker eller manuelt trait-valg (oppgave 13.3)

Ny side /collection/new med:
- 13 forhåndsdefinerte pakker (nettmagasin, podcaststudio, redaksjon osv.)
  som kort-grid med ikon, beskrivelse og trait-liste
- Manuelt trait-valg med hele trait-katalogen kategorisert i 9 grupper
- Oppsummering med valgte traits og opprett-knapp
- Navigerer til /collection/[id] etter opprettelse

Knapp «Ny samling» lagt til i mottak-headeren.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 00:26:17 +00:00
parent 7079759c24
commit 427eae9641
3 changed files with 464 additions and 2 deletions

View file

@ -346,6 +346,12 @@
> >
Graf Graf
</a> </a>
<a
href="/collection/new"
class="rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700"
>
Ny samling
</a>
<button <button
onclick={handleNewBoard} onclick={handleNewBoard}
disabled={isCreatingBoard} disabled={isCreatingBoard}

View file

@ -0,0 +1,457 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { connectionState } from '$lib/spacetime';
import { createNode, createEdge } from '$lib/api';
const session = $derived($page.data.session as Record<string, unknown> | undefined);
const nodeId = $derived(session?.nodeId as string | undefined);
const accessToken = $derived(session?.accessToken as string | undefined);
const connected = $derived(connectionState.current === 'connected');
// =========================================================================
// 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
// =========================================================================
type Mode = 'select' | 'custom';
let mode: Mode = $state('select');
let selectedPackage: Package | null = $state(null);
let title = $state('');
let customTraits: Set<string> = $state(new Set());
let isCreating = $state(false);
let error: string | null = $state(null);
/** Currently active traits (from package or custom selection) */
const activeTraits = $derived.by((): Record<string, Record<string, unknown>> => {
if (mode === 'select' && selectedPackage) {
return selectedPackage.traits;
}
if (mode === 'custom') {
const result: Record<string, Record<string, unknown>> = {};
for (const t of customTraits) {
result[t] = {};
}
return result;
}
return {};
});
const activeTraitCount = $derived(Object.keys(activeTraits).length);
const canCreate = $derived(
title.trim().length > 0 && activeTraitCount > 0 && !isCreating
);
function selectPackage(pkg: Package) {
selectedPackage = pkg;
mode = 'select';
}
function enterCustomMode() {
mode = 'custom';
selectedPackage = null;
}
function toggleTrait(trait: string) {
const next = new Set(customTraits);
if (next.has(trait)) {
next.delete(trait);
} else {
next.add(trait);
}
customTraits = next;
}
async function handleCreate() {
if (!canCreate || !accessToken || !nodeId) return;
isCreating = true;
error = null;
try {
const { node_id } = await createNode(accessToken, {
node_kind: 'collection',
title: title.trim(),
visibility: 'hidden',
metadata: { traits: activeTraits },
});
await createEdge(accessToken, {
source_id: nodeId,
target_id: node_id,
edge_type: 'owner',
});
goto(`/collection/${node_id}`);
} catch (e) {
error = e instanceof Error ? e.message : 'Ukjent feil';
isCreating = false;
}
}
</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">&larr; Mottak</a>
<h1 class="text-lg font-semibold text-gray-900">Ny samling</h1>
</div>
{#if connected}
<span class="text-xs text-green-600">Tilkoblet</span>
{:else}
<span class="text-xs text-gray-400">{connectionState.current}</span>
{/if}
</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 !accessToken || !nodeId}
<div class="rounded-lg border border-yellow-200 bg-yellow-50 p-4 text-sm text-yellow-800">
<p class="font-medium">Ikke innlogget</p>
<p class="mt-1">Du må være innlogget for å opprette samlinger.</p>
</div>
{:else}
<!-- Title input -->
<div class="mb-6">
<label for="collection-title" class="mb-1 block text-sm font-medium text-gray-700">
Navn på samling
</label>
<input
id="collection-title"
type="text"
bind:value={title}
placeholder="F.eks. «Sidelinja Podcast» eller «Teamets wiki»"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 focus:outline-none"
/>
</div>
<!-- Mode tabs -->
<div class="mb-4 flex gap-2">
<button
onclick={() => { mode = 'select'; }}
class="rounded-lg px-3 py-1.5 text-sm font-medium transition-colors {mode === 'select' ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}"
>
Velg pakke
</button>
<button
onclick={enterCustomMode}
class="rounded-lg px-3 py-1.5 text-sm font-medium transition-colors {mode === 'custom' ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}"
>
Velg traits manuelt
</button>
</div>
{#if mode === 'select'}
<!-- Package grid -->
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each packages as pkg (pkg.id)}
<button
onclick={() => selectPackage(pkg)}
class="rounded-lg border p-4 text-left transition-all {selectedPackage?.id === pkg.id
? 'border-indigo-500 bg-indigo-50 ring-2 ring-indigo-200'
: 'border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm'}"
>
<div class="mb-1 text-2xl">{pkg.icon}</div>
<h3 class="font-medium text-gray-900">{pkg.name}</h3>
<p class="mt-0.5 text-xs text-gray-500">{pkg.description}</p>
<div class="mt-2 flex flex-wrap gap-1">
{#each Object.keys(pkg.traits) as trait (trait)}
<span class="rounded-full bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-500">
{trait}
</span>
{/each}
</div>
</button>
{/each}
</div>
{:else}
<!-- Custom trait selector -->
<div class="space-y-4">
{#each traitCatalog as category (category.label)}
<div>
<h3 class="mb-2 text-xs font-semibold tracking-wide text-gray-400 uppercase">
{category.label}
</h3>
<div class="flex flex-wrap gap-2">
{#each category.traits as trait (trait)}
<button
onclick={() => toggleTrait(trait)}
class="rounded-full border px-3 py-1 text-sm transition-colors {customTraits.has(trait)
? 'border-indigo-500 bg-indigo-50 text-indigo-700'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300'}"
>
{trait}
</button>
{/each}
</div>
</div>
{/each}
</div>
{/if}
<!-- Summary & create -->
{#if activeTraitCount > 0}
<div class="mt-6 rounded-lg border border-gray-200 bg-white p-4">
<div class="mb-3 flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-700">
{#if mode === 'select' && selectedPackage}
Pakke: {selectedPackage.name}
{:else}
Egendefinert samling
{/if}
<span class="ml-1 text-gray-400">({activeTraitCount} traits)</span>
</h3>
</div>
<div class="mb-4 flex flex-wrap gap-1">
{#each Object.keys(activeTraits) as trait (trait)}
<span class="rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-medium text-indigo-600">
{trait}
</span>
{/each}
</div>
{#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}
<button
onclick={handleCreate}
disabled={!canCreate}
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{#if isCreating}
Oppretter…
{:else}
Opprett samling
{/if}
</button>
</div>
{/if}
{/if}
</main>
</div>

View file

@ -132,8 +132,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.
- [~] 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.
> Påbegynt: 2026-03-18T00:21
- [ ] 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.
## Fase 14: Publisering ## Fase 14: Publisering