Publisert tittel, ingress, OG-bilde og undertittel er nå egne noder koblet til artikler via title/subtitle/summary/og_image-edges. Rendering bruker presentasjonselementer med fallback til artikkelfelt. Backend: - Ny query: GET /query/presentation_elements?article_id=... - render_article_to_cas henter presentasjonselementer via edges - fetch_article + fetch_index_articles bruker pres.elementer - Batch-henting for forsideartikler (én SQL-spørring) - ArticleData utvides med subtitle + og_image - Alle fire temaer viser subtitle og OG-bilde - SEO og_image-tag fylles fra presentasjonselement Frontend: - PresentationEditor.svelte: opprett/rediger tittel, undertittel, ingress, OG-bilde med variantvelger (editorial/ai/social/rss) - Integrert i PublishDialog via <details>-seksjon - API-klient: fetchPresentationElements(), deleteNode() Grunnlag for A/B-testing (oppgave 14.17): edge-metadata støtter ab_status/impressions/clicks/ctr, best_of() prioriterer winner > testing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
194 lines
5.5 KiB
Svelte
194 lines
5.5 KiB
Svelte
<script lang="ts">
|
|
import type { Node } from '$lib/spacetime';
|
|
import { createEdge } from '$lib/api';
|
|
import PresentationEditor from './PresentationEditor.svelte';
|
|
|
|
interface Props {
|
|
/** The content node to publish */
|
|
node: Node;
|
|
/** The target publishing collection */
|
|
collection: Node;
|
|
/** Publishing trait config */
|
|
pubConfig: Record<string, unknown>;
|
|
/** Auth token */
|
|
accessToken: string;
|
|
/** Close callback */
|
|
onclose: () => void;
|
|
}
|
|
|
|
let { node, collection, pubConfig, accessToken, onclose }: Props = $props();
|
|
|
|
const collectionSlug = $derived((pubConfig.slug as string) ?? '');
|
|
const theme = $derived((pubConfig.theme as string) ?? 'default');
|
|
|
|
/** Auto-generate slug from title */
|
|
function slugify(text: string): string {
|
|
return text
|
|
.toLowerCase()
|
|
.replace(/[æ]/g, 'ae')
|
|
.replace(/[ø]/g, 'oe')
|
|
.replace(/[å]/g, 'aa')
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 80);
|
|
}
|
|
|
|
let articleSlug = $state(slugify(node.title ?? 'uten-tittel'));
|
|
let isPublishing = $state(false);
|
|
let error = $state<string | null>(null);
|
|
let showPreview = $state(false);
|
|
|
|
const shortId = $derived(node.id.slice(0, 8));
|
|
const publicUrl = $derived(
|
|
collectionSlug ? `/api/pub/${collectionSlug}/${shortId}` : ''
|
|
);
|
|
const previewUrl = $derived(
|
|
collectionSlug ? `/api/pub/${collectionSlug}/preview/${theme}` : ''
|
|
);
|
|
|
|
async function handlePublish() {
|
|
if (isPublishing) return;
|
|
isPublishing = true;
|
|
error = null;
|
|
|
|
try {
|
|
await createEdge(accessToken, {
|
|
source_id: node.id,
|
|
target_id: collection.id,
|
|
edge_type: 'belongs_to',
|
|
metadata: {}
|
|
});
|
|
onclose();
|
|
} catch (err) {
|
|
console.error('Publiseringsfeil:', err);
|
|
error = err instanceof Error ? err.message : 'Ukjent feil ved publisering';
|
|
} finally {
|
|
isPublishing = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<!-- Backdrop -->
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
|
onkeydown={(e) => { if (e.key === 'Escape') onclose(); }}
|
|
>
|
|
<!-- Dialog -->
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<div
|
|
class="w-full max-w-lg rounded-xl bg-white shadow-2xl"
|
|
onclick={(e) => e.stopPropagation()}
|
|
role="dialog"
|
|
aria-label="Publiser artikkel"
|
|
>
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between border-b border-gray-100 px-5 py-4">
|
|
<h2 class="text-base font-semibold text-gray-900">Publiser artikkel</h2>
|
|
<button
|
|
onclick={onclose}
|
|
class="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
|
aria-label="Lukk"
|
|
>
|
|
×
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Body -->
|
|
<div class="space-y-4 px-5 py-4">
|
|
{#if error}
|
|
<div class="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
|
{error}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Article info -->
|
|
<div>
|
|
<h3 class="text-sm font-medium text-gray-900">{node.title || 'Uten tittel'}</h3>
|
|
{#if node.content}
|
|
<p class="mt-1 text-xs text-gray-500">{node.content.slice(0, 200)}{node.content.length > 200 ? '...' : ''}</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Target collection -->
|
|
<div>
|
|
<dt class="text-xs font-medium text-gray-500">Publiseres i</dt>
|
|
<dd class="mt-0.5 text-sm text-gray-900">{collection.title ?? 'Samling'}</dd>
|
|
</div>
|
|
|
|
<!-- Slug editor -->
|
|
<div>
|
|
<label for="article-slug" class="block text-xs font-medium text-gray-500">
|
|
Artikkel-slug
|
|
</label>
|
|
<div class="mt-1 flex items-center gap-2">
|
|
<input
|
|
id="article-slug"
|
|
type="text"
|
|
bind:value={articleSlug}
|
|
class="block w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-mono text-gray-900 focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 focus:outline-none"
|
|
placeholder="artikkel-slug"
|
|
/>
|
|
</div>
|
|
{#if collectionSlug}
|
|
<p class="mt-1 text-xs text-gray-400">
|
|
URL: synops.no/pub/{collectionSlug}/{shortId}
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Presentasjonselementer -->
|
|
<details class="rounded-lg border border-gray-200" open>
|
|
<summary class="cursor-pointer px-3 py-2 text-xs font-medium text-gray-700">
|
|
Presentasjonselementer (tittel, ingress, bilde)
|
|
</summary>
|
|
<div class="border-t border-gray-100 px-3 py-3">
|
|
<PresentationEditor articleId={node.id} {accessToken} />
|
|
</div>
|
|
</details>
|
|
|
|
<!-- Theme info -->
|
|
<div class="flex items-center gap-4 text-xs text-gray-500">
|
|
<span>Tema: <span class="font-medium text-gray-700">{theme}</span></span>
|
|
{#if previewUrl}
|
|
<button
|
|
onclick={() => { showPreview = !showPreview; }}
|
|
class="text-emerald-600 hover:underline"
|
|
>
|
|
{showPreview ? 'Skjul forhåndsvisning' : 'Vis forhåndsvisning'}
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Preview iframe -->
|
|
{#if showPreview && previewUrl}
|
|
<div class="overflow-hidden rounded-lg border border-gray-200">
|
|
<iframe
|
|
src={previewUrl}
|
|
title="Tema-forhåndsvisning"
|
|
class="h-[300px] w-full border-0"
|
|
sandbox="allow-same-origin"
|
|
></iframe>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="flex items-center justify-end gap-3 border-t border-gray-100 px-5 py-4">
|
|
<button
|
|
onclick={onclose}
|
|
class="rounded-lg px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100"
|
|
disabled={isPublishing}
|
|
>
|
|
Avbryt
|
|
</button>
|
|
<button
|
|
onclick={handlePublish}
|
|
class="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
|
|
disabled={isPublishing}
|
|
>
|
|
{isPublishing ? 'Publiserer...' : 'Publiser'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|