Forside-admin i frontend (oppgave 14.6)

Visuell editor for redaksjonell forside-styring. Rute:
/collection/[id]/forside med tre soner (hero, featured, strøm).

- HTML5 drag-and-drop mellom hero/featured/strøm-plasser
- Pin-knapp per artikkel (forhindrer automatisk fjerning)
- Hurtigknapper for å flytte artikler mellom slots
- Forhåndsvisning via iframe av publisert forside
- Bruker POST /intentions/set_slot API fra maskinrommet
- Sanntidsdata fra SpacetimeDB (belongs_to-edges med slot-metadata)
- PublishingTrait viser nå «Rediger forside»-knapp og publisert-lenke

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 01:24:25 +00:00
parent 9fefa0a8c2
commit 8a4df2c237
4 changed files with 569 additions and 2 deletions

View file

@ -467,6 +467,30 @@ export function audioProcess(
}); });
} }
// =============================================================================
// Publisering / Forside-slots
// =============================================================================
export interface SetSlotRequest {
edge_id: string;
slot: string | null;
slot_order?: number;
pinned?: boolean;
}
export interface SetSlotResponse {
edge_id: string;
displaced: string[];
}
/** Sett slot-metadata (hero/featured/strøm) på en belongs_to-edge. */
export function setSlot(
accessToken: string,
data: SetSlotRequest
): Promise<SetSlotResponse> {
return post(accessToken, '/intentions/set_slot', data);
}
/** Hent metadata om lydfil (ffprobe). */ /** Hent metadata om lydfil (ffprobe). */
export async function audioInfo(accessToken: string, hash: string): Promise<AudioInfo> { export async function audioInfo(accessToken: string, hash: string): Promise<AudioInfo> {
const res = await fetch(`${BASE_URL}/query/audio_info?hash=${encodeURIComponent(hash)}`, { const res = await fetch(`${BASE_URL}/query/audio_info?hash=${encodeURIComponent(hash)}`, {

View file

@ -12,6 +12,8 @@
const slug = $derived((config.slug as string) ?? ''); const slug = $derived((config.slug as string) ?? '');
const theme = $derived((config.theme as string) ?? 'default'); const theme = $derived((config.theme as string) ?? 'default');
const customDomain = $derived((config.custom_domain as string) ?? ''); const customDomain = $derived((config.custom_domain as string) ?? '');
const indexMode = $derived((config.index_mode as string) ?? 'dynamic');
const featuredMax = $derived((config.featured_max as number) ?? 4);
</script> </script>
<TraitPanel name="publishing" label="Publisering" icon="🌐"> <TraitPanel name="publishing" label="Publisering" icon="🌐">
@ -33,6 +35,29 @@
<dd class="font-mono text-gray-900">{customDomain}</dd> <dd class="font-mono text-gray-900">{customDomain}</dd>
</div> </div>
{/if} {/if}
<div>
<dt class="text-xs text-gray-500">Forsidemodus</dt>
<dd class="text-gray-900">{indexMode === 'static' ? 'Statisk' : 'Dynamisk'} &middot; {featuredMax} fremhevede</dd>
</div>
</dl> </dl>
<div class="mt-4 flex flex-wrap gap-2">
<a
href="/collection/{collection.id}/forside"
class="inline-flex items-center gap-1 rounded-lg bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-700"
>
Rediger forside
</a>
{#if slug}
<a
href="/api/pub/{slug}"
target="_blank"
rel="noopener"
class="inline-flex items-center gap-1 rounded-lg bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-200"
>
Se publisert
</a>
{/if}
</div>
{/snippet} {/snippet}
</TraitPanel> </TraitPanel>

View file

@ -0,0 +1,519 @@
<script lang="ts">
import { page } from '$app/stores';
import { connectionState, nodeStore, edgeStore, nodeVisibility } from '$lib/spacetime';
import type { Node, Edge } from '$lib/spacetime';
import { setSlot } 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');
const collectionId = $derived($page.params.id ?? '');
const collectionNode = $derived(connected ? nodeStore.get(collectionId) : undefined);
/** Parse collection metadata */
const parsedMetadata = $derived.by((): Record<string, unknown> => {
if (!collectionNode) return {};
try {
return JSON.parse(collectionNode.metadata ?? '{}') as Record<string, unknown>;
} catch { return {}; }
});
/** Publishing config from traits */
const pubConfig = $derived.by((): Record<string, unknown> => {
const meta = parsedMetadata;
if (meta.traits && typeof meta.traits === 'object') {
const traits = meta.traits as Record<string, unknown>;
if (traits.publishing && typeof traits.publishing === 'object') {
return traits.publishing as Record<string, unknown>;
}
}
return {};
});
const slug = $derived((pubConfig.slug as string) ?? '');
const theme = $derived((pubConfig.theme as string) ?? 'default');
const featuredMax = $derived((pubConfig.featured_max as number) ?? 4);
// =========================================================================
// Article data: nodes with belongs_to edge to this collection
// =========================================================================
interface ArticleItem {
node: Node;
edge: Edge;
slot: string | null;
slotOrder: number | null;
pinned: boolean;
publishAt: string | null;
}
function parseEdgeMeta(edge: Edge): { slot: string | null; slotOrder: number | null; pinned: boolean; publishAt: string | null } {
try {
const meta = JSON.parse(edge.metadata ?? '{}');
return {
slot: meta.slot ?? null,
slotOrder: typeof meta.slot_order === 'number' ? meta.slot_order : null,
pinned: meta.pinned === true,
publishAt: meta.publish_at ?? null,
};
} catch {
return { slot: null, slotOrder: null, pinned: false, publishAt: null };
}
}
const articles = $derived.by((): ArticleItem[] => {
if (!connected || !collectionId) return [];
const result: ArticleItem[] = [];
for (const edge of edgeStore.byTarget(collectionId)) {
if (edge.edgeType !== 'belongs_to') continue;
const node = nodeStore.get(edge.sourceId);
if (!node || node.nodeKind === 'collection') continue;
if (nodeVisibility(node, nodeId) === 'hidden') continue;
const { slot, slotOrder, pinned, publishAt } = parseEdgeMeta(edge);
result.push({ node, edge, slot, slotOrder, pinned, publishAt });
}
return result;
});
/** Hero article (max 1) */
const heroArticle = $derived(articles.find(a => a.slot === 'hero') ?? null);
/** Featured articles, sorted by slot_order */
const featuredArticles = $derived.by(() => {
return articles
.filter(a => a.slot === 'featured')
.sort((a, b) => (a.slotOrder ?? 999) - (b.slotOrder ?? 999));
});
/** Stream articles (no slot), sorted by publish_at or created_at desc */
const streamArticles = $derived.by(() => {
return articles
.filter(a => !a.slot)
.sort((a, b) => {
const dateA = a.publishAt ?? a.node.createdAt?.toString() ?? '';
const dateB = b.publishAt ?? b.node.createdAt?.toString() ?? '';
return dateB.localeCompare(dateA);
});
});
// =========================================================================
// Drag and drop
// =========================================================================
let draggedArticle = $state<ArticleItem | null>(null);
let dragOverZone = $state<string | null>(null);
let isUpdating = $state(false);
let lastError = $state<string | null>(null);
function handleDragStart(e: DragEvent, article: ArticleItem) {
draggedArticle = article;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', article.edge.id);
}
}
function handleDragOver(e: DragEvent, zone: string) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
dragOverZone = zone;
}
function handleDragLeave() {
dragOverZone = null;
}
async function handleDrop(e: DragEvent, targetSlot: string | null) {
e.preventDefault();
dragOverZone = null;
if (!draggedArticle || !accessToken || isUpdating) return;
// No-op if same slot
const currentSlot = draggedArticle.slot ?? '';
const target = targetSlot ?? '';
if (currentSlot === target) {
draggedArticle = null;
return;
}
const article = draggedArticle;
draggedArticle = null;
lastError = null;
isUpdating = true;
try {
const slotOrder = targetSlot === 'featured' ? featuredArticles.length : undefined;
await setSlot(accessToken, {
edge_id: article.edge.id,
slot: targetSlot,
slot_order: slotOrder,
});
} catch (err) {
console.error('Feil ved slotendring:', err);
lastError = err instanceof Error ? err.message : 'Ukjent feil';
} finally {
isUpdating = false;
}
}
function handleDragEnd() {
draggedArticle = null;
dragOverZone = null;
}
// =========================================================================
// Actions: pin, move to slot
// =========================================================================
async function togglePin(article: ArticleItem) {
if (!accessToken || isUpdating) return;
isUpdating = true;
lastError = null;
try {
await setSlot(accessToken, {
edge_id: article.edge.id,
slot: article.slot,
slot_order: article.slotOrder ?? undefined,
pinned: !article.pinned,
});
} catch (err) {
console.error('Feil ved pin-endring:', err);
lastError = err instanceof Error ? err.message : 'Ukjent feil';
} finally {
isUpdating = false;
}
}
async function moveToSlot(article: ArticleItem, targetSlot: string | null) {
if (!accessToken || isUpdating) return;
isUpdating = true;
lastError = null;
try {
const slotOrder = targetSlot === 'featured' ? featuredArticles.length : undefined;
await setSlot(accessToken, {
edge_id: article.edge.id,
slot: targetSlot,
slot_order: slotOrder,
});
} catch (err) {
console.error('Feil ved slotflytting:', err);
lastError = err instanceof Error ? err.message : 'Ukjent feil';
} finally {
isUpdating = false;
}
}
// =========================================================================
// Preview
// =========================================================================
let showPreview = $state(false);
const previewUrl = $derived(slug ? `/api/pub/${slug}` : '');
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return '';
try {
const d = new Date(dateStr);
return d.toLocaleDateString('nb-NO', { day: 'numeric', month: 'short', year: 'numeric' });
} catch {
return '';
}
}
function articleTitle(node: Node): string {
return node.title || 'Uten tittel';
}
function articleExcerpt(node: Node): string {
const content = node.content ?? '';
if (content.length <= 120) return content;
return content.slice(0, 120) + '…';
}
</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-5xl items-center justify-between px-4 py-3">
<div class="flex items-center gap-3">
<a href="/collection/{collectionId}" class="text-sm text-gray-400 hover:text-gray-600">&larr; Samling</a>
<h1 class="text-lg font-semibold text-gray-900">
Forside-admin
</h1>
{#if collectionNode}
<span class="text-sm text-gray-500">{collectionNode.title}</span>
{/if}
</div>
<div class="flex items-center gap-3">
{#if connected}
<span class="text-xs text-green-600">Tilkoblet</span>
{:else}
<span class="text-xs text-gray-400">{connectionState.current}</span>
{/if}
<span class="text-xs text-gray-400">{articles.length} artikler</span>
<span class="text-xs text-gray-400">Tema: {theme}</span>
{#if previewUrl}
<button
onclick={() => { showPreview = !showPreview; }}
class="rounded-lg px-2 py-1 text-xs font-medium transition-colors {showPreview ? 'bg-emerald-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}"
>
{showPreview ? 'Skjul forhåndsvisning' : 'Forhåndsvisning'}
</button>
{/if}
</div>
</div>
</header>
<main class="mx-auto max-w-5xl px-4 py-6">
{#if lastError}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{lastError}
<button onclick={() => { lastError = null; }} class="ml-2 underline">Lukk</button>
</div>
{/if}
{#if isUpdating}
<div class="mb-4 rounded-lg border border-blue-200 bg-blue-50 p-2 text-sm text-blue-700">
Oppdaterer…
</div>
{/if}
{#if !connected}
<p class="text-sm text-gray-400">Venter på tilkobling…</p>
{:else if !collectionNode}
<div class="rounded-lg border border-yellow-200 bg-yellow-50 p-4 text-sm text-yellow-800">
Samling ikke funnet.
<a href="/" class="text-blue-600 hover:underline">Tilbake</a>
</div>
{:else if !slug}
<div class="rounded-lg border border-yellow-200 bg-yellow-50 p-4 text-sm text-yellow-800">
Denne samlingen har ikke publishing-trait med slug konfigurert.
</div>
{:else}
<div class="space-y-6">
<!-- HERO ZONE -->
<section>
<h2 class="mb-2 text-sm font-semibold text-gray-700">Hero (maks 1)</h2>
<div
class="min-h-[120px] rounded-lg border-2 border-dashed transition-colors {dragOverZone === 'hero' ? 'border-amber-400 bg-amber-50' : 'border-gray-300 bg-white'}"
ondragover={(e: DragEvent) => handleDragOver(e, 'hero')}
ondragleave={handleDragLeave}
ondrop={(e: DragEvent) => handleDrop(e, 'hero')}
role="region"
aria-label="Hero-sone"
>
{#if heroArticle}
<div
class="group relative m-2 cursor-grab rounded-lg border border-amber-200 bg-amber-50 p-4 shadow-sm transition-shadow hover:shadow-md active:cursor-grabbing {draggedArticle?.edge.id === heroArticle.edge.id ? 'opacity-50' : ''}"
draggable="true"
ondragstart={(e: DragEvent) => handleDragStart(e, heroArticle)}
ondragend={handleDragEnd}
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<h3 class="text-base font-semibold text-gray-900">{articleTitle(heroArticle.node)}</h3>
{#if articleExcerpt(heroArticle.node)}
<p class="mt-1 text-sm text-gray-600">{articleExcerpt(heroArticle.node)}</p>
{/if}
{#if heroArticle.publishAt}
<p class="mt-1 text-xs text-gray-400">{formatDate(heroArticle.publishAt)}</p>
{/if}
</div>
<div class="flex shrink-0 items-center gap-1">
<button
onclick={() => togglePin(heroArticle)}
class="rounded p-1 text-sm transition-colors {heroArticle.pinned ? 'bg-amber-200 text-amber-800' : 'text-gray-400 hover:bg-gray-100 hover:text-gray-600'}"
title={heroArticle.pinned ? 'Fjern pin' : 'Pin (forhindrer automatisk fjerning)'}
>
{heroArticle.pinned ? '&#128204;' : '&#128392;'}
</button>
<button
onclick={() => moveToSlot(heroArticle, null)}
class="rounded p-1 text-xs text-gray-400 hover:bg-gray-100 hover:text-gray-600"
title="Flytt til strøm"
>
&#8595;
</button>
</div>
</div>
</div>
{:else}
<div class="flex items-center justify-center p-8 text-sm text-gray-400">
Dra en artikkel hit for å sette som hero
</div>
{/if}
</div>
</section>
<!-- FEATURED ZONE -->
<section>
<h2 class="mb-2 text-sm font-semibold text-gray-700">
Fremhevet ({featuredArticles.length}/{featuredMax})
</h2>
<div
class="min-h-[80px] rounded-lg border-2 border-dashed transition-colors {dragOverZone === 'featured' ? 'border-blue-400 bg-blue-50' : 'border-gray-300 bg-white'}"
ondragover={(e: DragEvent) => handleDragOver(e, 'featured')}
ondragleave={handleDragLeave}
ondrop={(e: DragEvent) => handleDrop(e, 'featured')}
role="region"
aria-label="Fremhevet-sone"
>
{#if featuredArticles.length > 0}
<div class="grid grid-cols-1 gap-2 p-2 sm:grid-cols-2 lg:grid-cols-4">
{#each featuredArticles as article (article.edge.id)}
<div
class="group relative cursor-grab rounded-lg border border-blue-200 bg-blue-50 p-3 shadow-sm transition-shadow hover:shadow-md active:cursor-grabbing {draggedArticle?.edge.id === article.edge.id ? 'opacity-50' : ''}"
draggable="true"
ondragstart={(e: DragEvent) => handleDragStart(e, article)}
ondragend={handleDragEnd}
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<h3 class="text-sm font-semibold text-gray-900">{articleTitle(article.node)}</h3>
{#if article.publishAt}
<p class="mt-1 text-xs text-gray-400">{formatDate(article.publishAt)}</p>
{/if}
</div>
<div class="flex shrink-0 items-center gap-1">
<button
onclick={() => togglePin(article)}
class="rounded p-1 text-xs transition-colors {article.pinned ? 'bg-blue-200 text-blue-800' : 'text-gray-400 hover:bg-gray-100 hover:text-gray-600'}"
title={article.pinned ? 'Fjern pin' : 'Pin'}
>
{article.pinned ? '&#128204;' : '&#128392;'}
</button>
<button
onclick={() => moveToSlot(article, 'hero')}
class="rounded p-1 text-xs text-gray-400 hover:bg-gray-100 hover:text-gray-600"
title="Flytt til hero"
>
&#8593;
</button>
<button
onclick={() => moveToSlot(article, null)}
class="rounded p-1 text-xs text-gray-400 hover:bg-gray-100 hover:text-gray-600"
title="Flytt til strøm"
>
&#8595;
</button>
</div>
</div>
</div>
{/each}
</div>
{:else}
<div class="flex items-center justify-center p-6 text-sm text-gray-400">
Dra artikler hit for fremheving
</div>
{/if}
</div>
</section>
<!-- STREAM ZONE -->
<section>
<h2 class="mb-2 text-sm font-semibold text-gray-700">
Strøm ({streamArticles.length})
</h2>
<div
class="min-h-[80px] rounded-lg border-2 border-dashed transition-colors {dragOverZone === 'stream' ? 'border-gray-500 bg-gray-100' : 'border-gray-300 bg-white'}"
ondragover={(e: DragEvent) => handleDragOver(e, 'stream')}
ondragleave={handleDragLeave}
ondrop={(e: DragEvent) => handleDrop(e, 'stream')}
role="region"
aria-label="Strøm-sone"
>
{#if streamArticles.length > 0}
<div class="divide-y divide-gray-100">
{#each streamArticles as article (article.edge.id)}
<div
class="group flex cursor-grab items-center gap-3 px-4 py-3 transition-colors hover:bg-gray-50 active:cursor-grabbing {draggedArticle?.edge.id === article.edge.id ? 'opacity-50' : ''}"
draggable="true"
ondragstart={(e: DragEvent) => handleDragStart(e, article)}
ondragend={handleDragEnd}
>
<div class="min-w-0 flex-1">
<h3 class="text-sm font-medium text-gray-900">{articleTitle(article.node)}</h3>
{#if articleExcerpt(article.node)}
<p class="mt-0.5 text-xs text-gray-500 line-clamp-1">{articleExcerpt(article.node)}</p>
{/if}
</div>
<div class="flex shrink-0 items-center gap-2">
{#if article.publishAt}
<span class="text-xs text-gray-400">{formatDate(article.publishAt)}</span>
{/if}
<button
onclick={() => togglePin(article)}
class="rounded p-1 text-xs transition-colors {article.pinned ? 'bg-gray-200 text-gray-800' : 'text-gray-400 opacity-0 group-hover:opacity-100 hover:bg-gray-100 hover:text-gray-600'}"
title={article.pinned ? 'Fjern pin' : 'Pin'}
>
{article.pinned ? '&#128204;' : '&#128392;'}
</button>
<button
onclick={() => moveToSlot(article, 'hero')}
class="rounded p-1 text-xs text-gray-400 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-gray-100 hover:text-gray-600"
title="Sett som hero"
>
&#9733;
</button>
<button
onclick={() => moveToSlot(article, 'featured')}
class="rounded p-1 text-xs text-gray-400 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-gray-100 hover:text-gray-600"
title="Fremhev"
>
&#8593;
</button>
</div>
</div>
{/each}
</div>
{:else}
<div class="flex items-center justify-center p-6 text-sm text-gray-400">
Ingen artikler i strømmen
</div>
{/if}
</div>
</section>
</div>
<!-- Preview panel -->
{#if showPreview && previewUrl}
<div class="mt-6">
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm">
<div class="flex items-center justify-between border-b border-gray-100 px-4 py-2">
<span class="text-xs font-medium text-gray-500">Forhåndsvisning</span>
<a
href={previewUrl}
target="_blank"
rel="noopener"
class="text-xs text-blue-600 hover:underline"
>
Åpne i ny fane
</a>
</div>
<iframe
src={previewUrl}
title="Forhåndsvisning av forside"
class="h-[600px] w-full border-0"
sandbox="allow-same-origin"
></iframe>
</div>
</div>
{/if}
{/if}
</main>
</div>
<style>
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View file

@ -145,8 +145,7 @@ Uavhengige faser kan fortsatt plukkes.
- [x] 14.3 Forside-rendering: maskinrommet spør PG for hero/featured/strøm (tre indekserte spørringer), appliserer tema-template, rendrer til CAS (statisk modus) eller serverer med in-memory cache (dynamisk modus). `index_mode` og `index_cache_ttl` i trait-konfig. - [x] 14.3 Forside-rendering: maskinrommet spør PG for hero/featured/strøm (tre indekserte spørringer), appliserer tema-template, rendrer til CAS (statisk modus) eller serverer med in-memory cache (dynamisk modus). `index_mode` og `index_cache_ttl` i trait-konfig.
- [x] 14.4 Caddy-ruting for synops.no/pub: Caddy reverse-proxyer til maskinrommet som gjør slug→hash-oppslag og streamer CAS-fil. `Cache-Control: immutable` for artikler. Kategori/arkiv/søk serveres dynamisk av maskinrommet med kortere cache-TTL. - [x] 14.4 Caddy-ruting for synops.no/pub: Caddy reverse-proxyer til maskinrommet som gjør slug→hash-oppslag og streamer CAS-fil. `Cache-Control: immutable` for artikler. Kategori/arkiv/søk serveres dynamisk av maskinrommet med kortere cache-TTL.
- [x] 14.5 Slot-håndtering i maskinrommet: `slot` og `slot_order` i `belongs_to`-edge metadata. Ved ny hero → gammel hero flyttes til strøm. Ved featured over `featured_max` → FIFO tilbake til strøm. `pinned`-flagg forhindrer automatisk fjerning. - [x] 14.5 Slot-håndtering i maskinrommet: `slot` og `slot_order` i `belongs_to`-edge metadata. Ved ny hero → gammel hero flyttes til strøm. Ved featured over `featured_max` → FIFO tilbake til strøm. `pinned`-flagg forhindrer automatisk fjerning.
- [~] 14.6 Forside-admin i frontend: visuell editor for hero/featured/strøm. Drag-and-drop mellom plasser. Pin-knapp. Forhåndsvisning. Oppdaterer edge-metadata via maskinrommet. - [x] 14.6 Forside-admin i frontend: visuell editor for hero/featured/strøm. Drag-and-drop mellom plasser. Pin-knapp. Forhåndsvisning. Oppdaterer edge-metadata via maskinrommet.
> Påbegynt: 2026-03-18T01:17
- [ ] 14.7 Publiseringsflyt i frontend (personlig): publiseringsknapp på noder i samlinger med `publishing`-trait der `require_approval: false`. Forhåndsvisning, slug-editor, bekreftelse. Avpublisering ved fjerning av edge. - [ ] 14.7 Publiseringsflyt i frontend (personlig): publiseringsknapp på noder i samlinger med `publishing`-trait der `require_approval: false`. Forhåndsvisning, slug-editor, bekreftelse. Avpublisering ved fjerning av edge.
- [ ] 14.8 RSS/Atom-feed: samling med `rss`-trait genererer feed automatisk ved publisering/avpublisering. `synops.no/pub/{slug}/feed.xml`. Maks `rss_max_items` (default 50). - [ ] 14.8 RSS/Atom-feed: samling med `rss`-trait genererer feed automatisk ved publisering/avpublisering. `synops.no/pub/{slug}/feed.xml`. Maks `rss_max_items` (default 50).
- [ ] 14.9 Custom domains: bruker registrerer domene i `publishing`-trait. Maskinrommet validerer DNS, Caddy on-demand TLS med validerings-callback. Re-rendring med riktig canonical URL. - [ ] 14.9 Custom domains: bruker registrerer domene i `publishing`-trait. Maskinrommet validerer DNS, Caddy on-demand TLS med validerings-callback. Re-rendring med riktig canonical URL.