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
> **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
et bilde. Alt som vises *om* en artikkel på forsiden er en *ting med
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();
}
// =============================================================================
// 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. */
export function resolveRetranscription(
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">
import type { Node } from '$lib/spacetime';
import { createEdge } from '$lib/api';
import PresentationEditor from './PresentationEditor.svelte';
interface Props {
/** The content node to publish */
@ -136,6 +137,16 @@
{/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>

View file

@ -178,6 +178,7 @@ async fn main() {
.route("/intentions/close_communication", post(intentions::close_communication))
.route("/query/aliases", get(queries::query_aliases))
.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/segments_version", get(queries::query_segments_version))
.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);
// Bygg OG-image URL fra CAS-hash hvis tilgjengelig
let og_image = article.og_image.as_ref().map(|hash| format!("/cas/{hash}"));
SeoData {
og_title: article.title.clone(),
description,
canonical_url: canonical_url.to_string(),
og_image: None,
og_image,
json_ld,
}
}
@ -297,8 +300,10 @@ pub struct ArticleData {
pub id: String,
pub short_id: String,
pub title: String,
pub subtitle: Option<String>,
pub content: String,
pub summary: Option<String>,
pub og_image: Option<String>,
pub published_at: String,
pub published_at_short: String,
}
@ -501,16 +506,28 @@ pub async fn render_article_to_cas(
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 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 {
id: id.to_string(),
short_id: short_id.clone(),
title: article_title,
subtitle: pres.best_subtitle(),
content: article_html,
summary: Some(summary_text),
og_image: pres.best_og_image(),
published_at: publish_at.to_rfc3339(),
published_at_short: publish_at.format("%e. %B %Y").to_string(),
};
@ -858,17 +875,26 @@ async fn fetch_article(
content.unwrap_or_default()
};
let summary_text = truncate(
// Hent presentasjonselementer
let pres = fetch_presentation_elements(db, id).await?;
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 {
id: id.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,
summary: Some(summary_text),
og_image: pres.best_og_image(),
published_at: publish_at.to_rfc3339(),
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(),
short_id: id.to_string()[..8].to_string(),
title: title.unwrap_or_else(|| "Uten tittel".to_string()),
subtitle: None,
content: content.unwrap_or_default(),
summary,
og_image: None,
published_at: publish_at.to_rfc3339(),
published_at_short: publish_at.format("%e. %B %Y").to_string(),
}
@ -1001,9 +1029,203 @@ async fn fetch_index_articles_optimized(
})
.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))
}
// =============================================================================
// 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 {
if s.len() <= max {
return s.to_string();
@ -1248,11 +1470,13 @@ pub async fn preview_theme(
id: format!("00000000-0000-0000-0000-00000000000{i}"),
short_id: format!("0000000{i}"),
title: format!("Eksempelartikkel {i}"),
subtitle: None,
content: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. \
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. \
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris."
.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_short: "18. mars 2026".to_string(),
})
@ -1700,8 +1924,10 @@ pub async fn serve_category(
id: id.to_string(),
short_id: id.to_string()[..8].to_string(),
title: title.unwrap_or_else(|| "Uten tittel".to_string()),
subtitle: None,
content: content.unwrap_or_default(),
summary,
og_image: None,
published_at: publish_at.to_rfc3339(),
published_at_short: publish_at.format("%e. %B %Y").to_string(),
}
@ -1836,8 +2062,10 @@ pub async fn serve_archive(
id: id.to_string(),
short_id: id.to_string()[..8].to_string(),
title: title.unwrap_or_else(|| "Uten tittel".to_string()),
subtitle: None,
content: content.unwrap_or_default(),
summary,
og_image: None,
published_at: publish_at.to_rfc3339(),
published_at_short: publish_at.format("%e. %B %Y").to_string(),
};
@ -2004,8 +2232,10 @@ pub async fn serve_search(
id: id.to_string(),
short_id: id.to_string()[..8].to_string(),
title: title.unwrap_or_else(|| "Uten tittel".to_string()),
subtitle: None,
content: content.unwrap_or_default(),
summary,
og_image: None,
published_at: publish_at.to_rfc3339(),
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)]
mod tests {
use super::*;

View file

@ -69,7 +69,9 @@
{% block content %}
<article class="article">
<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>
{% 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__content">{{ article.content | safe }}</div>
<a class="article__back" href="{{ base_url }}">&larr; Tilbake til forsiden</a>

View file

@ -48,7 +48,9 @@
{% block content %}
<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>
{% 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__content">
{{ article.content | safe }}

View file

@ -61,8 +61,10 @@
{% block content %}
<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">
<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>
</header>
<div class="mag-article__content">

View file

@ -55,7 +55,9 @@
{% block content %}
<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>
{% 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__content">
{{ 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.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.
- [~] 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
- [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".
- [ ] 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