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>
This commit is contained in:
vegard 2026-03-18 02:55:23 +00:00
parent 18642d5e79
commit 63630eb55a
12 changed files with 802 additions and 10 deletions

View file

@ -248,6 +248,13 @@ samtale, ikke en workflow-tilstand.
## Presentasjonselementer er noder ## Presentasjonselementer er noder
> **Status:** Implementert i oppgave 14.16. Backend: query-endpoint
> (`/query/presentation_elements`), rendering bruker presentasjonselementer
> (title, subtitle, summary, og_image) med fallback til artikkelnoden.
> Frontend: PresentationEditor-komponent integrert i PublishDialog.
> A/B-testing (automatisk rotasjon, impression-logging) er spesifisert
> men ikke implementert (planlagt oppgave 14.17).
En ingress er en tekst. En overskrift er en tekst. Et forsidebilde er En ingress er en tekst. En overskrift er en tekst. Et forsidebilde er
et bilde. Alt som vises *om* en artikkel på forsiden er en *ting med et bilde. Alt som vises *om* en artikkel på forsiden er en *ting med
egen forfatter, eget tidspunkt, og potensielt flere varianter*. Det egen forfatter, eget tidspunkt, og potensielt flere varianter*. Det

View file

@ -567,6 +567,51 @@ export async function audioInfo(accessToken: string, hash: string): Promise<Audi
return res.json(); return res.json();
} }
// =============================================================================
// Presentasjonselementer
// =============================================================================
export interface PresentationElement {
node_id: string;
edge_id: string;
element_type: string;
title: string | null;
content: string | null;
node_kind: string;
metadata: Record<string, unknown>;
edge_metadata: Record<string, unknown>;
created_at: string;
}
export interface PresentationElementsResponse {
article_id: string;
elements: PresentationElement[];
}
/** Hent presentasjonselementer (tittel, undertittel, ingress, OG-bilde) for en artikkel. */
export async function fetchPresentationElements(
accessToken: string,
articleId: string
): Promise<PresentationElementsResponse> {
const res = await fetch(
`${BASE_URL}/query/presentation_elements?article_id=${encodeURIComponent(articleId)}`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
if (!res.ok) {
const body = await res.text();
throw new Error(`presentation_elements failed (${res.status}): ${body}`);
}
return res.json();
}
/** Slett en node (brukes for å fjerne presentasjonselementer). */
export function deleteNode(
accessToken: string,
nodeId: string
): Promise<{ deleted: boolean }> {
return post(accessToken, '/intentions/delete_node', { node_id: nodeId });
}
/** Anvend brukerens per-segment-valg etter re-transkripsjon. */ /** Anvend brukerens per-segment-valg etter re-transkripsjon. */
export function resolveRetranscription( export function resolveRetranscription(
accessToken: string, accessToken: string,

View file

@ -0,0 +1,349 @@
<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>

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { Node } from '$lib/spacetime'; import type { Node } from '$lib/spacetime';
import { createEdge } from '$lib/api'; import { createEdge } from '$lib/api';
import PresentationEditor from './PresentationEditor.svelte';
interface Props { interface Props {
/** The content node to publish */ /** The content node to publish */
@ -136,6 +137,16 @@
{/if} {/if}
</div> </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 --> <!-- Theme info -->
<div class="flex items-center gap-4 text-xs text-gray-500"> <div class="flex items-center gap-4 text-xs text-gray-500">
<span>Tema: <span class="font-medium text-gray-700">{theme}</span></span> <span>Tema: <span class="font-medium text-gray-700">{theme}</span></span>

View file

@ -178,6 +178,7 @@ async fn main() {
.route("/intentions/close_communication", post(intentions::close_communication)) .route("/intentions/close_communication", post(intentions::close_communication))
.route("/query/aliases", get(queries::query_aliases)) .route("/query/aliases", get(queries::query_aliases))
.route("/query/graph", get(queries::query_graph)) .route("/query/graph", get(queries::query_graph))
.route("/query/presentation_elements", get(queries::query_presentation_elements))
.route("/query/transcription_versions", get(queries::query_transcription_versions)) .route("/query/transcription_versions", get(queries::query_transcription_versions))
.route("/query/segments_version", get(queries::query_segments_version)) .route("/query/segments_version", get(queries::query_segments_version))
.route("/intentions/audio_analyze", post(intentions::audio_analyze)) .route("/intentions/audio_analyze", post(intentions::audio_analyze))

View file

@ -121,11 +121,14 @@ fn build_seo_data(
let json_ld = build_json_ld(article, collection_title, canonical_url); let json_ld = build_json_ld(article, collection_title, canonical_url);
// Bygg OG-image URL fra CAS-hash hvis tilgjengelig
let og_image = article.og_image.as_ref().map(|hash| format!("/cas/{hash}"));
SeoData { SeoData {
og_title: article.title.clone(), og_title: article.title.clone(),
description, description,
canonical_url: canonical_url.to_string(), canonical_url: canonical_url.to_string(),
og_image: None, og_image,
json_ld, json_ld,
} }
} }
@ -297,8 +300,10 @@ pub struct ArticleData {
pub id: String, pub id: String,
pub short_id: String, pub short_id: String,
pub title: String, pub title: String,
pub subtitle: Option<String>,
pub content: String, pub content: String,
pub summary: Option<String>, pub summary: Option<String>,
pub og_image: Option<String>,
pub published_at: String, pub published_at: String,
pub published_at_short: String, pub published_at_short: String,
} }
@ -501,16 +506,28 @@ pub async fn render_article_to_cas(
content.unwrap_or_default() content.unwrap_or_default()
}; };
let article_title = title.unwrap_or_else(|| "Uten tittel".to_string());
let short_id = id.to_string()[..8].to_string(); let short_id = id.to_string()[..8].to_string();
let summary_text = truncate(&article_html.replace("<p>", "").replace("</p>", " ").replace('\n', " "), 200);
// 3b. Hent presentasjonselementer (title/subtitle/summary/og_image/og_description)
let pres = fetch_presentation_elements(db, node_id).await
.map_err(|e| format!("Feil ved henting av presentasjonselementer: {e}"))?;
// Bruk presentasjonselement-tittel hvis tilgjengelig, ellers artikkelens interne tittel
let article_title = pres.best_title()
.unwrap_or_else(|| title.unwrap_or_else(|| "Uten tittel".to_string()));
// Bruk presentasjonselement-ingress hvis tilgjengelig, ellers auto-generert
let summary_text = pres.best_summary()
.unwrap_or_else(|| truncate(&article_html.replace("<p>", "").replace("</p>", " ").replace('\n', " "), 200));
let article_data = ArticleData { let article_data = ArticleData {
id: id.to_string(), id: id.to_string(),
short_id: short_id.clone(), short_id: short_id.clone(),
title: article_title, title: article_title,
subtitle: pres.best_subtitle(),
content: article_html, content: article_html,
summary: Some(summary_text), summary: Some(summary_text),
og_image: pres.best_og_image(),
published_at: publish_at.to_rfc3339(), published_at: publish_at.to_rfc3339(),
published_at_short: publish_at.format("%e. %B %Y").to_string(), published_at_short: publish_at.format("%e. %B %Y").to_string(),
}; };
@ -858,17 +875,26 @@ async fn fetch_article(
content.unwrap_or_default() content.unwrap_or_default()
}; };
let summary_text = truncate( // Hent presentasjonselementer
&article_html.replace("<p>", "").replace("</p>", " ").replace('\n', " "), let pres = fetch_presentation_elements(db, id).await?;
200,
); let article_title = pres.best_title()
.unwrap_or_else(|| title.unwrap_or_else(|| "Uten tittel".to_string()));
let summary_text = pres.best_summary()
.unwrap_or_else(|| truncate(
&article_html.replace("<p>", "").replace("</p>", " ").replace('\n', " "),
200,
));
let article = ArticleData { let article = ArticleData {
id: id.to_string(), id: id.to_string(),
short_id: id.to_string()[..8].to_string(), short_id: id.to_string()[..8].to_string(),
title: title.unwrap_or_else(|| "Uten tittel".to_string()), title: article_title,
subtitle: pres.best_subtitle(),
content: article_html, content: article_html,
summary: Some(summary_text), summary: Some(summary_text),
og_image: pres.best_og_image(),
published_at: publish_at.to_rfc3339(), published_at: publish_at.to_rfc3339(),
published_at_short: publish_at.format("%e. %B %Y").to_string(), published_at_short: publish_at.format("%e. %B %Y").to_string(),
}; };
@ -919,8 +945,10 @@ async fn fetch_index_articles_optimized(
id: id.to_string(), id: id.to_string(),
short_id: id.to_string()[..8].to_string(), short_id: id.to_string()[..8].to_string(),
title: title.unwrap_or_else(|| "Uten tittel".to_string()), title: title.unwrap_or_else(|| "Uten tittel".to_string()),
subtitle: None,
content: content.unwrap_or_default(), content: content.unwrap_or_default(),
summary, summary,
og_image: None,
published_at: publish_at.to_rfc3339(), published_at: publish_at.to_rfc3339(),
published_at_short: publish_at.format("%e. %B %Y").to_string(), published_at_short: publish_at.format("%e. %B %Y").to_string(),
} }
@ -1001,9 +1029,203 @@ async fn fetch_index_articles_optimized(
}) })
.collect(); .collect();
// Berik med presentasjonselementer (batch: hent alle i én spørring)
let mut all_ids: Vec<Uuid> = vec![];
if let Some(ref h) = hero {
if let Ok(uid) = h.id.parse::<Uuid>() { all_ids.push(uid); }
}
for a in &featured { if let Ok(uid) = a.id.parse::<Uuid>() { all_ids.push(uid); } }
for a in &stream { if let Ok(uid) = a.id.parse::<Uuid>() { all_ids.push(uid); } }
let pres_map = fetch_presentation_elements_batch(db, &all_ids).await
.unwrap_or_default();
fn enrich(article: &mut ArticleData, pres: &PresentationElements) {
if let Some(t) = pres.best_title() { article.title = t; }
if let Some(s) = pres.best_subtitle() { article.subtitle = Some(s); }
if let Some(s) = pres.best_summary() { article.summary = Some(s); }
if let Some(img) = pres.best_og_image() { article.og_image = Some(img); }
}
let mut hero = hero;
if let Some(ref mut h) = hero {
if let Ok(uid) = h.id.parse::<Uuid>() {
if let Some(pres) = pres_map.get(&uid) { enrich(h, pres); }
}
}
let mut featured = featured;
for a in &mut featured {
if let Ok(uid) = a.id.parse::<Uuid>() {
if let Some(pres) = pres_map.get(&uid) { enrich(a, pres); }
}
}
let mut stream = stream;
for a in &mut stream {
if let Ok(uid) = a.id.parse::<Uuid>() {
if let Some(pres) = pres_map.get(&uid) { enrich(a, pres); }
}
}
Ok((hero, featured, stream)) Ok((hero, featured, stream))
} }
// =============================================================================
// Presentasjonselementer: hent title/subtitle/summary/og_image fra edge-koblede noder
// =============================================================================
/// Presentasjonselementer for en artikkel.
/// Inneholder lister per type — flere varianter støttes for A/B-testing.
struct PresentationElements {
titles: Vec<PresEl>,
subtitles: Vec<PresEl>,
summaries: Vec<PresEl>,
og_images: Vec<PresEl>,
og_descriptions: Vec<PresEl>,
}
struct PresEl {
title: Option<String>,
content: Option<String>,
#[allow(dead_code)]
node_kind: String,
metadata: serde_json::Value,
edge_metadata: serde_json::Value,
}
impl PresEl {
fn ab_status(&self) -> &str {
self.edge_metadata
.get("ab_status")
.and_then(|v| v.as_str())
.unwrap_or("")
}
}
impl PresentationElements {
/// Velg beste variant: "winner" > "testing" > første tilgjengelige
fn best_of(elements: &[PresEl]) -> Option<&PresEl> {
// Prioriter "winner"
if let Some(el) = elements.iter().find(|e| e.ab_status() == "winner") {
return Some(el);
}
// Så "testing" eller de uten ab_status (enkeltvariant)
elements.iter().find(|e| e.ab_status() != "retired")
}
fn best_title(&self) -> Option<String> {
Self::best_of(&self.titles)
.and_then(|el| el.title.clone().or(el.content.clone()))
}
fn best_subtitle(&self) -> Option<String> {
Self::best_of(&self.subtitles)
.and_then(|el| el.title.clone().or(el.content.clone()))
}
fn best_summary(&self) -> Option<String> {
Self::best_of(&self.summaries)
.and_then(|el| el.content.clone().or(el.title.clone()))
}
fn best_og_image(&self) -> Option<String> {
Self::best_of(&self.og_images)
.and_then(|el| el.metadata.get("cas_hash").and_then(|h| h.as_str()).map(|s| s.to_string()))
}
#[allow(dead_code)]
fn best_og_description(&self) -> Option<String> {
Self::best_of(&self.og_descriptions)
.and_then(|el| el.content.clone().or(el.title.clone()))
}
}
/// Hent alle presentasjonselementer for en artikkel.
/// Noder koblet via title/subtitle/summary/og_image/og_description-edges
/// der source_id = elementnode, target_id = artikkel.
async fn fetch_presentation_elements(
db: &PgPool,
article_id: Uuid,
) -> Result<PresentationElements, sqlx::Error> {
let rows: Vec<(String, Option<String>, Option<String>, String, serde_json::Value, serde_json::Value)> = sqlx::query_as(
r#"
SELECT e.edge_type, n.title, n.content, n.node_kind, n.metadata, e.metadata AS edge_metadata
FROM edges e
JOIN nodes n ON n.id = e.source_id
WHERE e.target_id = $1
AND e.edge_type IN ('title', 'subtitle', 'summary', 'og_image', 'og_description')
ORDER BY e.created_at
"#,
)
.bind(article_id)
.fetch_all(db)
.await?;
let mut titles = vec![];
let mut subtitles = vec![];
let mut summaries = vec![];
let mut og_images = vec![];
let mut og_descriptions = vec![];
for (edge_type, title, content, node_kind, metadata, edge_metadata) in rows {
let el = PresEl { title, content, node_kind, metadata, edge_metadata };
match edge_type.as_str() {
"title" => titles.push(el),
"subtitle" => subtitles.push(el),
"summary" => summaries.push(el),
"og_image" => og_images.push(el),
"og_description" => og_descriptions.push(el),
_ => {}
}
}
Ok(PresentationElements { titles, subtitles, summaries, og_images, og_descriptions })
}
/// Hent presentasjonselementer for flere artikler i én spørring.
/// Returnerer et HashMap fra artikkel-ID til PresentationElements.
async fn fetch_presentation_elements_batch(
db: &PgPool,
article_ids: &[Uuid],
) -> Result<HashMap<Uuid, PresentationElements>, sqlx::Error> {
if article_ids.is_empty() {
return Ok(HashMap::new());
}
let rows: Vec<(Uuid, String, Option<String>, Option<String>, String, serde_json::Value, serde_json::Value)> = sqlx::query_as(
r#"
SELECT e.target_id AS article_id, e.edge_type, n.title, n.content, n.node_kind, n.metadata, e.metadata AS edge_metadata
FROM edges e
JOIN nodes n ON n.id = e.source_id
WHERE e.target_id = ANY($1)
AND e.edge_type IN ('title', 'subtitle', 'summary', 'og_image', 'og_description')
ORDER BY e.created_at
"#,
)
.bind(article_ids)
.fetch_all(db)
.await?;
let mut map: HashMap<Uuid, PresentationElements> = HashMap::new();
for (article_id, edge_type, title, content, node_kind, metadata, edge_metadata) in rows {
let el = PresEl { title, content, node_kind, metadata, edge_metadata };
let pres = map.entry(article_id).or_insert_with(|| PresentationElements {
titles: vec![], subtitles: vec![], summaries: vec![],
og_images: vec![], og_descriptions: vec![],
});
match edge_type.as_str() {
"title" => pres.titles.push(el),
"subtitle" => pres.subtitles.push(el),
"summary" => pres.summaries.push(el),
"og_image" => pres.og_images.push(el),
"og_description" => pres.og_descriptions.push(el),
_ => {}
}
}
Ok(map)
}
fn truncate(s: &str, max: usize) -> String { fn truncate(s: &str, max: usize) -> String {
if s.len() <= max { if s.len() <= max {
return s.to_string(); return s.to_string();
@ -1248,11 +1470,13 @@ pub async fn preview_theme(
id: format!("00000000-0000-0000-0000-00000000000{i}"), id: format!("00000000-0000-0000-0000-00000000000{i}"),
short_id: format!("0000000{i}"), short_id: format!("0000000{i}"),
title: format!("Eksempelartikkel {i}"), title: format!("Eksempelartikkel {i}"),
subtitle: None,
content: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. \ content: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. \
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. \ Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. \
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris." Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris."
.to_string(), .to_string(),
summary: Some("Lorem ipsum dolor sit amet, consectetur adipiscing elit.".to_string()), summary: Some("Lorem ipsum dolor sit amet, consectetur adipiscing elit.".to_string()),
og_image: None,
published_at: "2026-03-18T12:00:00Z".to_string(), published_at: "2026-03-18T12:00:00Z".to_string(),
published_at_short: "18. mars 2026".to_string(), published_at_short: "18. mars 2026".to_string(),
}) })
@ -1700,8 +1924,10 @@ pub async fn serve_category(
id: id.to_string(), id: id.to_string(),
short_id: id.to_string()[..8].to_string(), short_id: id.to_string()[..8].to_string(),
title: title.unwrap_or_else(|| "Uten tittel".to_string()), title: title.unwrap_or_else(|| "Uten tittel".to_string()),
subtitle: None,
content: content.unwrap_or_default(), content: content.unwrap_or_default(),
summary, summary,
og_image: None,
published_at: publish_at.to_rfc3339(), published_at: publish_at.to_rfc3339(),
published_at_short: publish_at.format("%e. %B %Y").to_string(), published_at_short: publish_at.format("%e. %B %Y").to_string(),
} }
@ -1836,8 +2062,10 @@ pub async fn serve_archive(
id: id.to_string(), id: id.to_string(),
short_id: id.to_string()[..8].to_string(), short_id: id.to_string()[..8].to_string(),
title: title.unwrap_or_else(|| "Uten tittel".to_string()), title: title.unwrap_or_else(|| "Uten tittel".to_string()),
subtitle: None,
content: content.unwrap_or_default(), content: content.unwrap_or_default(),
summary, summary,
og_image: None,
published_at: publish_at.to_rfc3339(), published_at: publish_at.to_rfc3339(),
published_at_short: publish_at.format("%e. %B %Y").to_string(), published_at_short: publish_at.format("%e. %B %Y").to_string(),
}; };
@ -2004,8 +2232,10 @@ pub async fn serve_search(
id: id.to_string(), id: id.to_string(),
short_id: id.to_string()[..8].to_string(), short_id: id.to_string()[..8].to_string(),
title: title.unwrap_or_else(|| "Uten tittel".to_string()), title: title.unwrap_or_else(|| "Uten tittel".to_string()),
subtitle: None,
content: content.unwrap_or_default(), content: content.unwrap_or_default(),
summary, summary,
og_image: None,
published_at: publish_at.to_rfc3339(), published_at: publish_at.to_rfc3339(),
published_at_short: publish_at.format("%e. %B %Y").to_string(), published_at_short: publish_at.format("%e. %B %Y").to_string(),
} }

View file

@ -1194,6 +1194,148 @@ async fn run_query_graph(
}) })
} }
// =============================================================================
// GET /query/presentation_elements — presentasjonselementer for en artikkel
// =============================================================================
//
// Henter noder koblet til en artikkel via title/subtitle/summary/og_image/
// og_description-edges. Disse er separate noder med variantmetadata.
// Ref: docs/concepts/publisering.md § "Presentasjonselementer"
#[derive(Deserialize)]
pub struct QueryPresentationRequest {
/// Artikkelens node-ID.
pub article_id: Uuid,
}
#[derive(Serialize)]
pub struct PresentationElement {
/// Presentasjonselementets node-ID.
pub node_id: Uuid,
/// Edge-ID (for oppdatering/sletting).
pub edge_id: Uuid,
/// Edge-type: title, subtitle, summary, og_image, og_description.
pub element_type: String,
/// Nodens tittel (brukt for title/subtitle).
pub title: Option<String>,
/// Nodens innhold (brukt for summary/og_description).
pub content: Option<String>,
/// Node-kind (content eller media).
pub node_kind: String,
/// Node metadata (inkl. cas_hash for media).
pub metadata: serde_json::Value,
/// Edge metadata (variant, language, ab_status etc.).
pub edge_metadata: serde_json::Value,
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Serialize)]
pub struct QueryPresentationResponse {
pub article_id: Uuid,
pub elements: Vec<PresentationElement>,
}
/// GET /query/presentation_elements?article_id=...
///
/// Henter alle presentasjonselementer (tittel, undertittel, ingress,
/// OG-bilde, OG-beskrivelse) knyttet til en artikkel.
/// Returnerer nodene med edge-metadata (variant, ab_status etc.).
pub async fn query_presentation_elements(
State(state): State<AppState>,
user: AuthUser,
axum::extract::Query(params): axum::extract::Query<QueryPresentationRequest>,
) -> Result<Json<QueryPresentationResponse>, (StatusCode, Json<ErrorResponse>)> {
// Verifiser tilgang til artikkelen via RLS
let mut tx = state.db.begin().await.map_err(|e| {
tracing::error!(error = %e, "Transaksjon feilet");
internal_error("Databasefeil")
})?;
set_rls_context(&mut tx, user.node_id).await.map_err(|e| {
tracing::error!(error = %e, "RLS-kontekst feilet");
internal_error("Databasefeil")
})?;
let exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1)",
)
.bind(params.article_id)
.fetch_one(&mut *tx)
.await
.map_err(|e| {
tracing::error!(error = %e, "Tilgangssjekk feilet");
internal_error("Databasefeil")
})?;
tx.commit().await.map_err(|e| {
tracing::error!(error = %e, "Commit feilet");
internal_error("Databasefeil")
})?;
if !exists {
return Err((
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: format!("Artikkel {} finnes ikke eller du har ikke tilgang", params.article_id),
}),
));
}
// Hent presentasjonselementer: noder med title/subtitle/summary/og_image/og_description-edge
// til artikkelen (source_id = element-node, target_id = article)
let rows = sqlx::query_as::<_, (
Uuid, // n.id
Uuid, // e.id
String, // e.edge_type
Option<String>, // n.title
Option<String>, // n.content
String, // n.node_kind
serde_json::Value, // n.metadata
serde_json::Value, // e.metadata
chrono::DateTime<chrono::Utc>, // n.created_at
)>(
r#"
SELECT
n.id, e.id AS edge_id, e.edge_type,
n.title, n.content, n.node_kind, n.metadata,
e.metadata AS edge_metadata, n.created_at
FROM edges e
JOIN nodes n ON n.id = e.source_id
WHERE e.target_id = $1
AND e.edge_type IN ('title', 'subtitle', 'summary', 'og_image', 'og_description')
ORDER BY e.edge_type, e.created_at
"#,
)
.bind(params.article_id)
.fetch_all(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Feil ved henting av presentasjonselementer");
internal_error("Databasefeil ved henting av presentasjonselementer")
})?;
let elements: Vec<PresentationElement> = rows
.into_iter()
.map(|(node_id, edge_id, element_type, title, content, node_kind, metadata, edge_metadata, created_at)| {
PresentationElement {
node_id,
edge_id,
element_type,
title,
content,
node_kind,
metadata,
edge_metadata,
created_at,
}
})
.collect();
Ok(Json(QueryPresentationResponse {
article_id: params.article_id,
elements,
}))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View file

@ -69,7 +69,9 @@
{% block content %} {% block content %}
<article class="article"> <article class="article">
<div class="article__main"> <div class="article__main">
{% if article.og_image %}<img src="/cas/{{ article.og_image }}" alt="{{ article.title }}" style="width:100%;max-height:400px;object-fit:cover;margin-bottom:1.5rem;">{% endif %}
<h1 class="article__title">{{ article.title }}</h1> <h1 class="article__title">{{ article.title }}</h1>
{% if article.subtitle %}<p style="font-size:1.15rem;color:var(--color-muted);margin-bottom:0.5rem;font-family:var(--font-heading);">{{ article.subtitle }}</p>{% endif %}
<div class="article__meta">Publisert {{ article.published_at_short }}</div> <div class="article__meta">Publisert {{ article.published_at_short }}</div>
<div class="article__content">{{ article.content | safe }}</div> <div class="article__content">{{ article.content | safe }}</div>
<a class="article__back" href="{{ base_url }}">&larr; Tilbake til forsiden</a> <a class="article__back" href="{{ base_url }}">&larr; Tilbake til forsiden</a>

View file

@ -48,7 +48,9 @@
{% block content %} {% block content %}
<article class="blog-article"> <article class="blog-article">
{% if article.og_image %}<img src="/cas/{{ article.og_image }}" alt="{{ article.title }}" style="width:100%;max-height:400px;object-fit:cover;border-radius:0.5rem;margin-bottom:1.5rem;">{% endif %}
<h1 class="blog-article__title">{{ article.title }}</h1> <h1 class="blog-article__title">{{ article.title }}</h1>
{% if article.subtitle %}<p style="font-size:1.2rem;color:var(--color-muted);margin-bottom:0.5rem;font-family:var(--font-heading);">{{ article.subtitle }}</p>{% endif %}
<div class="blog-article__meta">{{ article.published_at_short }}</div> <div class="blog-article__meta">{{ article.published_at_short }}</div>
<div class="blog-article__content"> <div class="blog-article__content">
{{ article.content | safe }} {{ article.content | safe }}

View file

@ -61,8 +61,10 @@
{% block content %} {% block content %}
<article class="mag-article"> <article class="mag-article">
{% if article.og_image %}<img src="/cas/{{ article.og_image }}" alt="{{ article.title }}" style="width:100%;max-height:500px;object-fit:cover;margin-bottom:0;">{% endif %}
<header class="mag-article__header"> <header class="mag-article__header">
<h1 class="mag-article__title">{{ article.title }}</h1> <h1 class="mag-article__title">{{ article.title }}</h1>
{% if article.subtitle %}<p style="font-size:1.25rem;color:var(--color-muted);margin-top:0.5rem;font-family:var(--font-heading);">{{ article.subtitle }}</p>{% endif %}
<div class="mag-article__meta">Publisert {{ article.published_at_short }}</div> <div class="mag-article__meta">Publisert {{ article.published_at_short }}</div>
</header> </header>
<div class="mag-article__content"> <div class="mag-article__content">

View file

@ -55,7 +55,9 @@
{% block content %} {% block content %}
<article class="journal-article"> <article class="journal-article">
{% if article.og_image %}<div style="text-align:center;margin-bottom:1.5rem;"><img src="/cas/{{ article.og_image }}" alt="{{ article.title }}" style="max-width:100%;max-height:400px;object-fit:cover;"></div>{% endif %}
<h1 class="journal-article__title">{{ article.title }}</h1> <h1 class="journal-article__title">{{ article.title }}</h1>
{% if article.subtitle %}<p style="font-size:1.1rem;color:var(--color-muted);text-align:center;margin-bottom:0.5rem;font-family:var(--font-heading);">{{ article.subtitle }}</p>{% endif %}
<div class="journal-article__meta">Publisert {{ article.published_at_short }}</div> <div class="journal-article__meta">Publisert {{ article.published_at_short }}</div>
<div class="journal-article__content"> <div class="journal-article__content">
{{ article.content | safe }} {{ article.content | safe }}

View file

@ -158,8 +158,7 @@ Uavhengige faser kan fortsatt plukkes.
- [x] 14.13 Redaksjonell samtale: ved innsending kan redaktør opprette kommunikasjonsnode knyttet til artikkel + forfatter for diskusjon/feedback utover kort notat i edge-metadata. - [x] 14.13 Redaksjonell samtale: ved innsending kan redaktør opprette kommunikasjonsnode knyttet til artikkel + forfatter for diskusjon/feedback utover kort notat i edge-metadata.
- [x] 14.14 Bulk re-rendering: batch-jobb via jobbkø ved temaendring. Paginert (100 artikler om gangen), oppdaterer `renderer_version`. Artikler serveres med gammelt tema til re-rendret. - [x] 14.14 Bulk re-rendering: batch-jobb via jobbkø ved temaendring. Paginert (100 artikler om gangen), oppdaterer `renderer_version`. Artikler serveres med gammelt tema til re-rendret.
- [x] 14.15 Dynamiske sider: kategori-sider (filtrert på tag-edges), arkiv (kronologisk med månedsgruppering), søk (PG fulltekst). Alle paginerte, cachet i maskinrommet. Om-side som statisk CAS-node. - [x] 14.15 Dynamiske sider: kategori-sider (filtrert på tag-edges), arkiv (kronologisk med månedsgruppering), søk (PG fulltekst). Alle paginerte, cachet i maskinrommet. Om-side som statisk CAS-node.
- [~] 14.16 Presentasjonselementer som noder: publisert tittel, ingress, OG-bilde, undertittel er egne noder med `title`/`summary`/`og_image`-edges til artikkelen. Frontend for å opprette/redigere varianter. Ref: `docs/concepts/publisering.md` § "Presentasjonselementer". - [x] 14.16 Presentasjonselementer som noder: publisert tittel, ingress, OG-bilde, undertittel er egne noder med `title`/`summary`/`og_image`-edges til artikkelen. Frontend for å opprette/redigere varianter. Ref: `docs/concepts/publisering.md` § "Presentasjonselementer".
> Påbegynt: 2026-03-18T02:42
- [ ] 14.17 A/B-testing: maskinrommet roterer varianter ved forside-rendering, logger impressions/klikk per variant, normaliserer CTR mot tidspunkt-baseline. Etter statistisk signifikans markeres vinner. Redaktør kan overstyre. Edge-metadata: `ab_status`, `impressions`, `clicks`, `ctr`. - [ ] 14.17 A/B-testing: maskinrommet roterer varianter ved forside-rendering, logger impressions/klikk per variant, normaliserer CTR mot tidspunkt-baseline. Etter statistisk signifikans markeres vinner. Redaktør kan overstyre. Edge-metadata: `ab_status`, `impressions`, `clicks`, `ctr`.
## Fase 15: Adminpanel ## Fase 15: Adminpanel