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>
349 lines
10 KiB
Svelte
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"
|
|
>
|
|
×
|
|
</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"
|
|
>
|
|
×
|
|
</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"
|
|
>
|
|
×
|
|
</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"
|
|
>
|
|
×
|
|
</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>
|