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
|
||||
|
||||
> **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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
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">
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
&article_html.replace("<p>", "").replace("</p>", " ").replace('\n', " "),
|
||||
200,
|
||||
);
|
||||
// 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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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 }}">← Tilbake til forsiden</a>
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
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.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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue