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:
parent
9fefa0a8c2
commit
8a4df2c237
4 changed files with 569 additions and 2 deletions
|
|
@ -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)}`, {
|
||||||
|
|
|
||||||
|
|
@ -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'} · {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>
|
||||||
|
|
|
||||||
519
frontend/src/routes/collection/[id]/forside/+page.svelte
Normal file
519
frontend/src/routes/collection/[id]/forside/+page.svelte
Normal 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">← 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 ? '📌' : '🖈'}
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</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 ? '📌' : '🖈'}
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</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 ? '📌' : '🖈'}
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</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>
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue