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:
parent
7079759c24
commit
427eae9641
3 changed files with 464 additions and 2 deletions
|
|
@ -346,6 +346,12 @@
|
|||
>
|
||||
Graf
|
||||
</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
|
||||
onclick={handleNewBoard}
|
||||
disabled={isCreatingBoard}
|
||||
|
|
|
|||
457
frontend/src/routes/collection/new/+page.svelte
Normal file
457
frontend/src/routes/collection/new/+page.svelte
Normal 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">← 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>
|
||||
3
tasks.md
3
tasks.md
|
|
@ -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.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.
|
||||
> Påbegynt: 2026-03-18T00:21
|
||||
- [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.
|
||||
|
||||
## Fase 14: Publisering
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue