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:
parent
18642d5e79
commit
63630eb55a
12 changed files with 802 additions and 10 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
349
frontend/src/lib/components/PresentationEditor.svelte
Normal file
349
frontend/src/lib/components/PresentationEditor.svelte
Normal 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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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::*;
|
||||||
|
|
|
||||||
|
|
@ -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 }}">← Tilbake til forsiden</a>
|
<a class="article__back" href="{{ base_url }}">← Tilbake til forsiden</a>
|
||||||
|
|
|
||||||
|
|
@ -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 }}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 }}
|
||||||
|
|
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue