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:
parent
dda68594d7
commit
26c6a3b8d9
8 changed files with 1368 additions and 4 deletions
|
|
@ -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<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.
|
||||
pub async fn serve_custom_domain_feed(
|
||||
State(state): State<AppState>,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
// =============================================================================
|
||||
|
|
|
|||
54
maskinrommet/src/templates/about.html
Normal file
54
maskinrommet/src/templates/about.html
Normal 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 %}
|
||||
126
maskinrommet/src/templates/archive.html
Normal file
126
maskinrommet/src/templates/archive.html
Normal 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 %}
|
||||
133
maskinrommet/src/templates/category.html
Normal file
133
maskinrommet/src/templates/category.html
Normal 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 %}
|
||||
162
maskinrommet/src/templates/search.html
Normal file
162
maskinrommet/src/templates/search.html
Normal 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 «{{ query }}»</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 «{{ query }}».</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="empty-state">Skriv inn et søkeord for å søke i artiklene.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
34
migrations/011_fulltext_search.sql
Normal file
34
migrations/011_fulltext_search.sql
Normal 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;
|
||||
Loading…
Add table
Reference in a new issue