Dynamiske sider (oppgave 14.15): kategori, arkiv, søk, om-side

Implementerer fire nye dynamiske sidetyper for publiseringssamlinger:

- Kategori-sider: filtrert på tag-edges, paginert med cache
- Arkiv: kronologisk med månedsgruppering, paginert med cache
- Søk: PG fulltekst med tsvector/ts_rank, paginert med cache
- Om-side: statisk CAS-node (page_role: "about"), immutable cache

Teknisk:
- Ny migrasjon (011): tsvector-kolonne + GIN-indeks + trigger for søk
- Nye Tera-templates: category.html, archive.html, search.html, about.html
- DynamicPageCache for in-memory caching av dynamiske sider
- Ruter for /pub/{slug}/kategori/{tag}, arkiv, sok, om
- Custom domain-varianter for alle nye sidetyper

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 02:39:06 +00:00
parent dda68594d7
commit 26c6a3b8d9
8 changed files with 1368 additions and 4 deletions

View file

@ -21,6 +21,9 @@ use crate::publishing::{self, PublishingConfig};
use crate::rss; use crate::rss;
use crate::AppState; use crate::AppState;
// Re-export for route handler parameter types
// (PageQuery og SearchQuery er definert i publishing-modulen)
// ============================================================================= // =============================================================================
// Verify-domain (Caddy on-demand TLS callback) // Verify-domain (Caddy on-demand TLS callback)
// ============================================================================= // =============================================================================
@ -225,6 +228,101 @@ pub async fn serve_custom_domain_article(
.await .await
} }
/// GET /custom-domain/kategori/{tag} — kategori for custom domain.
pub async fn serve_custom_domain_category(
State(state): State<AppState>,
headers: HeaderMap,
axum::extract::Path(tag): axum::extract::Path<String>,
query: Query<publishing::PageQuery>,
) -> Result<Response, StatusCode> {
let domain = extract_domain(&headers)?;
let collection = find_collection_by_domain(&state.db, &domain)
.await
.map_err(|e| {
tracing::error!(domain = %domain, error = %e, "DB-feil ved domene-oppslag");
StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or(StatusCode::NOT_FOUND)?;
let slug = collection.slug;
publishing::serve_category(
State(state),
axum::extract::Path((slug, tag)),
query,
)
.await
}
/// GET /custom-domain/arkiv — arkiv for custom domain.
pub async fn serve_custom_domain_archive(
State(state): State<AppState>,
headers: HeaderMap,
query: Query<publishing::PageQuery>,
) -> Result<Response, StatusCode> {
let domain = extract_domain(&headers)?;
let collection = find_collection_by_domain(&state.db, &domain)
.await
.map_err(|e| {
tracing::error!(domain = %domain, error = %e, "DB-feil ved domene-oppslag");
StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or(StatusCode::NOT_FOUND)?;
let slug = collection.slug;
publishing::serve_archive(
State(state),
axum::extract::Path(slug),
query,
)
.await
}
/// GET /custom-domain/sok — søk for custom domain.
pub async fn serve_custom_domain_search(
State(state): State<AppState>,
headers: HeaderMap,
query: Query<publishing::SearchQuery>,
) -> Result<Response, StatusCode> {
let domain = extract_domain(&headers)?;
let collection = find_collection_by_domain(&state.db, &domain)
.await
.map_err(|e| {
tracing::error!(domain = %domain, error = %e, "DB-feil ved domene-oppslag");
StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or(StatusCode::NOT_FOUND)?;
let slug = collection.slug;
publishing::serve_search(
State(state),
axum::extract::Path(slug),
query,
)
.await
}
/// GET /custom-domain/om — om-side for custom domain.
pub async fn serve_custom_domain_about(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Response, StatusCode> {
let domain = extract_domain(&headers)?;
let collection = find_collection_by_domain(&state.db, &domain)
.await
.map_err(|e| {
tracing::error!(domain = %domain, error = %e, "DB-feil ved domene-oppslag");
StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or(StatusCode::NOT_FOUND)?;
let slug = collection.slug;
publishing::serve_about(
State(state),
axum::extract::Path(slug),
)
.await
}
/// GET /custom-domain/feed.xml — RSS/Atom for custom domain. /// GET /custom-domain/feed.xml — RSS/Atom for custom domain.
pub async fn serve_custom_domain_feed( pub async fn serve_custom_domain_feed(
State(state): State<AppState>, State(state): State<AppState>,

View file

@ -37,6 +37,7 @@ pub struct AppState {
pub stdb: StdbClient, pub stdb: StdbClient,
pub cas: CasStore, pub cas: CasStore,
pub index_cache: publishing::IndexCache, pub index_cache: publishing::IndexCache,
pub dynamic_page_cache: publishing::DynamicPageCache,
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -144,7 +145,8 @@ async fn main() {
publishing::start_publish_scheduler(db.clone()); publishing::start_publish_scheduler(db.clone());
let index_cache = publishing::new_index_cache(); let index_cache = publishing::new_index_cache();
let state = AppState { db, jwks, stdb, cas, index_cache }; let dynamic_page_cache = publishing::new_dynamic_page_cache();
let state = AppState { db, jwks, stdb, cas, index_cache, dynamic_page_cache };
// Ruter: /health er offentlig, /me krever gyldig JWT // Ruter: /health er offentlig, /me krever gyldig JWT
let app = Router::new() let app = Router::new()
@ -183,13 +185,24 @@ async fn main() {
.route("/query/audio_info", get(intentions::audio_info)) .route("/query/audio_info", get(intentions::audio_info))
.route("/pub/{slug}/feed.xml", get(rss::generate_feed)) .route("/pub/{slug}/feed.xml", get(rss::generate_feed))
.route("/pub/{slug}", get(publishing::serve_index)) .route("/pub/{slug}", get(publishing::serve_index))
.route("/pub/{slug}/{article_id}", get(publishing::serve_article)) // Dynamiske sider: kategori, arkiv, søk, om (oppgave 14.15)
.route("/pub/{slug}/kategori/{tag}", get(publishing::serve_category))
.route("/pub/{slug}/arkiv", get(publishing::serve_archive))
.route("/pub/{slug}/sok", get(publishing::serve_search))
.route("/pub/{slug}/om", get(publishing::serve_about))
.route("/pub/{slug}/preview/{theme}", get(publishing::preview_theme)) .route("/pub/{slug}/preview/{theme}", get(publishing::preview_theme))
// NB: {article_id} catch-all må komme etter de spesifikke rutene
.route("/pub/{slug}/{article_id}", get(publishing::serve_article))
// Custom domains: Caddy on-demand TLS callback // Custom domains: Caddy on-demand TLS callback
.route("/internal/verify-domain", get(custom_domain::verify_domain)) .route("/internal/verify-domain", get(custom_domain::verify_domain))
// Custom domains: domene-basert serving (Caddy proxyer hit) // Custom domains: domene-basert serving (Caddy proxyer hit)
.route("/custom-domain/index", get(custom_domain::serve_custom_domain_index)) .route("/custom-domain/index", get(custom_domain::serve_custom_domain_index))
.route("/custom-domain/feed.xml", get(custom_domain::serve_custom_domain_feed)) .route("/custom-domain/feed.xml", get(custom_domain::serve_custom_domain_feed))
// Dynamiske sider for custom domains
.route("/custom-domain/kategori/{tag}", get(custom_domain::serve_custom_domain_category))
.route("/custom-domain/arkiv", get(custom_domain::serve_custom_domain_archive))
.route("/custom-domain/sok", get(custom_domain::serve_custom_domain_search))
.route("/custom-domain/om", get(custom_domain::serve_custom_domain_about))
.route("/custom-domain/{article_id}", get(custom_domain::serve_custom_domain_article)) .route("/custom-domain/{article_id}", get(custom_domain::serve_custom_domain_article))
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.with_state(state); .with_state(state);

View file

@ -14,11 +14,11 @@ use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, Query, State},
http::{header, StatusCode}, http::{header, StatusCode},
response::Response, response::Response,
}; };
use chrono::{DateTime, Utc}; use chrono::{DateTime, Datelike, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
use tera::{Context, Tera}; use tera::{Context, Tera};
@ -275,6 +275,16 @@ pub fn build_tera() -> Tera {
tera.add_raw_template("tidsskrift/index.html", include_str!("templates/tidsskrift/index.html")) tera.add_raw_template("tidsskrift/index.html", include_str!("templates/tidsskrift/index.html"))
.expect("Feil i tidsskrift/index.html"); .expect("Feil i tidsskrift/index.html");
// Dynamiske sider (delte templates, temaet styres via CSS-variabler i base.html)
tera.add_raw_template("category.html", include_str!("templates/category.html"))
.expect("Feil i category.html");
tera.add_raw_template("archive.html", include_str!("templates/archive.html"))
.expect("Feil i archive.html");
tera.add_raw_template("search.html", include_str!("templates/search.html"))
.expect("Feil i search.html");
tera.add_raw_template("about.html", include_str!("templates/about.html"))
.expect("Feil i about.html");
tera tera
} }
@ -1515,6 +1525,740 @@ pub async fn trigger_bulk_rerender(
Ok(total_enqueued) Ok(total_enqueued)
} }
// =============================================================================
// Dynamiske sider: kategori, arkiv, søk, om-side (oppgave 14.15)
// =============================================================================
/// Cachet dynamisk side med utløpstid. Nøkkel er (collection_id, side-type + params).
pub struct CachedDynamicPage {
html: String,
expires_at: DateTime<Utc>,
}
/// Thread-safe cache for dynamiske sider (kategori, arkiv, søk).
pub type DynamicPageCache = Arc<RwLock<HashMap<String, CachedDynamicPage>>>;
pub fn new_dynamic_page_cache() -> DynamicPageCache {
Arc::new(RwLock::new(HashMap::new()))
}
/// Sjekk cache og returner HTML hvis gyldig.
async fn check_dynamic_cache(cache: &DynamicPageCache, key: &str) -> Option<String> {
let map = cache.read().await;
if let Some(cached) = map.get(key) {
if cached.expires_at > Utc::now() {
return Some(cached.html.clone());
}
}
None
}
/// Sett inn i dynamisk cache.
async fn insert_dynamic_cache(cache: &DynamicPageCache, key: String, html: String, ttl_secs: u64) {
let expires_at = Utc::now() + chrono::Duration::seconds(ttl_secs as i64);
let mut map = cache.write().await;
map.insert(key, CachedDynamicPage { html, expires_at });
}
const DYNAMIC_PAGE_SIZE: i64 = 20;
/// Beregn sideområde for paginering (maks 7 sider rundt gjeldende).
fn page_range(current: i64, total: i64) -> Vec<i64> {
let start = (current - 3).max(1);
let end = (current + 3).min(total);
(start..=end).collect()
}
/// Norsk månedsnavn.
fn norwegian_month(month: u32) -> &'static str {
match month {
1 => "januar", 2 => "februar", 3 => "mars", 4 => "april",
5 => "mai", 6 => "juni", 7 => "juli", 8 => "august",
9 => "september", 10 => "oktober", 11 => "november",
12 => "desember", _ => "ukjent",
}
}
#[derive(Deserialize)]
pub struct PageQuery {
pub side: Option<i64>,
}
#[derive(Deserialize)]
pub struct SearchQuery {
pub q: Option<String>,
pub side: Option<i64>,
}
// --- Kategori-side ---
/// GET /pub/{slug}/kategori/{tag} — artikler filtrert på tag-edge.
///
/// Tag-edges: edge_type = 'tagged' fra artikkel → tag-node.
/// Tag-noder har node_kind = 'tag'. Slug-matching på tag-nodens title (lowercased).
pub async fn serve_category(
State(state): State<AppState>,
Path((slug, tag_slug)): Path<(String, String)>,
Query(query): Query<PageQuery>,
) -> Result<Response, StatusCode> {
let collection = find_publishing_collection(&state.db, &slug)
.await
.map_err(|e| {
tracing::error!(slug = %slug, error = %e, "Feil ved oppslag av samling (kategori)");
StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or(StatusCode::NOT_FOUND)?;
let page = query.side.unwrap_or(1).max(1);
let cache_ttl = collection.publishing_config.index_cache_ttl.unwrap_or(300);
// Cache-nøkkel
let cache_key = format!("cat:{}:{}:{}", collection.id, tag_slug, page);
if let Some(html) = check_dynamic_cache(&state.dynamic_page_cache, &cache_key).await {
return Ok(Response::builder()
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(header::CACHE_CONTROL, format!("public, max-age={cache_ttl}"))
.body(html.into())
.unwrap());
}
// Finn tag-noden via title match (case-insensitive)
let tag_row: Option<(Uuid, Option<String>)> = sqlx::query_as(
r#"
SELECT id, title FROM nodes
WHERE node_kind = 'tag' AND LOWER(title) = LOWER($1)
LIMIT 1
"#,
)
.bind(&tag_slug)
.fetch_optional(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let Some((tag_id, tag_title)) = tag_row else {
return Err(StatusCode::NOT_FOUND);
};
let tag_name = tag_title.unwrap_or_else(|| tag_slug.clone());
// Tell totalt antall artikler med denne taggen i samlingen
let (total_count,): (i64,) = sqlx::query_as(
r#"
SELECT COUNT(*)
FROM edges e_belongs
JOIN edges e_tag ON e_tag.source_id = e_belongs.source_id
WHERE e_belongs.target_id = $1
AND e_belongs.edge_type = 'belongs_to'
AND e_tag.target_id = $2
AND e_tag.edge_type = 'tagged'
"#,
)
.bind(collection.id)
.bind(tag_id)
.fetch_one(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let total_pages = ((total_count as f64) / DYNAMIC_PAGE_SIZE as f64).ceil() as i64;
let offset = (page - 1) * DYNAMIC_PAGE_SIZE;
type Row = (Uuid, Option<String>, Option<String>, DateTime<Utc>, Option<serde_json::Value>);
let rows: Vec<Row> = sqlx::query_as(
r#"
SELECT n.id, n.title, n.content, n.created_at, e_belongs.metadata
FROM edges e_belongs
JOIN edges e_tag ON e_tag.source_id = e_belongs.source_id
JOIN nodes n ON n.id = e_belongs.source_id
WHERE e_belongs.target_id = $1
AND e_belongs.edge_type = 'belongs_to'
AND e_tag.target_id = $2
AND e_tag.edge_type = 'tagged'
ORDER BY COALESCE(
(e_belongs.metadata->>'publish_at')::timestamptz,
n.created_at
) DESC
LIMIT $3 OFFSET $4
"#,
)
.bind(collection.id)
.bind(tag_id)
.bind(DYNAMIC_PAGE_SIZE)
.bind(offset)
.fetch_all(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let articles: Vec<ArticleData> = rows.into_iter().map(|(id, title, content, created_at, edge_meta)| {
let publish_at = edge_meta.as_ref()
.and_then(|m| m.get("publish_at"))
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<DateTime<Utc>>().ok())
.unwrap_or(created_at);
let summary = content.as_deref().map(|c| truncate(c, 200));
ArticleData {
id: id.to_string(),
short_id: id.to_string()[..8].to_string(),
title: title.unwrap_or_else(|| "Uten tittel".to_string()),
content: content.unwrap_or_default(),
summary,
published_at: publish_at.to_rfc3339(),
published_at_short: publish_at.format("%e. %B %Y").to_string(),
}
}).collect();
// Render
let theme = collection.publishing_config.theme.as_deref().unwrap_or("blogg");
let config = &collection.publishing_config.theme_config;
let css_vars = build_css_variables(theme, config);
let collection_title = collection.title.unwrap_or_else(|| slug.clone());
let base_url = collection.publishing_config.custom_domain.as_deref()
.map(|d| format!("https://{d}"))
.unwrap_or_else(|| format!("/pub/{slug}"));
let tera = build_tera();
let mut ctx = Context::new();
ctx.insert("css_variables", &css_vars);
ctx.insert("theme", theme);
ctx.insert("collection_title", &collection_title);
ctx.insert("base_url", &base_url);
ctx.insert("logo_hash", &config.logo_hash);
ctx.insert("has_rss", &collection.has_rss);
ctx.insert("tag_name", &tag_name);
ctx.insert("tag_slug", &tag_slug);
ctx.insert("articles", &articles);
ctx.insert("article_count", &total_count);
ctx.insert("current_page", &page);
ctx.insert("total_pages", &total_pages);
ctx.insert("page_range", &page_range(page, total_pages));
let html = tera.render("category.html", &ctx).map_err(|e| {
tracing::error!(error = %e, "Tera render-feil (category)");
StatusCode::INTERNAL_SERVER_ERROR
})?;
insert_dynamic_cache(&state.dynamic_page_cache, cache_key, html.clone(), cache_ttl).await;
Ok(Response::builder()
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(header::CACHE_CONTROL, format!("public, max-age={cache_ttl}"))
.body(html.into())
.unwrap())
}
// --- Arkiv-side ---
#[derive(Serialize)]
struct MonthGroup {
label: String,
articles: Vec<ArticleData>,
}
/// GET /pub/{slug}/arkiv — kronologisk arkiv med månedsgruppering.
pub async fn serve_archive(
State(state): State<AppState>,
Path(slug): Path<String>,
Query(query): Query<PageQuery>,
) -> Result<Response, StatusCode> {
let collection = find_publishing_collection(&state.db, &slug)
.await
.map_err(|e| {
tracing::error!(slug = %slug, error = %e, "Feil ved oppslag av samling (arkiv)");
StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or(StatusCode::NOT_FOUND)?;
let page = query.side.unwrap_or(1).max(1);
let cache_ttl = collection.publishing_config.index_cache_ttl.unwrap_or(300);
let cache_key = format!("archive:{}:{}", collection.id, page);
if let Some(html) = check_dynamic_cache(&state.dynamic_page_cache, &cache_key).await {
return Ok(Response::builder()
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(header::CACHE_CONTROL, format!("public, max-age={cache_ttl}"))
.body(html.into())
.unwrap());
}
// Tell totalt
let (total_count,): (i64,) = sqlx::query_as(
r#"
SELECT COUNT(*)
FROM edges e
WHERE e.target_id = $1 AND e.edge_type = 'belongs_to'
"#,
)
.bind(collection.id)
.fetch_one(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let total_pages = ((total_count as f64) / DYNAMIC_PAGE_SIZE as f64).ceil() as i64;
let offset = (page - 1) * DYNAMIC_PAGE_SIZE;
type Row = (Uuid, Option<String>, Option<String>, DateTime<Utc>, Option<serde_json::Value>);
let rows: Vec<Row> = sqlx::query_as(
r#"
SELECT n.id, n.title, n.content, n.created_at, e.metadata
FROM edges e
JOIN nodes n ON n.id = e.source_id
WHERE e.target_id = $1
AND e.edge_type = 'belongs_to'
ORDER BY COALESCE(
(e.metadata->>'publish_at')::timestamptz,
n.created_at
) DESC
LIMIT $2 OFFSET $3
"#,
)
.bind(collection.id)
.bind(DYNAMIC_PAGE_SIZE)
.bind(offset)
.fetch_all(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Grupper artikler etter måned
let mut month_groups: Vec<MonthGroup> = Vec::new();
for (id, title, content, created_at, edge_meta) in rows {
let publish_at = edge_meta.as_ref()
.and_then(|m| m.get("publish_at"))
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<DateTime<Utc>>().ok())
.unwrap_or(created_at);
let label = format!("{} {}", norwegian_month(publish_at.month()), publish_at.year());
let summary = content.as_deref().map(|c| truncate(c, 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()),
content: content.unwrap_or_default(),
summary,
published_at: publish_at.to_rfc3339(),
published_at_short: publish_at.format("%e. %B %Y").to_string(),
};
if let Some(last) = month_groups.last_mut() {
if last.label == label {
last.articles.push(article);
continue;
}
}
month_groups.push(MonthGroup {
label,
articles: vec![article],
});
}
// Render
let theme = collection.publishing_config.theme.as_deref().unwrap_or("blogg");
let config = &collection.publishing_config.theme_config;
let css_vars = build_css_variables(theme, config);
let collection_title = collection.title.unwrap_or_else(|| slug.clone());
let base_url = collection.publishing_config.custom_domain.as_deref()
.map(|d| format!("https://{d}"))
.unwrap_or_else(|| format!("/pub/{slug}"));
let tera = build_tera();
let mut ctx = Context::new();
ctx.insert("css_variables", &css_vars);
ctx.insert("theme", theme);
ctx.insert("collection_title", &collection_title);
ctx.insert("base_url", &base_url);
ctx.insert("logo_hash", &config.logo_hash);
ctx.insert("has_rss", &collection.has_rss);
ctx.insert("month_groups", &month_groups);
ctx.insert("total_articles", &total_count);
ctx.insert("current_page", &page);
ctx.insert("total_pages", &total_pages);
ctx.insert("page_range", &page_range(page, total_pages));
let html = tera.render("archive.html", &ctx).map_err(|e| {
tracing::error!(error = %e, "Tera render-feil (archive)");
StatusCode::INTERNAL_SERVER_ERROR
})?;
insert_dynamic_cache(&state.dynamic_page_cache, cache_key, html.clone(), cache_ttl).await;
Ok(Response::builder()
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(header::CACHE_CONTROL, format!("public, max-age={cache_ttl}"))
.body(html.into())
.unwrap())
}
// --- Søk-side ---
/// GET /pub/{slug}/sok?q=...&side=1 — fulltekstsøk i publiserte artikler.
///
/// Bruker PostgreSQL tsvector + ts_rank for relevanssortering.
/// Kun artikler med belongs_to-edge til samlingen inkluderes.
pub async fn serve_search(
State(state): State<AppState>,
Path(slug): Path<String>,
Query(query): Query<SearchQuery>,
) -> Result<Response, StatusCode> {
let collection = find_publishing_collection(&state.db, &slug)
.await
.map_err(|e| {
tracing::error!(slug = %slug, error = %e, "Feil ved oppslag av samling (søk)");
StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or(StatusCode::NOT_FOUND)?;
let search_query = query.q.unwrap_or_default().trim().to_string();
let page = query.side.unwrap_or(1).max(1);
let cache_ttl = collection.publishing_config.index_cache_ttl.unwrap_or(300).min(60); // Kortere cache for søk
let theme = collection.publishing_config.theme.as_deref().unwrap_or("blogg");
let config = &collection.publishing_config.theme_config;
let css_vars = build_css_variables(theme, config);
let collection_title = collection.title.unwrap_or_else(|| slug.clone());
let base_url = collection.publishing_config.custom_domain.as_deref()
.map(|d| format!("https://{d}"))
.unwrap_or_else(|| format!("/pub/{slug}"));
let tera = build_tera();
let mut ctx = Context::new();
ctx.insert("css_variables", &css_vars);
ctx.insert("theme", theme);
ctx.insert("collection_title", &collection_title);
ctx.insert("base_url", &base_url);
ctx.insert("logo_hash", &config.logo_hash);
ctx.insert("has_rss", &collection.has_rss);
ctx.insert("query", &search_query);
if search_query.is_empty() {
// Vis tom søkeside
ctx.insert("articles", &Vec::<ArticleData>::new());
ctx.insert("result_count", &0i64);
ctx.insert("current_page", &1i64);
ctx.insert("total_pages", &0i64);
ctx.insert("page_range", &Vec::<i64>::new());
} else {
// Cache-oppslag
let cache_key = format!("search:{}:{}:{}", collection.id, search_query, page);
if let Some(html) = check_dynamic_cache(&state.dynamic_page_cache, &cache_key).await {
return Ok(Response::builder()
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(header::CACHE_CONTROL, format!("public, max-age={cache_ttl}"))
.body(html.into())
.unwrap());
}
// Konverter søkeord til tsquery (bruk plainto_tsquery for sikkerhet)
let (total_count,): (i64,) = sqlx::query_as(
r#"
SELECT COUNT(*)
FROM edges e
JOIN nodes n ON n.id = e.source_id
WHERE e.target_id = $1
AND e.edge_type = 'belongs_to'
AND n.search_vector @@ plainto_tsquery('norwegian', $2)
"#,
)
.bind(collection.id)
.bind(&search_query)
.fetch_one(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let total_pages = ((total_count as f64) / DYNAMIC_PAGE_SIZE as f64).ceil() as i64;
let offset = (page - 1) * DYNAMIC_PAGE_SIZE;
type Row = (Uuid, Option<String>, Option<String>, DateTime<Utc>, Option<serde_json::Value>);
let rows: Vec<Row> = sqlx::query_as(
r#"
SELECT n.id, n.title, n.content, n.created_at, e.metadata
FROM edges e
JOIN nodes n ON n.id = e.source_id
WHERE e.target_id = $1
AND e.edge_type = 'belongs_to'
AND n.search_vector @@ plainto_tsquery('norwegian', $2)
ORDER BY ts_rank(n.search_vector, plainto_tsquery('norwegian', $2)) DESC
LIMIT $3 OFFSET $4
"#,
)
.bind(collection.id)
.bind(&search_query)
.bind(DYNAMIC_PAGE_SIZE)
.bind(offset)
.fetch_all(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let articles: Vec<ArticleData> = rows.into_iter().map(|(id, title, content, created_at, edge_meta)| {
let publish_at = edge_meta.as_ref()
.and_then(|m| m.get("publish_at"))
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<DateTime<Utc>>().ok())
.unwrap_or(created_at);
let summary = content.as_deref().map(|c| truncate(c, 200));
ArticleData {
id: id.to_string(),
short_id: id.to_string()[..8].to_string(),
title: title.unwrap_or_else(|| "Uten tittel".to_string()),
content: content.unwrap_or_default(),
summary,
published_at: publish_at.to_rfc3339(),
published_at_short: publish_at.format("%e. %B %Y").to_string(),
}
}).collect();
ctx.insert("articles", &articles);
ctx.insert("result_count", &total_count);
ctx.insert("current_page", &page);
ctx.insert("total_pages", &total_pages);
ctx.insert("page_range", &page_range(page, total_pages));
let html = tera.render("search.html", &ctx).map_err(|e| {
tracing::error!(error = %e, "Tera render-feil (search)");
StatusCode::INTERNAL_SERVER_ERROR
})?;
insert_dynamic_cache(&state.dynamic_page_cache, cache_key, html.clone(), cache_ttl).await;
return Ok(Response::builder()
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(header::CACHE_CONTROL, format!("public, max-age={cache_ttl}"))
.body(html.into())
.unwrap());
}
let html = tera.render("search.html", &ctx).map_err(|e| {
tracing::error!(error = %e, "Tera render-feil (search)");
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Response::builder()
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(header::CACHE_CONTROL, "no-cache")
.body(html.into())
.unwrap())
}
// --- Om-side ---
/// GET /pub/{slug}/om — statisk om-side.
///
/// Om-siden er en node med `page_role: "about"` i metadata, koblet til
/// samlingen via `belongs_to`-edge. Rendret HTML hentes fra CAS
/// (metadata.rendered.html_hash) eller rendres on-the-fly via about-template.
pub async fn serve_about(
State(state): State<AppState>,
Path(slug): Path<String>,
) -> Result<Response, StatusCode> {
let collection = find_publishing_collection(&state.db, &slug)
.await
.map_err(|e| {
tracing::error!(slug = %slug, error = %e, "Feil ved oppslag av samling (om)");
StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or(StatusCode::NOT_FOUND)?;
// Finn om-noden: belongs_to samlingen med page_role = "about"
let about_row: Option<(Uuid, Option<String>, Option<String>, serde_json::Value)> = sqlx::query_as(
r#"
SELECT n.id, n.title, n.content, n.metadata
FROM edges e
JOIN nodes n ON n.id = e.source_id
WHERE e.target_id = $1
AND e.edge_type = 'belongs_to'
AND n.metadata->>'page_role' = 'about'
LIMIT 1
"#,
)
.bind(collection.id)
.fetch_optional(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let Some((_id, _title, content, metadata)) = about_row else {
return Err(StatusCode::NOT_FOUND);
};
// Sjekk om rendret HTML finnes i CAS
if let Some(html_hash) = metadata
.get("rendered")
.and_then(|r| r.get("html_hash"))
.and_then(|h| h.as_str())
{
let cas_path = state.cas.path_for(html_hash);
if cas_path.exists() {
let html_bytes = tokio::fs::read(&cas_path).await.map_err(|e| {
tracing::error!(hash = %html_hash, error = %e, "Kunne ikke lese CAS-fil (about)");
StatusCode::INTERNAL_SERVER_ERROR
})?;
return Ok(Response::builder()
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.body(html_bytes.into())
.unwrap());
}
}
// Fallback: render on-the-fly via about-template
let about_html = if let Some(doc) = metadata.get("document") {
let html = tiptap::document_to_html(doc);
if html.is_empty() {
content.unwrap_or_default()
} else {
html
}
} else {
content.unwrap_or_default()
};
let theme = collection.publishing_config.theme.as_deref().unwrap_or("blogg");
let config = &collection.publishing_config.theme_config;
let css_vars = build_css_variables(theme, config);
let collection_title = collection.title.unwrap_or_else(|| slug.clone());
let base_url = collection.publishing_config.custom_domain.as_deref()
.map(|d| format!("https://{d}"))
.unwrap_or_else(|| format!("/pub/{slug}"));
let tera = build_tera();
let mut ctx = Context::new();
ctx.insert("css_variables", &css_vars);
ctx.insert("theme", theme);
ctx.insert("collection_title", &collection_title);
ctx.insert("base_url", &base_url);
ctx.insert("logo_hash", &config.logo_hash);
ctx.insert("has_rss", &collection.has_rss);
ctx.insert("about_html", &about_html);
let html = tera.render("about.html", &ctx).map_err(|e| {
tracing::error!(error = %e, "Tera render-feil (about)");
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Response::builder()
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(header::CACHE_CONTROL, "public, max-age=3600")
.body(html.into())
.unwrap())
}
/// Render om-side til CAS. Kalles ved opprettelse/oppdatering av om-noden.
pub async fn render_about_to_cas(
db: &PgPool,
cas: &CasStore,
node_id: Uuid,
collection_id: Uuid,
) -> Result<serde_json::Value, String> {
// Hent samlingens konfig
let collection_row: Option<(Option<String>, serde_json::Value)> = sqlx::query_as(
r#"SELECT title, metadata FROM nodes WHERE id = $1 AND node_kind = 'collection'"#,
)
.bind(collection_id)
.fetch_optional(db)
.await
.map_err(|e| format!("Feil ved henting av samling: {e}"))?;
let Some((collection_title_opt, collection_metadata)) = collection_row else {
return Err(format!("Samling {collection_id} finnes ikke"));
};
let traits = collection_metadata.get("traits");
let publishing_config: PublishingConfig = traits
.and_then(|t| t.get("publishing"))
.cloned()
.map(|v| serde_json::from_value(v).unwrap_or_default())
.unwrap_or_default();
let has_rss = traits.and_then(|t| t.get("rss")).is_some();
let slug = publishing_config.slug.as_deref().unwrap_or("unknown");
let theme = publishing_config.theme.as_deref().unwrap_or("blogg");
let config = &publishing_config.theme_config;
let collection_title = collection_title_opt.unwrap_or_else(|| slug.to_string());
// Hent om-nodens innhold
let node_row: Option<(Option<String>, serde_json::Value)> = sqlx::query_as(
"SELECT content, metadata FROM nodes WHERE id = $1",
)
.bind(node_id)
.fetch_optional(db)
.await
.map_err(|e| format!("Feil ved henting av om-node: {e}"))?;
let Some((content, metadata)) = node_row else {
return Err(format!("Node {node_id} finnes ikke"));
};
let about_html = if let Some(doc) = metadata.get("document") {
let html = tiptap::document_to_html(doc);
if html.is_empty() { content.unwrap_or_default() } else { html }
} else {
content.unwrap_or_default()
};
let css_vars = build_css_variables(theme, config);
let base_url = publishing_config.custom_domain.as_deref()
.map(|d| format!("https://{d}"))
.unwrap_or_else(|| format!("/pub/{slug}"));
let tera = build_tera();
let mut ctx = Context::new();
ctx.insert("css_variables", &css_vars);
ctx.insert("theme", theme);
ctx.insert("collection_title", &collection_title);
ctx.insert("base_url", &base_url);
ctx.insert("logo_hash", &config.logo_hash);
ctx.insert("has_rss", &has_rss);
ctx.insert("about_html", &about_html);
let html = tera.render("about.html", &ctx)
.map_err(|e| format!("Tera render-feil (about): {e}"))?;
let store_result = cas.store(html.as_bytes()).await
.map_err(|e| format!("CAS-lagring feilet: {e}"))?;
// Oppdater nodens metadata.rendered
let now = Utc::now();
sqlx::query(
r#"
UPDATE nodes
SET metadata = jsonb_set(
jsonb_set(
jsonb_set(
CASE WHEN metadata ? 'rendered'
THEN metadata
ELSE jsonb_set(metadata, '{rendered}', '{}'::jsonb)
END,
'{rendered,html_hash}',
to_jsonb($2::text)
),
'{rendered,rendered_at}',
to_jsonb($3::text)
),
'{rendered,renderer_version}',
to_jsonb($4::bigint)
)
WHERE id = $1
"#,
)
.bind(node_id)
.bind(&store_result.hash)
.bind(now.to_rfc3339())
.bind(RENDERER_VERSION)
.execute(db)
.await
.map_err(|e| format!("Feil ved oppdatering av metadata.rendered (about): {e}"))?;
Ok(serde_json::json!({
"html_hash": store_result.hash,
"size": store_result.size,
"renderer_version": RENDERER_VERSION
}))
}
// ============================================================================= // =============================================================================
// Tester // Tester
// ============================================================================= // =============================================================================

View file

@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block title %}Om — {{ collection_title }}{% endblock %}
{% block extra_css %}
.about-page {
max-width: var(--layout-max-width);
margin: 2rem auto;
padding: 0 1rem;
}
.about-page__content {
font-size: 1.05rem;
line-height: 1.8;
}
.about-page__content h1,
.about-page__content h2,
.about-page__content h3 {
font-family: var(--font-heading);
color: var(--color-primary);
margin-top: 2rem;
margin-bottom: 0.75rem;
line-height: 1.3;
}
.about-page__content h1 { font-size: 2rem; }
.about-page__content h2 { font-size: 1.5rem; }
.about-page__content h3 { font-size: 1.25rem; }
.about-page__content p { margin-bottom: 1rem; }
.about-page__content blockquote {
border-left: 3px solid var(--color-accent);
padding-left: 1rem;
margin: 1.5rem 0;
color: var(--color-muted);
font-style: italic;
}
.about-page__content ul, .about-page__content ol {
margin: 1rem 0;
padding-left: 1.5rem;
}
.about-page__content li { margin-bottom: 0.5rem; }
.about-page__content img { margin: 1.5rem 0; border-radius: 4px; }
@media (max-width: 768px) {
.about-page__content h1 { font-size: 1.5rem; }
.about-page__content h2 { font-size: 1.25rem; }
.about-page__content { font-size: 1rem; }
}
{% endblock %}
{% block content %}
<div class="about-page">
<div class="about-page__content">
{{ about_html | safe }}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,126 @@
{% extends "base.html" %}
{% block title %}Arkiv — {{ collection_title }}{% endblock %}
{% block extra_css %}
.dynamic-page {
max-width: var(--layout-max-width);
margin: 2rem auto;
padding: 0 1rem;
}
.dynamic-page__header {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid var(--color-accent);
}
.dynamic-page__title {
font-family: var(--font-heading);
font-size: 2rem;
color: var(--color-primary);
}
.dynamic-page__subtitle {
color: var(--color-muted);
margin-top: 0.25rem;
}
.month-group {
margin-bottom: 2rem;
}
.month-group__heading {
font-family: var(--font-heading);
font-size: 1.25rem;
color: var(--color-primary);
padding-bottom: 0.5rem;
border-bottom: 1px solid #e0e0e0;
margin-bottom: 0.75rem;
}
.article-list { list-style: none; }
.article-list__item {
padding: 1rem 0;
border-bottom: 1px solid #f5f5f5;
}
.article-list__item:last-child { border-bottom: none; }
.article-list__title {
font-family: var(--font-heading);
font-size: 1.2rem;
color: var(--color-primary);
line-height: 1.3;
margin-bottom: 0.15rem;
}
.article-list__meta {
font-size: 0.8rem;
color: var(--color-muted);
}
.pagination {
display: flex;
gap: 0.5rem;
justify-content: center;
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid #f0f0f0;
}
.pagination a, .pagination span {
padding: 0.5rem 1rem;
border: 1px solid #e0e0e0;
border-radius: 4px;
font-size: 0.9rem;
}
.pagination .current {
background: var(--color-accent);
color: #fff;
border-color: var(--color-accent);
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--color-muted);
}
@media (max-width: 768px) {
.dynamic-page__title { font-size: 1.5rem; }
.article-list__title { font-size: 1.05rem; }
}
{% endblock %}
{% block content %}
<div class="dynamic-page">
<div class="dynamic-page__header">
<h1 class="dynamic-page__title">Arkiv</h1>
<p class="dynamic-page__subtitle">{{ total_articles }} artikler totalt</p>
</div>
{% if month_groups | length > 0 %}
{% for group in month_groups %}
<section class="month-group">
<h2 class="month-group__heading">{{ group.label }}</h2>
<ul class="article-list">
{% for item in group.articles %}
<li class="article-list__item">
<h3 class="article-list__title"><a href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a></h3>
<div class="article-list__meta">{{ item.published_at_short }}</div>
</li>
{% endfor %}
</ul>
</section>
{% endfor %}
{% if total_pages > 1 %}
<nav class="pagination">
{% if current_page > 1 %}
<a href="{{ base_url }}/arkiv?side={{ current_page - 1 }}">Forrige</a>
{% endif %}
{% for p in page_range %}
{% if p == current_page %}
<span class="current">{{ p }}</span>
{% else %}
<a href="{{ base_url }}/arkiv?side={{ p }}">{{ p }}</a>
{% endif %}
{% endfor %}
{% if current_page < total_pages %}
<a href="{{ base_url }}/arkiv?side={{ current_page + 1 }}">Neste</a>
{% endif %}
</nav>
{% endif %}
{% else %}
<div class="empty-state">Ingen publiserte artikler ennå.</div>
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,133 @@
{% extends "base.html" %}
{% block title %}{{ tag_name }} — {{ collection_title }}{% endblock %}
{% block extra_css %}
.dynamic-page {
max-width: var(--layout-max-width);
margin: 2rem auto;
padding: 0 1rem;
}
.dynamic-page__header {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid var(--color-accent);
}
.dynamic-page__title {
font-family: var(--font-heading);
font-size: 2rem;
color: var(--color-primary);
}
.dynamic-page__subtitle {
color: var(--color-muted);
margin-top: 0.25rem;
}
.article-list { list-style: none; }
.article-list__item {
padding: 1.25rem 0;
border-bottom: 1px solid #f0f0f0;
}
.article-list__item:first-child { padding-top: 0; }
.article-list__title {
font-family: var(--font-heading);
font-size: 1.35rem;
color: var(--color-primary);
line-height: 1.3;
margin-bottom: 0.25rem;
}
.article-list__meta {
font-size: 0.8rem;
color: var(--color-muted);
margin-bottom: 0.4rem;
}
.article-list__summary {
font-size: 0.95rem;
line-height: 1.5;
}
.pagination {
display: flex;
gap: 0.5rem;
justify-content: center;
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid #f0f0f0;
}
.pagination a, .pagination span {
padding: 0.5rem 1rem;
border: 1px solid #e0e0e0;
border-radius: 4px;
font-size: 0.9rem;
}
.pagination .current {
background: var(--color-accent);
color: #fff;
border-color: var(--color-accent);
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
.tag-link {
display: inline-block;
padding: 0.25rem 0.75rem;
background: #f3f4f6;
border-radius: 1rem;
font-size: 0.85rem;
color: var(--color-text);
}
.tag-link:hover { background: var(--color-accent); color: #fff; text-decoration: none; }
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--color-muted);
}
@media (max-width: 768px) {
.dynamic-page__title { font-size: 1.5rem; }
.article-list__title { font-size: 1.15rem; }
}
{% endblock %}
{% block content %}
<div class="dynamic-page">
<div class="dynamic-page__header">
<h1 class="dynamic-page__title">{{ tag_name }}</h1>
<p class="dynamic-page__subtitle">{{ article_count }} {% if article_count == 1 %}artikkel{% else %}artikler{% endif %}</p>
</div>
{% if articles | length > 0 %}
<ul class="article-list">
{% for item in articles %}
<li class="article-list__item">
<h2 class="article-list__title"><a href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a></h2>
<div class="article-list__meta">{{ item.published_at_short }}</div>
{% if item.summary %}
<p class="article-list__summary">{{ item.summary }}</p>
{% endif %}
</li>
{% endfor %}
</ul>
{% if total_pages > 1 %}
<nav class="pagination">
{% if current_page > 1 %}
<a href="{{ base_url }}/kategori/{{ tag_slug }}?side={{ current_page - 1 }}">Forrige</a>
{% endif %}
{% for p in page_range %}
{% if p == current_page %}
<span class="current">{{ p }}</span>
{% else %}
<a href="{{ base_url }}/kategori/{{ tag_slug }}?side={{ p }}">{{ p }}</a>
{% endif %}
{% endfor %}
{% if current_page < total_pages %}
<a href="{{ base_url }}/kategori/{{ tag_slug }}?side={{ current_page + 1 }}">Neste</a>
{% endif %}
</nav>
{% endif %}
{% else %}
<div class="empty-state">Ingen artikler i denne kategorien.</div>
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,162 @@
{% extends "base.html" %}
{% block title %}{% if query %}Søk: {{ query }} — {% endif %}{{ collection_title }}{% endblock %}
{% block extra_css %}
.dynamic-page {
max-width: var(--layout-max-width);
margin: 2rem auto;
padding: 0 1rem;
}
.dynamic-page__header {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid var(--color-accent);
}
.dynamic-page__title {
font-family: var(--font-heading);
font-size: 2rem;
color: var(--color-primary);
}
.search-form {
margin-top: 1rem;
display: flex;
gap: 0.5rem;
}
.search-form__input {
flex: 1;
padding: 0.75rem 1rem;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 1rem;
font-family: var(--font-body);
color: var(--color-text);
background: var(--color-background);
}
.search-form__input:focus {
border-color: var(--color-accent);
outline: none;
}
.search-form__button {
padding: 0.75rem 1.5rem;
background: var(--color-accent);
color: #fff;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
}
.search-form__button:hover { opacity: 0.9; }
.search-results__info {
color: var(--color-muted);
margin-bottom: 1rem;
font-size: 0.9rem;
}
.article-list { list-style: none; }
.article-list__item {
padding: 1.25rem 0;
border-bottom: 1px solid #f0f0f0;
}
.article-list__item:first-child { padding-top: 0; }
.article-list__title {
font-family: var(--font-heading);
font-size: 1.35rem;
color: var(--color-primary);
line-height: 1.3;
margin-bottom: 0.25rem;
}
.article-list__meta {
font-size: 0.8rem;
color: var(--color-muted);
margin-bottom: 0.4rem;
}
.article-list__summary {
font-size: 0.95rem;
line-height: 1.5;
}
.article-list__highlight {
background: rgba(233, 69, 96, 0.1);
padding: 0 0.15rem;
border-radius: 2px;
}
.pagination {
display: flex;
gap: 0.5rem;
justify-content: center;
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid #f0f0f0;
}
.pagination a, .pagination span {
padding: 0.5rem 1rem;
border: 1px solid #e0e0e0;
border-radius: 4px;
font-size: 0.9rem;
}
.pagination .current {
background: var(--color-accent);
color: #fff;
border-color: var(--color-accent);
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--color-muted);
}
@media (max-width: 768px) {
.dynamic-page__title { font-size: 1.5rem; }
.article-list__title { font-size: 1.15rem; }
.search-form { flex-direction: column; }
}
{% endblock %}
{% block content %}
<div class="dynamic-page">
<div class="dynamic-page__header">
<h1 class="dynamic-page__title">Søk</h1>
<form class="search-form" method="get" action="{{ base_url }}/sok">
<input class="search-form__input" type="text" name="q" value="{{ query | default(value='') }}" placeholder="Søk i artikler..." autocomplete="off">
<button class="search-form__button" type="submit">Søk</button>
</form>
</div>
{% if query %}
{% if articles | length > 0 %}
<p class="search-results__info">{{ result_count }} treff for &laquo;{{ query }}&raquo;</p>
<ul class="article-list">
{% for item in articles %}
<li class="article-list__item">
<h2 class="article-list__title"><a href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a></h2>
<div class="article-list__meta">{{ item.published_at_short }}</div>
{% if item.summary %}
<p class="article-list__summary">{{ item.summary }}</p>
{% endif %}
</li>
{% endfor %}
</ul>
{% if total_pages > 1 %}
<nav class="pagination">
{% if current_page > 1 %}
<a href="{{ base_url }}/sok?q={{ query | urlencode }}&side={{ current_page - 1 }}">Forrige</a>
{% endif %}
{% for p in page_range %}
{% if p == current_page %}
<span class="current">{{ p }}</span>
{% else %}
<a href="{{ base_url }}/sok?q={{ query | urlencode }}&side={{ p }}">{{ p }}</a>
{% endif %}
{% endfor %}
{% if current_page < total_pages %}
<a href="{{ base_url }}/sok?q={{ query | urlencode }}&side={{ current_page + 1 }}">Neste</a>
{% endif %}
</nav>
{% endif %}
{% else %}
<div class="empty-state">Ingen treff for &laquo;{{ query }}&raquo;.</div>
{% endif %}
{% else %}
<div class="empty-state">Skriv inn et søkeord for å søke i artiklene.</div>
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,34 @@
-- Migrasjon 011: Fulltekstsøk for publiserte artikler
--
-- Legger til tsvector-kolonne på nodes-tabellen og GIN-indeks
-- for effektivt fulltekstsøk i publisert innhold.
-- Brukes av dynamiske søkesider i publiseringssystemet.
-- tsvector-kolonne som oppdateres automatisk via trigger
ALTER TABLE nodes ADD COLUMN IF NOT EXISTS search_vector tsvector;
-- GIN-indeks for rask fulltekstsøk
CREATE INDEX IF NOT EXISTS idx_nodes_search_vector ON nodes USING GIN (search_vector);
-- Trigger-funksjon som oppdaterer search_vector ved insert/update
CREATE OR REPLACE FUNCTION update_search_vector() RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('norwegian', COALESCE(NEW.title, '')), 'A') ||
setweight(to_tsvector('norwegian', COALESCE(NEW.content, '')), 'B');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger på nodes-tabellen
DROP TRIGGER IF EXISTS trg_nodes_search_vector ON nodes;
CREATE TRIGGER trg_nodes_search_vector
BEFORE INSERT OR UPDATE OF title, content ON nodes
FOR EACH ROW
EXECUTE FUNCTION update_search_vector();
-- Populer eksisterende noder
UPDATE nodes SET search_vector =
setweight(to_tsvector('norwegian', COALESCE(title, '')), 'A') ||
setweight(to_tsvector('norwegian', COALESCE(content, '')), 'B')
WHERE search_vector IS NULL;