From 4b9f520eabdab647b6807bd55b206023fc14c5a0 Mon Sep 17 00:00:00 2001 From: vegard Date: Wed, 18 Mar 2026 00:41:54 +0000 Subject: [PATCH] Tera-templates: innebygde temaer for publisering (oppgave 14.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementerer publiseringsmotoren med fire innebygde temaer: - Avis: multi-kolonne, informasjonstung, hero+sidebar+rutenett - Magasin: store bilder, luft, editorial, cards-layout - Blogg: enkel, én kolonne, kronologisk liste - Tidsskrift: akademisk, tekstdrevet, nummerert innholdsfortegnelse Hvert tema har artikkelmal + forside-mal som Tera-templates (Jinja2-like). CSS-variabler for theme_config-overstyring fra publishing-traiten — fungerer meningsfullt med bare "theme": "magasin" (null konfigurasjon). Teknisk: - publishing.rs: Tera engine, render-funksjoner, DB-spørringer, HTTP-handlers - Templates innebygd via include_str! (kompilert inn i binæren) - Ruter: GET /pub/{slug} (forside), /pub/{slug}/{id} (artikkel), /pub/{slug}/preview/{theme} (forhåndsvisning med testdata) - 6 enhetstester for CSS-variabler, rendering og tema-fallback Ref: docs/concepts/publisering.md § "Temaer" Co-Authored-By: Claude Opus 4.6 (1M context) --- maskinrommet/Cargo.lock | 287 +++++++ maskinrommet/Cargo.toml | 1 + maskinrommet/src/main.rs | 8 + maskinrommet/src/publishing.rs | 709 ++++++++++++++++++ maskinrommet/src/templates/avis/article.html | 66 ++ maskinrommet/src/templates/avis/index.html | 159 ++++ maskinrommet/src/templates/base.html | 90 +++ maskinrommet/src/templates/blogg/article.html | 44 ++ maskinrommet/src/templates/blogg/index.html | 93 +++ .../src/templates/magasin/article.html | 59 ++ maskinrommet/src/templates/magasin/index.html | 132 ++++ .../src/templates/tidsskrift/article.html | 51 ++ .../src/templates/tidsskrift/index.html | 133 ++++ tasks.md | 3 +- 14 files changed, 1833 insertions(+), 2 deletions(-) create mode 100644 maskinrommet/src/publishing.rs create mode 100644 maskinrommet/src/templates/avis/article.html create mode 100644 maskinrommet/src/templates/avis/index.html create mode 100644 maskinrommet/src/templates/base.html create mode 100644 maskinrommet/src/templates/blogg/article.html create mode 100644 maskinrommet/src/templates/blogg/index.html create mode 100644 maskinrommet/src/templates/magasin/article.html create mode 100644 maskinrommet/src/templates/magasin/index.html create mode 100644 maskinrommet/src/templates/tidsskrift/article.html create mode 100644 maskinrommet/src/templates/tidsskrift/index.html diff --git a/maskinrommet/Cargo.lock b/maskinrommet/Cargo.lock index fa75e02..b0b62eb 100644 --- a/maskinrommet/Cargo.lock +++ b/maskinrommet/Cargo.lock @@ -136,6 +136,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -190,6 +200,28 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" +dependencies = [ + "parse-zoneinfo", + "phf", + "phf_codegen", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -235,6 +267,25 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -280,6 +331,12 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + [[package]] name = "digest" version = "0.10.7" @@ -530,6 +587,30 @@ dependencies = [ "wasip3", ] +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags", + "ignore", + "walkdir", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -640,6 +721,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + [[package]] name = "hyper" version = "1.8.1" @@ -834,6 +924,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -982,6 +1088,7 @@ dependencies = [ "serde_json", "sha2", "sqlx", + "tera", "tokio", "tokio-util", "tower-http", @@ -1171,6 +1278,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + [[package]] name = "pem" version = "3.0.6" @@ -1196,6 +1312,87 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1437,6 +1634,18 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -1583,6 +1792,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1730,12 +1948,28 @@ dependencies = [ "time", ] +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slug" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -2026,6 +2260,28 @@ dependencies = [ "syn", ] +[[package]] +name = "tera" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8004bca281f2d32df3bacd59bc67b312cb4c70cea46cbd79dbe8ac5ed206722" +dependencies = [ + "chrono", + "chrono-tz", + "globwalk", + "humansize", + "lazy_static", + "percent-encoding", + "pest", + "pest_derive", + "rand 0.8.5", + "regex", + "serde", + "serde_json", + "slug", + "unicode-segmentation", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -2307,6 +2563,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicase" version = "2.9.0" @@ -2340,6 +2602,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -2400,6 +2668,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2593,6 +2871,15 @@ dependencies = [ "wasite", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/maskinrommet/Cargo.toml b/maskinrommet/Cargo.toml index 425a42c..1392825 100644 --- a/maskinrommet/Cargo.toml +++ b/maskinrommet/Cargo.toml @@ -19,3 +19,4 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls" sha2 = "0.10" hex = "0.4" tokio-util = { version = "0.7", features = ["io"] } +tera = "1" diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 17e4bea..f89f5c9 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -1,5 +1,6 @@ pub mod agent; pub mod ai_edges; +pub mod audio; mod auth; pub mod cas; mod intentions; @@ -7,6 +8,7 @@ pub mod jobs; pub mod livekit; pub mod pruning; mod queries; +pub mod publishing; mod rss; mod serving; mod stdb; @@ -166,7 +168,13 @@ async fn main() { .route("/query/graph", get(queries::query_graph)) .route("/query/transcription_versions", get(queries::query_transcription_versions)) .route("/query/segments_version", get(queries::query_segments_version)) + .route("/intentions/audio_analyze", post(intentions::audio_analyze)) + .route("/intentions/audio_process", post(intentions::audio_process)) + .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)) + .route("/pub/{slug}/preview/{theme}", get(publishing::preview_theme)) .layer(TraceLayer::new_for_http()) .with_state(state); diff --git a/maskinrommet/src/publishing.rs b/maskinrommet/src/publishing.rs new file mode 100644 index 0000000..697073d --- /dev/null +++ b/maskinrommet/src/publishing.rs @@ -0,0 +1,709 @@ +//! Publiseringsmotor: Tera-templates med innebygde temaer. +//! +//! Fire temaer: avis, magasin, blogg, tidsskrift. +//! Hvert tema har artikkelmal + forside-mal. +//! CSS-variabler for theme_config-overstyring. +//! +//! Ref: docs/concepts/publisering.md § "Temaer" + +use axum::{ + extract::{Path, State}, + http::{header, StatusCode}, + response::Response, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use tera::{Context, Tera}; +use uuid::Uuid; + +use crate::AppState; + +// ============================================================================= +// Tema-konfigurasjon fra publishing-trait +// ============================================================================= + +#[derive(Deserialize, Default, Debug)] +pub struct PublishingConfig { + pub slug: Option, + pub theme: Option, + #[serde(default)] + pub theme_config: ThemeConfig, + pub custom_domain: Option, + pub index_mode: Option, + pub featured_max: Option, + pub stream_page_size: Option, +} + +#[derive(Deserialize, Default, Debug, Clone, Serialize)] +pub struct ThemeConfig { + #[serde(default)] + pub colors: ColorConfig, + #[serde(default)] + pub typography: TypographyConfig, + #[serde(default)] + pub layout: LayoutConfig, + pub logo_hash: Option, +} + +#[derive(Deserialize, Default, Debug, Clone, Serialize)] +pub struct ColorConfig { + pub primary: Option, + pub accent: Option, + pub background: Option, + pub text: Option, + pub muted: Option, +} + +#[derive(Deserialize, Default, Debug, Clone, Serialize)] +pub struct TypographyConfig { + pub heading_font: Option, + pub body_font: Option, +} + +#[derive(Deserialize, Default, Debug, Clone, Serialize)] +pub struct LayoutConfig { + pub max_width: Option, +} + +// ============================================================================= +// Innebygde temaer — Tera-templates +// ============================================================================= + +/// Tema-defaults for CSS-variabler per tema. +struct ThemeDefaults { + primary: &'static str, + accent: &'static str, + background: &'static str, + text: &'static str, + muted: &'static str, + heading_font: &'static str, + body_font: &'static str, + max_width: &'static str, +} + +fn theme_defaults(theme: &str) -> ThemeDefaults { + match theme { + "avis" => ThemeDefaults { + primary: "#1a1a2e", + accent: "#e94560", + background: "#ffffff", + text: "#1a1a2e", + muted: "#6b7280", + heading_font: "'Georgia', 'Times New Roman', serif", + body_font: "'Charter', 'Georgia', serif", + max_width: "1200px", + }, + "magasin" => ThemeDefaults { + primary: "#2d3436", + accent: "#0984e3", + background: "#fafafa", + text: "#2d3436", + muted: "#636e72", + heading_font: "'Playfair Display', 'Georgia', serif", + body_font: "system-ui, -apple-system, sans-serif", + max_width: "1100px", + }, + "blogg" => ThemeDefaults { + primary: "#2c3e50", + accent: "#3498db", + background: "#ffffff", + text: "#333333", + muted: "#7f8c8d", + heading_font: "system-ui, -apple-system, sans-serif", + body_font: "system-ui, -apple-system, sans-serif", + max_width: "720px", + }, + "tidsskrift" => ThemeDefaults { + primary: "#1a1a1a", + accent: "#8b0000", + background: "#fffff8", + text: "#1a1a1a", + muted: "#555555", + heading_font: "'Georgia', 'Times New Roman', serif", + body_font: "'Georgia', 'Times New Roman', serif", + max_width: "680px", + }, + // Fallback til blogg-defaults + _ => theme_defaults("blogg"), + } +} + +/// Generer CSS-variabler fra theme_config med tema-defaults som fallback. +fn build_css_variables(theme: &str, config: &ThemeConfig) -> String { + let defaults = theme_defaults(theme); + format!( + r#":root {{ + --color-primary: {primary}; + --color-accent: {accent}; + --color-background: {background}; + --color-text: {text}; + --color-muted: {muted}; + --font-heading: {heading_font}; + --font-body: {body_font}; + --layout-max-width: {max_width}; +}}"#, + primary = config.colors.primary.as_deref().unwrap_or(defaults.primary), + accent = config.colors.accent.as_deref().unwrap_or(defaults.accent), + background = config.colors.background.as_deref().unwrap_or(defaults.background), + text = config.colors.text.as_deref().unwrap_or(defaults.text), + muted = config.colors.muted.as_deref().unwrap_or(defaults.muted), + heading_font = config.typography.heading_font.as_deref().unwrap_or(defaults.heading_font), + body_font = config.typography.body_font.as_deref().unwrap_or(defaults.body_font), + max_width = config.layout.max_width.as_deref().unwrap_or(defaults.max_width), + ) +} + +// ============================================================================= +// Tera engine med innebygde templates +// ============================================================================= + +/// Bygg Tera-instans med alle innebygde temaer. +pub fn build_tera() -> Tera { + let mut tera = Tera::default(); + + // Base-template (felles for alle temaer) + tera.add_raw_template("base.html", include_str!("templates/base.html")) + .expect("Feil i base.html template"); + + // Avis + tera.add_raw_template("avis/article.html", include_str!("templates/avis/article.html")) + .expect("Feil i avis/article.html"); + tera.add_raw_template("avis/index.html", include_str!("templates/avis/index.html")) + .expect("Feil i avis/index.html"); + + // Magasin + tera.add_raw_template("magasin/article.html", include_str!("templates/magasin/article.html")) + .expect("Feil i magasin/article.html"); + tera.add_raw_template("magasin/index.html", include_str!("templates/magasin/index.html")) + .expect("Feil i magasin/index.html"); + + // Blogg + tera.add_raw_template("blogg/article.html", include_str!("templates/blogg/article.html")) + .expect("Feil i blogg/article.html"); + tera.add_raw_template("blogg/index.html", include_str!("templates/blogg/index.html")) + .expect("Feil i blogg/index.html"); + + // Tidsskrift + tera.add_raw_template("tidsskrift/article.html", include_str!("templates/tidsskrift/article.html")) + .expect("Feil i tidsskrift/article.html"); + tera.add_raw_template("tidsskrift/index.html", include_str!("templates/tidsskrift/index.html")) + .expect("Feil i tidsskrift/index.html"); + + tera +} + +// ============================================================================= +// Datamodeller for rendering +// ============================================================================= + +#[derive(Serialize, Clone)] +pub struct ArticleData { + pub id: String, + pub short_id: String, + pub title: String, + pub content: String, + pub summary: Option, + pub published_at: String, + pub published_at_short: String, +} + +#[derive(Serialize)] +pub struct IndexData { + pub title: String, + pub description: Option, + pub hero: Option, + pub featured: Vec, + pub stream: Vec, +} + +// ============================================================================= +// Render-funksjoner +// ============================================================================= + +/// Render en artikkel med gitt tema. +pub fn render_article( + tera: &Tera, + theme: &str, + config: &ThemeConfig, + article: &ArticleData, + collection_title: &str, + base_url: &str, +) -> Result { + let css_vars = build_css_variables(theme, config); + let template_name = format!("{theme}/article.html"); + + let mut ctx = Context::new(); + ctx.insert("css_variables", &css_vars); + ctx.insert("theme", theme); + ctx.insert("article", article); + ctx.insert("collection_title", collection_title); + ctx.insert("base_url", base_url); + ctx.insert("logo_hash", &config.logo_hash); + + tera.render(&template_name, &ctx) +} + +/// Render forsiden med gitt tema. +pub fn render_index( + tera: &Tera, + theme: &str, + config: &ThemeConfig, + index: &IndexData, + base_url: &str, +) -> Result { + let css_vars = build_css_variables(theme, config); + let template_name = format!("{theme}/index.html"); + + let mut ctx = Context::new(); + ctx.insert("css_variables", &css_vars); + ctx.insert("theme", theme); + ctx.insert("index", index); + ctx.insert("base_url", base_url); + ctx.insert("logo_hash", &config.logo_hash); + + tera.render(&template_name, &ctx) +} + +// ============================================================================= +// Database-spørringer +// ============================================================================= + +struct CollectionRow { + id: Uuid, + title: Option, + publishing_config: PublishingConfig, +} + +/// Finn samling med publishing-trait basert på slug. +async fn find_publishing_collection( + db: &PgPool, + slug: &str, +) -> Result, sqlx::Error> { + let row: Option<(Uuid, Option, serde_json::Value)> = sqlx::query_as( + r#" + SELECT id, title, metadata + FROM nodes + WHERE node_kind = 'collection' + AND metadata->'traits'->'publishing'->>'slug' = $1 + LIMIT 1 + "#, + ) + .bind(slug) + .fetch_optional(db) + .await?; + + let Some((id, title, metadata)) = row else { + return Ok(None); + }; + + let publishing_config: PublishingConfig = metadata + .get("traits") + .and_then(|t| t.get("publishing")) + .cloned() + .map(|v| serde_json::from_value(v).unwrap_or_default()) + .unwrap_or_default(); + + Ok(Some(CollectionRow { + id, + title, + publishing_config, + })) +} + +/// Hent artikkeldata for en enkelt node (belongs_to samlingen). +async fn fetch_article( + db: &PgPool, + collection_id: Uuid, + article_short_id: &str, +) -> Result)>, sqlx::Error> { + // short_id er de første 8 tegnene av UUID + let pattern = format!("{article_short_id}%"); + + let row: Option<(Uuid, Option, Option, DateTime, Option)> = 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.id::text LIKE $2 + LIMIT 1 + "#, + ) + .bind(collection_id) + .bind(&pattern) + .fetch_optional(db) + .await?; + + let Some((id, title, content, created_at, edge_meta)) = row else { + return Ok(None); + }; + + 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 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: None, + published_at: publish_at.to_rfc3339(), + published_at_short: publish_at.format("%e. %B %Y").to_string(), + }; + + Ok(Some((article, edge_meta))) +} + +/// Hent artikler for forsiden, sortert i slots. +async fn fetch_index_articles( + db: &PgPool, + collection_id: Uuid, + featured_max: i64, + stream_page_size: i64, +) -> Result<(Option, Vec, Vec), sqlx::Error> { + // Hent alle publiserte artikler med edge-metadata + let rows: Vec<(Uuid, Option, Option, DateTime, Option)> = 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 + "#, + ) + .bind(collection_id) + .fetch_all(db) + .await?; + + let mut hero: Option = None; + let mut featured: Vec = Vec::new(); + let mut stream: Vec = Vec::new(); + + for (id, title, content, created_at, edge_meta) in rows { + let slot = edge_meta + .as_ref() + .and_then(|m| m.get("slot")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + 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)); + + 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(), + }; + + match slot { + "hero" if hero.is_none() => hero = Some(article), + "featured" if (featured.len() as i64) < featured_max => featured.push(article), + _ => { + if (stream.len() as i64) < stream_page_size { + stream.push(article); + } + } + } + } + + Ok((hero, featured, stream)) +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + return s.to_string(); + } + match s[..max].rfind(' ') { + Some(pos) => format!("{}…", &s[..pos]), + None => format!("{}…", &s[..max]), + } +} + +// ============================================================================= +// HTTP-handlers +// ============================================================================= + +/// GET /pub/{slug} — forside for en publikasjon. +pub async fn serve_index( + 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"); + StatusCode::INTERNAL_SERVER_ERROR + })? + .ok_or(StatusCode::NOT_FOUND)?; + + let theme = collection.publishing_config.theme.as_deref().unwrap_or("blogg"); + let config = &collection.publishing_config.theme_config; + let featured_max = collection.publishing_config.featured_max.unwrap_or(4); + let stream_page_size = collection.publishing_config.stream_page_size.unwrap_or(20); + + let (hero, featured, stream) = fetch_index_articles( + &state.db, + collection.id, + featured_max, + stream_page_size, + ) + .await + .map_err(|e| { + tracing::error!(slug = %slug, error = %e, "Feil ved henting av forsideartikler"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + 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 index_data = IndexData { + title: collection_title, + description: None, + hero, + featured, + stream, + }; + + let tera = build_tera(); + let html = render_index(&tera, theme, config, &index_data, &base_url).map_err(|e| { + tracing::error!(slug = %slug, theme = %theme, error = %e, "Tera render-feil (index)"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Response::builder() + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .header(header::CACHE_CONTROL, "public, max-age=60") + .body(html.into()) + .unwrap()) +} + +/// GET /pub/{slug}/{article_id} — enkeltartikkel. +pub async fn serve_article( + State(state): State, + Path((slug, article_id)): Path<(String, String)>, +) -> Result { + let collection = find_publishing_collection(&state.db, &slug) + .await + .map_err(|e| { + tracing::error!(slug = %slug, error = %e, "Feil ved oppslag av samling"); + StatusCode::INTERNAL_SERVER_ERROR + })? + .ok_or(StatusCode::NOT_FOUND)?; + + let theme = collection.publishing_config.theme.as_deref().unwrap_or("blogg"); + let config = &collection.publishing_config.theme_config; + + let (article, _edge_meta) = fetch_article(&state.db, collection.id, &article_id) + .await + .map_err(|e| { + tracing::error!(slug = %slug, article = %article_id, error = %e, "Feil ved henting av artikkel"); + StatusCode::INTERNAL_SERVER_ERROR + })? + .ok_or(StatusCode::NOT_FOUND)?; + + 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 html = render_article(&tera, theme, config, &article, &collection_title, &base_url) + .map_err(|e| { + tracing::error!(slug = %slug, article = %article_id, theme = %theme, error = %e, "Tera render-feil (artikkel)"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Response::builder() + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .header(header::CACHE_CONTROL, "public, max-age=300") + .body(html.into()) + .unwrap()) +} + +/// GET /pub/{slug}/preview/{theme} — forhåndsvisning av tema med testdata. +/// Nyttig for å se hvordan et tema ser ut uten reelle data. +pub async fn preview_theme( + Path((slug, theme)): Path<(String, String)>, +) -> Result { + let valid_themes = ["avis", "magasin", "blogg", "tidsskrift"]; + if !valid_themes.contains(&theme.as_str()) { + return Err(StatusCode::NOT_FOUND); + } + + let config = ThemeConfig::default(); + + let sample_articles: Vec = (1..=6) + .map(|i| ArticleData { + id: format!("00000000-0000-0000-0000-00000000000{i}"), + short_id: format!("0000000{i}"), + title: format!("Eksempelartikkel {i}"), + content: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. \ + Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. \ + Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris." + .to_string(), + summary: Some("Lorem ipsum dolor sit amet, consectetur adipiscing elit.".to_string()), + published_at: "2026-03-18T12:00:00Z".to_string(), + published_at_short: "18. mars 2026".to_string(), + }) + .collect(); + + let index_data = IndexData { + title: format!("Forhåndsvisning — {theme}"), + description: Some("Eksempeldata for temavisning".to_string()), + hero: Some(sample_articles[0].clone()), + featured: sample_articles[1..4].to_vec(), + stream: sample_articles[4..].to_vec(), + }; + + let base_url = format!("/pub/{slug}"); + + let tera = build_tera(); + let html = render_index(&tera, &theme, &config, &index_data, &base_url).map_err(|e| { + tracing::error!(theme = %theme, error = %e, "Tera render-feil (preview)"); + 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()) +} + +// ============================================================================= +// Tester +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn css_variables_use_defaults() { + let config = ThemeConfig::default(); + let css = build_css_variables("avis", &config); + assert!(css.contains("--color-primary: #1a1a2e")); + assert!(css.contains("--font-heading: 'Georgia'")); + assert!(css.contains("--layout-max-width: 1200px")); + } + + #[test] + fn css_variables_override() { + let config = ThemeConfig { + colors: ColorConfig { + primary: Some("#ff0000".to_string()), + ..Default::default() + }, + ..Default::default() + }; + let css = build_css_variables("blogg", &config); + assert!(css.contains("--color-primary: #ff0000")); + // Andre verdier skal fortsatt bruke blogg-defaults + assert!(css.contains("--color-accent: #3498db")); + } + + #[test] + fn tera_builds_successfully() { + let tera = build_tera(); + // Alle 8 tema-templates + base skal finnes + let templates: Vec<&str> = tera.get_template_names().collect(); + assert!(templates.contains(&"base.html")); + assert!(templates.contains(&"avis/article.html")); + assert!(templates.contains(&"avis/index.html")); + assert!(templates.contains(&"magasin/article.html")); + assert!(templates.contains(&"magasin/index.html")); + assert!(templates.contains(&"blogg/article.html")); + assert!(templates.contains(&"blogg/index.html")); + assert!(templates.contains(&"tidsskrift/article.html")); + assert!(templates.contains(&"tidsskrift/index.html")); + } + + #[test] + fn render_article_all_themes() { + let tera = build_tera(); + let config = ThemeConfig::default(); + let article = ArticleData { + id: "test-id".to_string(), + short_id: "test-sho".to_string(), + title: "Testittel".to_string(), + content: "

Testinnhold

".to_string(), + summary: Some("Kort oppsummering".to_string()), + published_at: "2026-03-18T12:00:00Z".to_string(), + published_at_short: "18. mars 2026".to_string(), + }; + + for theme in &["avis", "magasin", "blogg", "tidsskrift"] { + let html = render_article(&tera, theme, &config, &article, "Testsamling", "/pub/test") + .unwrap_or_else(|e| panic!("Render feilet for {theme}: {e}")); + assert!(html.contains("Testittel"), "Tittel mangler i {theme}"); + assert!(html.contains("Testinnhold"), "Innhold mangler i {theme}"); + assert!(html.contains("--color-primary"), "CSS-variabler mangler i {theme}"); + } + } + + #[test] + fn render_index_all_themes() { + let tera = build_tera(); + let config = ThemeConfig::default(); + let index = IndexData { + title: "Testforside".to_string(), + description: None, + hero: None, + featured: vec![], + stream: vec![ArticleData { + id: "s1".to_string(), + short_id: "s1000000".to_string(), + title: "Strøm-artikkel".to_string(), + content: "Innhold".to_string(), + summary: Some("Sammendrag".to_string()), + published_at: "2026-03-18T12:00:00Z".to_string(), + published_at_short: "18. mars 2026".to_string(), + }], + }; + + for theme in &["avis", "magasin", "blogg", "tidsskrift"] { + let html = render_index(&tera, theme, &config, &index, "/pub/test") + .unwrap_or_else(|e| panic!("Render feilet for {theme}: {e}")); + assert!(html.contains("Testforside"), "Tittel mangler i {theme}"); + assert!(html.contains("Strøm-artikkel"), "Strøm-artikkel mangler i {theme}"); + } + } + + #[test] + fn unknown_theme_falls_back_to_blogg() { + let config = ThemeConfig::default(); + let css = build_css_variables("nonexistent", &config); + let css_blogg = build_css_variables("blogg", &config); + assert_eq!(css, css_blogg); + } +} diff --git a/maskinrommet/src/templates/avis/article.html b/maskinrommet/src/templates/avis/article.html new file mode 100644 index 0000000..4cd9772 --- /dev/null +++ b/maskinrommet/src/templates/avis/article.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} + +{% block title %}{{ article.title }} — {{ collection_title }}{% endblock %} + +{% block extra_css %} +.article { + max-width: var(--layout-max-width); + margin: 2rem auto; + padding: 0 1rem; + display: grid; + grid-template-columns: 1fr 300px; + gap: 2rem; +} +.article__main { min-width: 0; } +.article__sidebar { + border-left: 2px solid var(--color-accent); + padding-left: 1.5rem; +} +.article__title { + font-family: var(--font-heading); + font-size: 2.5rem; + line-height: 1.15; + margin-bottom: 0.5rem; + color: var(--color-primary); +} +.article__meta { + color: var(--color-muted); + font-size: 0.875rem; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--color-muted); +} +.article__content { + font-size: 1.05rem; + line-height: 1.75; +} +.article__content p { margin-bottom: 1em; } +.article__back { + display: inline-block; + margin-top: 2rem; + font-size: 0.9rem; +} + +@media (max-width: 768px) { + .article { grid-template-columns: 1fr; } + .article__sidebar { + border-left: none; + border-top: 2px solid var(--color-accent); + padding-left: 0; + padding-top: 1.5rem; + } +} +{% endblock %} + +{% block content %} +
+
+

{{ article.title }}

+ +
{{ article.content | safe }}
+ ← Tilbake til forsiden +
+ +
+{% endblock %} diff --git a/maskinrommet/src/templates/avis/index.html b/maskinrommet/src/templates/avis/index.html new file mode 100644 index 0000000..fbe32e8 --- /dev/null +++ b/maskinrommet/src/templates/avis/index.html @@ -0,0 +1,159 @@ +{% extends "base.html" %} + +{% block title %}{{ index.title }}{% endblock %} + +{% block extra_css %} +.avis-layout { + max-width: var(--layout-max-width); + margin: 1.5rem auto; + padding: 0 1rem; +} + +/* Hero */ +.hero { + border-bottom: 3px solid var(--color-primary); + padding-bottom: 1.5rem; + margin-bottom: 1.5rem; +} +.hero__title { + font-family: var(--font-heading); + font-size: 2.5rem; + line-height: 1.1; + color: var(--color-primary); + margin-bottom: 0.5rem; +} +.hero__summary { + font-size: 1.1rem; + color: var(--color-muted); + max-width: 60ch; +} +.hero__meta { + font-size: 0.8rem; + color: var(--color-muted); + margin-top: 0.5rem; +} + +/* Featured + sidebar grid */ +.avis-grid { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 1.5rem; + margin-bottom: 2rem; +} +.featured-list { display: flex; flex-direction: column; gap: 1rem; } +.featured-item { + border-bottom: 1px solid #e5e7eb; + padding-bottom: 1rem; +} +.featured-item__title { + font-family: var(--font-heading); + font-size: 1.25rem; + color: var(--color-primary); + margin-bottom: 0.25rem; +} +.featured-item__summary { + font-size: 0.9rem; + color: var(--color-muted); +} + +/* Sidebar */ +.sidebar { + border-left: 2px solid var(--color-accent); + padding-left: 1rem; +} +.sidebar__heading { + font-family: var(--font-heading); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-muted); + margin-bottom: 1rem; +} + +/* Stream */ +.stream { border-top: 2px solid var(--color-primary); padding-top: 1rem; } +.stream__heading { + font-family: var(--font-heading); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-muted); + margin-bottom: 1rem; +} +.stream-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 1rem; +} +.stream-item__title { + font-family: var(--font-heading); + font-size: 1rem; + color: var(--color-primary); +} +.stream-item__meta { + font-size: 0.8rem; + color: var(--color-muted); +} + +@media (max-width: 768px) { + .avis-grid { grid-template-columns: 1fr; } + .sidebar { border-left: none; border-top: 2px solid var(--color-accent); padding-left: 0; padding-top: 1rem; } + .hero__title { font-size: 1.75rem; } +} +{% endblock %} + +{% block content %} +
+ {% if index.hero %} +
+

{{ index.hero.title }}

+ {% if index.hero.summary %} +

{{ index.hero.summary }}

+ {% endif %} +
{{ index.hero.published_at_short }}
+
+ {% endif %} + + {% if index.featured | length > 0 or index.stream | length > 0 %} +
+ + +
+ {% endif %} + + {% if index.stream | length > 5 %} +
+
Flere saker
+
+ {% for item in index.stream %} + {% if loop.index > 5 %} +
+

{{ item.title }}

+
{{ item.published_at_short }}
+
+ {% endif %} + {% endfor %} +
+
+ {% endif %} +
+{% endblock %} diff --git a/maskinrommet/src/templates/base.html b/maskinrommet/src/templates/base.html new file mode 100644 index 0000000..ba31429 --- /dev/null +++ b/maskinrommet/src/templates/base.html @@ -0,0 +1,90 @@ + + + + + + {% block title %}{{ collection_title | default(value="Synops") }}{% endblock %} + + + + + +
+ {% block content %}{% endblock %} +
+ +
+
+ Drevet av Synops +
+
+ + diff --git a/maskinrommet/src/templates/blogg/article.html b/maskinrommet/src/templates/blogg/article.html new file mode 100644 index 0000000..b4d8ef0 --- /dev/null +++ b/maskinrommet/src/templates/blogg/article.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} + +{% block title %}{{ article.title }} — {{ collection_title }}{% endblock %} + +{% block extra_css %} +.blog-article { + max-width: var(--layout-max-width); + margin: 2rem auto; + padding: 0 1rem; +} +.blog-article__title { + font-family: var(--font-heading); + font-size: 2rem; + line-height: 1.2; + color: var(--color-primary); + margin-bottom: 0.5rem; +} +.blog-article__meta { + color: var(--color-muted); + font-size: 0.875rem; + margin-bottom: 2rem; +} +.blog-article__content { + font-size: 1.05rem; + line-height: 1.75; +} +.blog-article__content p { margin-bottom: 1em; } +.blog-article__back { + display: inline-block; + margin-top: 2rem; + font-size: 0.9rem; +} +{% endblock %} + +{% block content %} +
+

{{ article.title }}

+ +
+ {{ article.content | safe }} +
+ ← Tilbake +
+{% endblock %} diff --git a/maskinrommet/src/templates/blogg/index.html b/maskinrommet/src/templates/blogg/index.html new file mode 100644 index 0000000..05ce5d8 --- /dev/null +++ b/maskinrommet/src/templates/blogg/index.html @@ -0,0 +1,93 @@ +{% extends "base.html" %} + +{% block title %}{{ index.title }}{% endblock %} + +{% block extra_css %} +.blog-layout { + max-width: var(--layout-max-width); + margin: 2rem auto; + padding: 0 1rem; +} +.blog-list { list-style: none; } +.blog-item { + padding: 1.5rem 0; + border-bottom: 1px solid #f3f4f6; +} +.blog-item:first-child { padding-top: 0; } +.blog-item__title { + font-family: var(--font-heading); + font-size: 1.5rem; + color: var(--color-primary); + margin-bottom: 0.25rem; + line-height: 1.3; +} +.blog-item__meta { + font-size: 0.8rem; + color: var(--color-muted); + margin-bottom: 0.5rem; +} +.blog-item__summary { + font-size: 0.95rem; + color: var(--color-text); + line-height: 1.5; +} + +/* Pinned hero-artikkel */ +.blog-pinned { + background: #f8f9fa; + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 2rem; + border-left: 4px solid var(--color-accent); +} +.blog-pinned__label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-accent); + margin-bottom: 0.5rem; +} + +@media (max-width: 768px) { + .blog-item__title { font-size: 1.25rem; } +} +{% endblock %} + +{% block content %} +
+ {% if index.hero %} +
+
Fremhevet
+

{{ index.hero.title }}

+ {% if index.hero.summary %} +

{{ index.hero.summary }}

+ {% endif %} +
{{ index.hero.published_at_short }}
+
+ {% endif %} + + {% if index.featured | length > 0 %} + {% for item in index.featured %} +
+

{{ item.title }}

+ {% if item.summary %} +

{{ item.summary }}

+ {% endif %} +
{{ item.published_at_short }}
+
+ {% endfor %} + {% endif %} + +
    + {% for item in index.stream %} +
  • +

    {{ item.title }}

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

    {{ item.summary }}

    + {% endif %} +
  • + {% endfor %} +
+
+{% endblock %} diff --git a/maskinrommet/src/templates/magasin/article.html b/maskinrommet/src/templates/magasin/article.html new file mode 100644 index 0000000..6b3fdc2 --- /dev/null +++ b/maskinrommet/src/templates/magasin/article.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} + +{% block title %}{{ article.title }} — {{ collection_title }}{% endblock %} + +{% block extra_css %} +.mag-article { + max-width: var(--layout-max-width); + margin: 0 auto; + padding: 0 1rem; +} +.mag-article__header { + text-align: center; + padding: 3rem 0 2rem; + max-width: 720px; + margin: 0 auto; +} +.mag-article__title { + font-family: var(--font-heading); + font-size: 3rem; + line-height: 1.1; + color: var(--color-primary); + margin-bottom: 0.75rem; +} +.mag-article__meta { + color: var(--color-muted); + font-size: 0.9rem; +} +.mag-article__content { + max-width: 680px; + margin: 0 auto; + font-size: 1.1rem; + line-height: 1.8; + padding-bottom: 3rem; +} +.mag-article__content p { margin-bottom: 1.25em; } +.mag-article__back { + display: inline-block; + margin-top: 2rem; + font-size: 0.9rem; +} + +@media (max-width: 768px) { + .mag-article__title { font-size: 2rem; } + .mag-article__header { padding: 2rem 0 1.5rem; } +} +{% endblock %} + +{% block content %} +
+
+

{{ article.title }}

+ +
+
+ {{ article.content | safe }} + ← Tilbake +
+
+{% endblock %} diff --git a/maskinrommet/src/templates/magasin/index.html b/maskinrommet/src/templates/magasin/index.html new file mode 100644 index 0000000..ade075d --- /dev/null +++ b/maskinrommet/src/templates/magasin/index.html @@ -0,0 +1,132 @@ +{% extends "base.html" %} + +{% block title %}{{ index.title }}{% endblock %} + +{% block extra_css %} +.mag-layout { + max-width: var(--layout-max-width); + margin: 0 auto; + padding: 0 1rem; +} + +/* Hero — fullbredde */ +.mag-hero { + padding: 3rem 0; + text-align: center; + border-bottom: 1px solid #e5e7eb; + margin-bottom: 2rem; +} +.mag-hero__title { + font-family: var(--font-heading); + font-size: 3rem; + line-height: 1.1; + color: var(--color-primary); + margin-bottom: 0.75rem; +} +.mag-hero__summary { + font-size: 1.15rem; + color: var(--color-muted); + max-width: 50ch; + margin: 0 auto; +} +.mag-hero__meta { + font-size: 0.85rem; + color: var(--color-muted); + margin-top: 0.75rem; +} + +/* Featured cards */ +.mag-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 2rem; + margin-bottom: 3rem; +} +.mag-card { + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 1.5rem; + transition: box-shadow 0.2s; +} +.mag-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.08); } +.mag-card__title { + font-family: var(--font-heading); + font-size: 1.35rem; + color: var(--color-primary); + margin-bottom: 0.5rem; + line-height: 1.25; +} +.mag-card__summary { + font-size: 0.95rem; + color: var(--color-muted); + line-height: 1.5; +} +.mag-card__meta { + font-size: 0.8rem; + color: var(--color-muted); + margin-top: 0.75rem; +} + +/* Kronologisk strøm */ +.mag-stream { border-top: 2px solid var(--color-primary); padding-top: 1.5rem; } +.mag-stream__heading { + font-family: var(--font-heading); + font-size: 1.25rem; + color: var(--color-primary); + margin-bottom: 1rem; +} +.mag-stream-item { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 0.75rem 0; + border-bottom: 1px solid #f3f4f6; +} +.mag-stream-item__title { font-size: 1rem; } +.mag-stream-item__date { font-size: 0.8rem; color: var(--color-muted); white-space: nowrap; margin-left: 1rem; } + +@media (max-width: 768px) { + .mag-hero__title { font-size: 2rem; } + .mag-hero { padding: 2rem 0; } +} +{% endblock %} + +{% block content %} +
+ {% if index.hero %} +
+

{{ index.hero.title }}

+ {% if index.hero.summary %} +

{{ index.hero.summary }}

+ {% endif %} +
{{ index.hero.published_at_short }}
+
+ {% endif %} + + {% if index.featured | length > 0 %} +
+ {% for item in index.featured %} +
+

{{ item.title }}

+ {% if item.summary %} +

{{ item.summary }}

+ {% endif %} +
{{ item.published_at_short }}
+
+ {% endfor %} +
+ {% endif %} + + {% if index.stream | length > 0 %} +
+

Alle artikler

+ {% for item in index.stream %} +
+ {{ item.title }} + {{ item.published_at_short }} +
+ {% endfor %} +
+ {% endif %} +
+{% endblock %} diff --git a/maskinrommet/src/templates/tidsskrift/article.html b/maskinrommet/src/templates/tidsskrift/article.html new file mode 100644 index 0000000..6d87fac --- /dev/null +++ b/maskinrommet/src/templates/tidsskrift/article.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} + +{% block title %}{{ article.title }} — {{ collection_title }}{% endblock %} + +{% block extra_css %} +.journal-article { + max-width: var(--layout-max-width); + margin: 3rem auto; + padding: 0 1rem; +} +.journal-article__title { + font-family: var(--font-heading); + font-size: 1.75rem; + line-height: 1.3; + color: var(--color-primary); + margin-bottom: 0.25rem; + text-align: center; +} +.journal-article__meta { + color: var(--color-muted); + font-size: 0.85rem; + text-align: center; + margin-bottom: 2rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--color-muted); +} +.journal-article__content { + font-size: 1rem; + line-height: 1.8; + text-align: justify; + hyphens: auto; +} +.journal-article__content p { margin-bottom: 1em; text-indent: 1.5em; } +.journal-article__content p:first-child { text-indent: 0; } +.journal-article__back { + display: inline-block; + margin-top: 2rem; + font-size: 0.85rem; +} +{% endblock %} + +{% block content %} + +{% endblock %} diff --git a/maskinrommet/src/templates/tidsskrift/index.html b/maskinrommet/src/templates/tidsskrift/index.html new file mode 100644 index 0000000..747e9a3 --- /dev/null +++ b/maskinrommet/src/templates/tidsskrift/index.html @@ -0,0 +1,133 @@ +{% extends "base.html" %} + +{% block title %}{{ index.title }}{% endblock %} + +{% block extra_css %} +.journal-layout { + max-width: var(--layout-max-width); + margin: 3rem auto; + padding: 0 1rem; +} +.journal-header { + text-align: center; + margin-bottom: 2rem; + padding-bottom: 1.5rem; + border-bottom: 2px solid var(--color-primary); +} +.journal-header__title { + font-family: var(--font-heading); + font-size: 2rem; + color: var(--color-primary); +} +.journal-header__desc { + font-size: 0.95rem; + color: var(--color-muted); + font-style: italic; + margin-top: 0.5rem; +} + +/* Nummerert innholdsfortegnelse */ +.journal-toc { + counter-reset: article-counter; + list-style: none; +} +.journal-toc__item { + counter-increment: article-counter; + padding: 1rem 0; + border-bottom: 1px solid #eee; + display: flex; + align-items: baseline; + gap: 1rem; +} +.journal-toc__item::before { + content: counter(article-counter) "."; + font-family: var(--font-heading); + font-size: 1.1rem; + color: var(--color-muted); + min-width: 2rem; + text-align: right; +} +.journal-toc__title { + font-family: var(--font-heading); + font-size: 1.1rem; + color: var(--color-primary); + line-height: 1.3; +} +.journal-toc__meta { + font-size: 0.8rem; + color: var(--color-muted); + margin-top: 0.25rem; +} + +/* Fremhevet (hero/featured) */ +.journal-featured { + margin-bottom: 2rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--color-primary); +} +.journal-featured__label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--color-accent); + margin-bottom: 0.5rem; +} +.journal-featured__title { + font-family: var(--font-heading); + font-size: 1.5rem; + color: var(--color-primary); + line-height: 1.3; +} +.journal-featured__summary { + font-size: 0.95rem; + color: var(--color-text); + margin-top: 0.5rem; + line-height: 1.5; +} +{% endblock %} + +{% block content %} +
+
+

{{ index.title }}

+ {% if index.description %} +

{{ index.description }}

+ {% endif %} +
+ + {% if index.hero %} + + {% endif %} + + {% if index.featured | length > 0 %} + {% for item in index.featured %} + + {% endfor %} + {% endif %} + + {% if index.stream | length > 0 %} +

Innholdsfortegnelse

+
    + {% for item in index.stream %} +
  1. +
    + +
    {{ item.published_at_short }}
    +
    +
  2. + {% endfor %} +
+ {% endif %} +
+{% endblock %} diff --git a/tasks.md b/tasks.md index 48c7daf..0468419 100644 --- a/tasks.md +++ b/tasks.md @@ -137,8 +137,7 @@ Uavhengige faser kan fortsatt plukkes. ## Fase 14: Publisering -- [~] 14.1 Tera-templates: innebygde temaer (avis, magasin, blogg, tidsskrift) med Tera i Rust. Artikkelmal + forside-mal per tema. CSS-variabler for theme_config-overstyring. Ref: `docs/concepts/publisering.md` § "Temaer". - > Påbegynt: 2026-03-18T00:33 +- [x] 14.1 Tera-templates: innebygde temaer (avis, magasin, blogg, tidsskrift) med Tera i Rust. Artikkelmal + forside-mal per tema. CSS-variabler for theme_config-overstyring. Ref: `docs/concepts/publisering.md` § "Temaer". - [ ] 14.2 HTML-rendering av enkeltartikler: maskinrommet rendrer `metadata.document` til HTML via Tera, lagrer i CAS. Noden får `metadata.rendered.html_hash` + `renderer_version`. SEO-metadata (OG-tags, canonical, JSON-LD). - [ ] 14.3 Forside-rendering: maskinrommet spør PG for hero/featured/strøm (tre indekserte spørringer), appliserer tema-template, rendrer til CAS (statisk modus) eller serverer med in-memory cache (dynamisk modus). `index_mode` og `index_cache_ttl` i trait-konfig. - [ ] 14.4 Caddy-ruting for synops.no/pub: Caddy reverse-proxyer til maskinrommet som gjør slug→hash-oppslag og streamer CAS-fil. `Cache-Control: immutable` for artikler. Kategori/arkiv/søk serveres dynamisk av maskinrommet med kortere cache-TTL.