synops/frontend/src/lib/components/PresentationEditor.svelte
vegard 63630eb55a Fullfører oppgave 14.16: Presentasjonselementer som noder
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>
2026-03-18 02:55:23 +00:00

349 lines
10 KiB
Svelte

<script lang="ts">
import {
createNode,
createEdge,
updateNode,
deleteEdge,
deleteNode,
uploadMedia,
casUrl,
fetchPresentationElements,
type PresentationElement
} from '$lib/api';
interface Props {
/** Artikkelens node-ID */
articleId: string;
/** Auth token */
accessToken: string;
}
let { articleId, accessToken }: Props = $props();
let elements = $state<PresentationElement[]>([]);
let loading = $state(true);
let error = $state('');
let saving = $state(false);
// Nye verdier for opprettelse
let newTitle = $state('');
let newSubtitle = $state('');
let newSummary = $state('');
let newVariant = $state('editorial');
const variantOptions = ['editorial', 'ai', 'social', 'rss'];
// Grupper elementer etter type
const titles = $derived(elements.filter((e) => e.element_type === 'title'));
const subtitles = $derived(elements.filter((e) => e.element_type === 'subtitle'));
const summaries = $derived(elements.filter((e) => e.element_type === 'summary'));
const ogImages = $derived(elements.filter((e) => e.element_type === 'og_image'));
async function loadElements() {
loading = true;
error = '';
try {
const resp = await fetchPresentationElements(accessToken, articleId);
elements = resp.elements;
} catch (e) {
error = e instanceof Error ? e.message : 'Feil ved lasting';
} finally {
loading = false;
}
}
// Last elementer ved oppstart og når articleId endrer seg
$effect(() => {
if (articleId && accessToken) {
loadElements();
}
});
async function createPresentationElement(
type: 'title' | 'subtitle' | 'summary',
value: string,
variant: string
) {
if (!value.trim() || saving) return;
saving = true;
error = '';
try {
// Opprett en content-node med verdien
const isTitle = type === 'title' || type === 'subtitle';
const nodeData = isTitle
? { node_kind: 'content', title: value.trim(), visibility: 'hidden' }
: { node_kind: 'content', content: value.trim(), visibility: 'hidden' };
const { node_id } = await createNode(accessToken, nodeData);
// Opprett edge fra element-noden til artikkelen
await createEdge(accessToken, {
source_id: node_id,
target_id: articleId,
edge_type: type,
metadata: { variant }
});
// Tøm felt og last inn på nytt
if (type === 'title') newTitle = '';
if (type === 'subtitle') newSubtitle = '';
if (type === 'summary') newSummary = '';
await loadElements();
} catch (e) {
error = e instanceof Error ? e.message : 'Feil ved opprettelse';
} finally {
saving = false;
}
}
async function handleImageUpload(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file || saving) return;
saving = true;
error = '';
try {
const result = await uploadMedia(accessToken, {
file,
title: file.name,
visibility: 'hidden'
});
// Opprett edge fra media-noden til artikkelen
await createEdge(accessToken, {
source_id: result.media_node_id,
target_id: articleId,
edge_type: 'og_image',
metadata: { variant: newVariant }
});
await loadElements();
} catch (e) {
error = e instanceof Error ? e.message : 'Feil ved bildeopplasting';
} finally {
saving = false;
input.value = '';
}
}
async function removeElement(el: PresentationElement) {
if (saving) return;
saving = true;
error = '';
try {
// Slett edge og node
await deleteEdge(accessToken, { edge_id: el.edge_id });
await deleteNode(accessToken, el.node_id);
await loadElements();
} catch (e) {
error = e instanceof Error ? e.message : 'Feil ved sletting';
} finally {
saving = false;
}
}
function getVariant(el: PresentationElement): string {
return (el.edge_metadata?.variant as string) ?? 'editorial';
}
function getAbStatus(el: PresentationElement): string {
return (el.edge_metadata?.ab_status as string) ?? '';
}
function getDisplayValue(el: PresentationElement): string {
if (el.element_type === 'og_image') {
const hash = el.metadata?.cas_hash as string;
return hash ? casUrl(hash) : '(bilde)';
}
return el.title ?? el.content ?? '(tom)';
}
</script>
<div class="space-y-4">
{#if loading}
<p class="text-sm text-gray-400">Laster presentasjonselementer...</p>
{:else}
{#if error}
<div class="rounded-lg border border-red-200 bg-red-50 p-2 text-xs text-red-700">
{error}
</div>
{/if}
<!-- Variant-velger -->
<div class="flex items-center gap-2">
<label for="variant-select" class="text-xs font-medium text-gray-500">Variant:</label>
<select
id="variant-select"
bind:value={newVariant}
class="rounded border border-gray-300 px-2 py-1 text-xs"
>
{#each variantOptions as v}
<option value={v}>{v}</option>
{/each}
</select>
</div>
<!-- Tittel -->
<fieldset class="rounded-lg border border-gray-200 p-3">
<legend class="px-1 text-xs font-medium text-gray-500">Publisert tittel</legend>
{#each titles as el}
<div class="mb-2 flex items-center gap-2 text-sm">
<span class="flex-1 truncate">{getDisplayValue(el)}</span>
<span class="rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-500"
>{getVariant(el)}</span
>
{#if getAbStatus(el)}
<span
class="rounded px-1.5 py-0.5 text-xs"
class:bg-green-100={getAbStatus(el) === 'winner'}
class:text-green-700={getAbStatus(el) === 'winner'}
class:bg-yellow-100={getAbStatus(el) === 'testing'}
class:text-yellow-700={getAbStatus(el) === 'testing'}
class:bg-gray-100={getAbStatus(el) === 'retired'}
class:text-gray-500={getAbStatus(el) === 'retired'}>{getAbStatus(el)}</span
>
{/if}
<button
onclick={() => removeElement(el)}
disabled={saving}
class="text-red-400 hover:text-red-600"
aria-label="Fjern"
>
&times;
</button>
</div>
{/each}
<div class="flex gap-2">
<input
type="text"
bind:value={newTitle}
placeholder="Skriv publisert tittel..."
disabled={saving}
class="flex-1 rounded border border-gray-300 px-2 py-1 text-sm focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 focus:outline-none"
/>
<button
onclick={() => createPresentationElement('title', newTitle, newVariant)}
disabled={saving || !newTitle.trim()}
class="rounded bg-emerald-600 px-3 py-1 text-xs font-medium text-white hover:bg-emerald-700 disabled:opacity-40"
>
Legg til
</button>
</div>
</fieldset>
<!-- Undertittel -->
<fieldset class="rounded-lg border border-gray-200 p-3">
<legend class="px-1 text-xs font-medium text-gray-500">Undertittel</legend>
{#each subtitles as el}
<div class="mb-2 flex items-center gap-2 text-sm">
<span class="flex-1 truncate">{getDisplayValue(el)}</span>
<span class="rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-500"
>{getVariant(el)}</span
>
<button
onclick={() => removeElement(el)}
disabled={saving}
class="text-red-400 hover:text-red-600"
aria-label="Fjern"
>
&times;
</button>
</div>
{/each}
<div class="flex gap-2">
<input
type="text"
bind:value={newSubtitle}
placeholder="Skriv undertittel..."
disabled={saving}
class="flex-1 rounded border border-gray-300 px-2 py-1 text-sm focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 focus:outline-none"
/>
<button
onclick={() => createPresentationElement('subtitle', newSubtitle, newVariant)}
disabled={saving || !newSubtitle.trim()}
class="rounded bg-emerald-600 px-3 py-1 text-xs font-medium text-white hover:bg-emerald-700 disabled:opacity-40"
>
Legg til
</button>
</div>
</fieldset>
<!-- Ingress / Summary -->
<fieldset class="rounded-lg border border-gray-200 p-3">
<legend class="px-1 text-xs font-medium text-gray-500">Ingress</legend>
{#each summaries as el}
<div class="mb-2 flex items-center gap-2 text-sm">
<span class="flex-1 truncate">{getDisplayValue(el)}</span>
<span class="rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-500"
>{getVariant(el)}</span
>
<button
onclick={() => removeElement(el)}
disabled={saving}
class="text-red-400 hover:text-red-600"
aria-label="Fjern"
>
&times;
</button>
</div>
{/each}
<div class="flex gap-2">
<textarea
bind:value={newSummary}
placeholder="Skriv ingress (1-2 setninger)..."
disabled={saving}
rows="2"
class="flex-1 rounded border border-gray-300 px-2 py-1 text-sm focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 focus:outline-none"
></textarea>
<button
onclick={() => createPresentationElement('summary', newSummary, newVariant)}
disabled={saving || !newSummary.trim()}
class="self-end rounded bg-emerald-600 px-3 py-1 text-xs font-medium text-white hover:bg-emerald-700 disabled:opacity-40"
>
Legg til
</button>
</div>
</fieldset>
<!-- OG-bilde -->
<fieldset class="rounded-lg border border-gray-200 p-3">
<legend class="px-1 text-xs font-medium text-gray-500">OG-bilde (forside/deling)</legend>
{#each ogImages as el}
<div class="mb-2 flex items-center gap-2">
{#if el.metadata?.cas_hash}
<img
src={casUrl(el.metadata.cas_hash as string)}
alt="OG-bilde"
class="h-12 w-20 rounded border object-cover"
/>
{/if}
<span class="flex-1 text-xs text-gray-500">{getVariant(el)}</span>
<button
onclick={() => removeElement(el)}
disabled={saving}
class="text-red-400 hover:text-red-600"
aria-label="Fjern"
>
&times;
</button>
</div>
{/each}
<div>
<input
type="file"
accept="image/*"
onchange={handleImageUpload}
disabled={saving}
class="text-xs text-gray-500 file:mr-2 file:rounded file:border-0 file:bg-emerald-50 file:px-3 file:py-1 file:text-xs file:font-medium file:text-emerald-700 hover:file:bg-emerald-100"
/>
</div>
</fieldset>
{#if elements.length > 1}
<p class="text-xs text-gray-400">
Flere varianter av samme type aktiverer automatisk A/B-testing.
</p>
{/if}
{/if}
</div>