From 26c6a3b8d9b082a777877fb5c4c71fef947269b9 Mon Sep 17 00:00:00 2001 From: vegard Date: Wed, 18 Mar 2026 02:39:06 +0000 Subject: [PATCH] =?UTF-8?q?Dynamiske=20sider=20(oppgave=2014.15):=20katego?= =?UTF-8?q?ri,=20arkiv,=20s=C3=B8k,=20om-side?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- maskinrommet/src/custom_domain.rs | 98 +++ maskinrommet/src/main.rs | 17 +- maskinrommet/src/publishing.rs | 748 ++++++++++++++++++++++- maskinrommet/src/templates/about.html | 54 ++ maskinrommet/src/templates/archive.html | 126 ++++ maskinrommet/src/templates/category.html | 133 ++++ maskinrommet/src/templates/search.html | 162 +++++ migrations/011_fulltext_search.sql | 34 ++ 8 files changed, 1368 insertions(+), 4 deletions(-) create mode 100644 maskinrommet/src/templates/about.html create mode 100644 maskinrommet/src/templates/archive.html create mode 100644 maskinrommet/src/templates/category.html create mode 100644 maskinrommet/src/templates/search.html create mode 100644 migrations/011_fulltext_search.sql diff --git a/maskinrommet/src/custom_domain.rs b/maskinrommet/src/custom_domain.rs index fb5f1ea..293e4e6 100644 --- a/maskinrommet/src/custom_domain.rs +++ b/maskinrommet/src/custom_domain.rs @@ -21,6 +21,9 @@ use crate::publishing::{self, PublishingConfig}; use crate::rss; 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) // ============================================================================= @@ -225,6 +228,101 @@ pub async fn serve_custom_domain_article( .await } +/// GET /custom-domain/kategori/{tag} — kategori for custom domain. +pub async fn serve_custom_domain_category( + State(state): State, + headers: HeaderMap, + axum::extract::Path(tag): axum::extract::Path, + query: Query, +) -> Result { + 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, + headers: HeaderMap, + query: Query, +) -> Result { + 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, + headers: HeaderMap, + query: Query, +) -> Result { + 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, + headers: HeaderMap, +) -> Result { + 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. pub async fn serve_custom_domain_feed( State(state): State, diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index e3c7be7..8e1cfb0 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -37,6 +37,7 @@ pub struct AppState { pub stdb: StdbClient, pub cas: CasStore, pub index_cache: publishing::IndexCache, + pub dynamic_page_cache: publishing::DynamicPageCache, } #[derive(Serialize)] @@ -144,7 +145,8 @@ async fn main() { publishing::start_publish_scheduler(db.clone()); 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 let app = Router::new() @@ -183,13 +185,24 @@ async fn main() { .route("/query/audio_info", get(intentions::audio_info)) .route("/pub/{slug}/feed.xml", get(rss::generate_feed)) .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)) + // 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 .route("/internal/verify-domain", get(custom_domain::verify_domain)) // Custom domains: domene-basert serving (Caddy proxyer hit) .route("/custom-domain/index", get(custom_domain::serve_custom_domain_index)) .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)) .layer(TraceLayer::new_for_http()) .with_state(state); diff --git a/maskinrommet/src/publishing.rs b/maskinrommet/src/publishing.rs index 44e312d..bc31deb 100644 --- a/maskinrommet/src/publishing.rs +++ b/maskinrommet/src/publishing.rs @@ -14,11 +14,11 @@ use std::collections::HashMap; use std::sync::Arc; use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, http::{header, StatusCode}, response::Response, }; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, Datelike, Utc}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; 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")) .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 } @@ -1515,6 +1525,740 @@ pub async fn trigger_bulk_rerender( 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, +} + +/// Thread-safe cache for dynamiske sider (kategori, arkiv, søk). +pub type DynamicPageCache = Arc>>; + +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 { + 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 { + 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, +} + +#[derive(Deserialize)] +pub struct SearchQuery { + pub q: Option, + pub side: Option, +} + +// --- 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, + Path((slug, tag_slug)): Path<(String, String)>, + Query(query): Query, +) -> Result { + 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)> = 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, Option, DateTime, Option); + + let rows: Vec = 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 = 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::>().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, +} + +/// GET /pub/{slug}/arkiv — kronologisk arkiv med månedsgruppering. +pub async fn serve_archive( + State(state): State, + Path(slug): Path, + Query(query): Query, +) -> Result { + 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, Option, DateTime, Option); + + let rows: Vec = 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 = 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::>().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, + Path(slug): Path, + Query(query): Query, +) -> Result { + 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::::new()); + ctx.insert("result_count", &0i64); + ctx.insert("current_page", &1i64); + ctx.insert("total_pages", &0i64); + ctx.insert("page_range", &Vec::::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, Option, DateTime, Option); + + let rows: Vec = 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 = 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::>().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, + Path(slug): Path, +) -> Result { + 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, Option, 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 { + // Hent samlingens konfig + let collection_row: Option<(Option, 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, 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 // ============================================================================= diff --git a/maskinrommet/src/templates/about.html b/maskinrommet/src/templates/about.html new file mode 100644 index 0000000..84cd961 --- /dev/null +++ b/maskinrommet/src/templates/about.html @@ -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 %} +
+
+ {{ about_html | safe }} +
+
+{% endblock %} diff --git a/maskinrommet/src/templates/archive.html b/maskinrommet/src/templates/archive.html new file mode 100644 index 0000000..129e7af --- /dev/null +++ b/maskinrommet/src/templates/archive.html @@ -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 %} +
+
+

Arkiv

+

{{ total_articles }} artikler totalt

+
+ + {% if month_groups | length > 0 %} + {% for group in month_groups %} +
+

{{ group.label }}

+
    + {% for item in group.articles %} +
  • +

    {{ item.title }}

    +
    {{ item.published_at_short }}
    +
  • + {% endfor %} +
+
+ {% endfor %} + + {% if total_pages > 1 %} + + {% endif %} + {% else %} +
Ingen publiserte artikler ennå.
+ {% endif %} +
+{% endblock %} diff --git a/maskinrommet/src/templates/category.html b/maskinrommet/src/templates/category.html new file mode 100644 index 0000000..6941945 --- /dev/null +++ b/maskinrommet/src/templates/category.html @@ -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 %} +
+
+

{{ tag_name }}

+

{{ article_count }} {% if article_count == 1 %}artikkel{% else %}artikler{% endif %}

+
+ + {% if articles | length > 0 %} +
    + {% for item in articles %} +
  • +

    {{ item.title }}

    +
    {{ item.published_at_short }}
    + {% if item.summary %} +

    {{ item.summary }}

    + {% endif %} +
  • + {% endfor %} +
+ + {% if total_pages > 1 %} + + {% endif %} + {% else %} +
Ingen artikler i denne kategorien.
+ {% endif %} +
+{% endblock %} diff --git a/maskinrommet/src/templates/search.html b/maskinrommet/src/templates/search.html new file mode 100644 index 0000000..147a346 --- /dev/null +++ b/maskinrommet/src/templates/search.html @@ -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 %} +
+
+

Søk

+
+ + +
+
+ + {% if query %} + {% if articles | length > 0 %} +

{{ result_count }} treff for «{{ query }}»

+
    + {% for item in articles %} +
  • +

    {{ item.title }}

    +
    {{ item.published_at_short }}
    + {% if item.summary %} +

    {{ item.summary }}

    + {% endif %} +
  • + {% endfor %} +
+ + {% if total_pages > 1 %} + + {% endif %} + {% else %} +
Ingen treff for «{{ query }}».
+ {% endif %} + {% else %} +
Skriv inn et søkeord for å søke i artiklene.
+ {% endif %} +
+{% endblock %} diff --git a/migrations/011_fulltext_search.sql b/migrations/011_fulltext_search.sql new file mode 100644 index 0000000..27fee89 --- /dev/null +++ b/migrations/011_fulltext_search.sql @@ -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;