Implementer synops-render CLI-verktøy (oppgave 21.3)
Nytt CLI-verktøy som rendrer artikler og forsider til HTML via Tera-templates og lagrer resultatet i CAS. Erstatter rendering-logikken i maskinrommet/src/publishing.rs som standalone verktøy. Støtter to render-typer: - article: Rendrer enkeltartikkel med SEO-metadata, presentasjonselementer, TipTap→HTML-konvertering, og tema-basert CSS. - index: Rendrer forside med hero/featured/stream-artikler. Fire innebygde temaer: avis, magasin, blogg, tidsskrift. Templates er kopiert fra maskinrommet og innebygd via include_str!(). TipTap-modulen er duplisert inntil synops-common (21.16) samler felles kode. Følger eksisterende CLI-mønster: --write gater DB-oppdateringer, JSON til stdout, stderr for logging. 16 enhetstester dekker CSS-variabler, SEO, kategorisering, rendering og TipTap-konvertering. Verifisert mot produksjons-DB. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
46fe19b78d
commit
17e35b2644
19 changed files with 5645 additions and 2 deletions
3
tasks.md
3
tasks.md
|
|
@ -243,8 +243,7 @@ kaller dem direkte. Samme verktøy, to brukere.
|
||||||
|
|
||||||
- [x] 21.1 `synops-transcribe`: Whisper-transkribering. Input: `--cas-hash <hash> --model <model> [--initial-prompt <tekst>]`. Output: JSON med segmenter. Skriver segmenter til PG, oppdaterer node metadata. Erstatter `transcribe.rs`.
|
- [x] 21.1 `synops-transcribe`: Whisper-transkribering. Input: `--cas-hash <hash> --model <model> [--initial-prompt <tekst>]`. Output: JSON med segmenter. Skriver segmenter til PG, oppdaterer node metadata. Erstatter `transcribe.rs`.
|
||||||
- [x] 21.2 `synops-audio`: FFmpeg-prosessering. Input: `--cas-hash <hash> --edl <json>`. Output: ny CAS-hash. Erstatter `audio.rs`. Inkluder parametervalidering (fase 17.2–17.3).
|
- [x] 21.2 `synops-audio`: FFmpeg-prosessering. Input: `--cas-hash <hash> --edl <json>`. Output: ny CAS-hash. Erstatter `audio.rs`. Inkluder parametervalidering (fase 17.2–17.3).
|
||||||
- [~] 21.3 `synops-render`: Tera HTML-rendering. Input: `--node-id <uuid> --theme <tema>`. Output: CAS-hash for rendret HTML. Erstatter `publishing.rs`.
|
- [x] 21.3 `synops-render`: Tera HTML-rendering. Input: `--node-id <uuid> --theme <tema>`. Output: CAS-hash for rendret HTML. Erstatter `publishing.rs`.
|
||||||
> Påbegynt: 2026-03-18T09:10
|
|
||||||
- [ ] 21.4 `synops-rss`: RSS/Atom-generering. Input: `--collection-id <uuid>`. Output: XML til stdout. Erstatter `rss.rs`.
|
- [ ] 21.4 `synops-rss`: RSS/Atom-generering. Input: `--collection-id <uuid>`. Output: XML til stdout. Erstatter `rss.rs`.
|
||||||
- [ ] 21.5 `synops-tts`: Tekst-til-tale. Input: `--text <tekst> --voice <stemme>`. Output: CAS-hash for lydfil. Erstatter `tts.rs`.
|
- [ ] 21.5 `synops-tts`: Tekst-til-tale. Input: `--text <tekst> --voice <stemme>`. Output: CAS-hash for lydfil. Erstatter `tts.rs`.
|
||||||
- [ ] 21.6 `synops-summarize`: AI-oppsummering. Input: `--communication-id <uuid>`. Output: sammendrag som tekst. Erstatter `summarize.rs`.
|
- [ ] 21.6 `synops-summarize`: AI-oppsummering. Input: `--communication-id <uuid>`. Output: sammendrag som tekst. Erstatter `summarize.rs`.
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ eller maskinrommet-API. Ligger i PATH via symlink eller direkte kall.
|
||||||
|---------|-------------|--------|
|
|---------|-------------|--------|
|
||||||
| `synops-transcribe` | Whisper-transkribering av lydfil fra CAS | Ferdig |
|
| `synops-transcribe` | Whisper-transkribering av lydfil fra CAS | Ferdig |
|
||||||
| `synops-audio` | FFmpeg lydprosessering med EDL (cut, normalize, EQ, m.m.) | Ferdig |
|
| `synops-audio` | FFmpeg lydprosessering med EDL (cut, normalize, EQ, m.m.) | Ferdig |
|
||||||
|
| `synops-render` | Tera HTML-rendering til CAS (artikler, forsider) | Ferdig |
|
||||||
|
|
||||||
## Konvensjoner
|
## Konvensjoner
|
||||||
- Navnekonvensjon: `synops-<verb>` (f.eks. `synops-context`)
|
- Navnekonvensjon: `synops-<verb>` (f.eks. `synops-context`)
|
||||||
|
|
|
||||||
2722
tools/synops-render/Cargo.lock
generated
Normal file
2722
tools/synops-render/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
22
tools/synops-render/Cargo.toml
Normal file
22
tools/synops-render/Cargo.toml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
[package]
|
||||||
|
name = "synops-render"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "synops-render"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
uuid = { version = "1", features = ["v7", "serde"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
tera = "1"
|
||||||
|
sha2 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
1238
tools/synops-render/src/main.rs
Normal file
1238
tools/synops-render/src/main.rs
Normal file
File diff suppressed because it is too large
Load diff
54
tools/synops-render/src/templates/about.html
Normal file
54
tools/synops-render/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
tools/synops-render/src/templates/archive.html
Normal file
126
tools/synops-render/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 %}
|
||||||
82
tools/synops-render/src/templates/avis/article.html
Normal file
82
tools/synops-render/src/templates/avis/article.html
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ article.title }} — {{ collection_title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block seo %}
|
||||||
|
<meta name="description" content="{{ seo.description }}">
|
||||||
|
<link rel="canonical" href="{{ seo.canonical_url }}">
|
||||||
|
<meta property="og:type" content="article">
|
||||||
|
<meta property="og:title" content="{{ seo.og_title }}">
|
||||||
|
<meta property="og:description" content="{{ seo.description }}">
|
||||||
|
<meta property="og:url" content="{{ seo.canonical_url }}">
|
||||||
|
<meta property="og:site_name" content="{{ collection_title }}">
|
||||||
|
{% if seo.og_image %}<meta property="og:image" content="{{ seo.og_image }}">{% endif %}
|
||||||
|
<meta property="article:published_time" content="{{ article.published_at }}">
|
||||||
|
<link rel="alternate" type="application/atom+xml" title="{{ collection_title }} — RSS" href="{{ base_url }}/feed.xml">
|
||||||
|
<script type="application/ld+json">{{ seo.json_ld | safe }}</script>
|
||||||
|
{% 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 class="article">
|
||||||
|
<div class="article__main">
|
||||||
|
{% if article.og_image %}<img src="/cas/{{ article.og_image }}" alt="{{ article.title }}" style="width:100%;max-height:400px;object-fit:cover;margin-bottom:1.5rem;">{% endif %}
|
||||||
|
<h1 class="article__title">{{ article.title }}</h1>
|
||||||
|
{% if article.subtitle %}<p style="font-size:1.15rem;color:var(--color-muted);margin-bottom:0.5rem;font-family:var(--font-heading);">{{ article.subtitle }}</p>{% endif %}
|
||||||
|
<div class="article__meta">Publisert {{ article.published_at_short }}</div>
|
||||||
|
<div class="article__content">{{ article.content | safe }}</div>
|
||||||
|
<a class="article__back" href="{{ base_url }}">← Tilbake til forsiden</a>
|
||||||
|
</div>
|
||||||
|
<aside class="article__sidebar">
|
||||||
|
</aside>
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
159
tools/synops-render/src/templates/avis/index.html
Normal file
159
tools/synops-render/src/templates/avis/index.html
Normal file
|
|
@ -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 %}
|
||||||
|
<div class="avis-layout">
|
||||||
|
{% if index.hero %}
|
||||||
|
<div class="hero">
|
||||||
|
<h2 class="hero__title"><a href="{{ base_url }}/{{ index.hero.short_id }}">{{ index.hero.title }}</a></h2>
|
||||||
|
{% if index.hero.summary %}
|
||||||
|
<p class="hero__summary">{{ index.hero.summary }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="hero__meta">{{ index.hero.published_at_short }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if index.featured | length > 0 or index.stream | length > 0 %}
|
||||||
|
<div class="avis-grid">
|
||||||
|
<div class="featured-list">
|
||||||
|
{% for item in index.featured %}
|
||||||
|
<div class="featured-item">
|
||||||
|
<h3 class="featured-item__title"><a href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a></h3>
|
||||||
|
{% if item.summary %}
|
||||||
|
<p class="featured-item__summary">{{ item.summary }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar__heading">Siste nytt</div>
|
||||||
|
{% for item in index.stream %}
|
||||||
|
{% if loop.index <= 5 %}
|
||||||
|
<div style="margin-bottom: 0.75rem;">
|
||||||
|
<a href="{{ base_url }}/{{ item.short_id }}" style="font-size: 0.9rem;">{{ item.title }}</a>
|
||||||
|
<div style="font-size: 0.75rem; color: var(--color-muted);">{{ item.published_at_short }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if index.stream | length > 5 %}
|
||||||
|
<section class="stream">
|
||||||
|
<div class="stream__heading">Flere saker</div>
|
||||||
|
<div class="stream-grid">
|
||||||
|
{% for item in index.stream %}
|
||||||
|
{% if loop.index > 5 %}
|
||||||
|
<div class="stream-item">
|
||||||
|
<h4 class="stream-item__title"><a href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a></h4>
|
||||||
|
<div class="stream-item__meta">{{ item.published_at_short }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
94
tools/synops-render/src/templates/base.html
Normal file
94
tools/synops-render/src/templates/base.html
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="no">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}{{ collection_title | default(value="Synops") }}{% endblock %}</title>
|
||||||
|
{% if has_rss %}<link rel="alternate" type="application/rss+xml" title="{{ collection_title | default(value='RSS') }}" href="{{ base_url }}/feed.xml">{% endif %}
|
||||||
|
{% block seo %}{% endblock %}
|
||||||
|
<style>
|
||||||
|
{{ css_variables | safe }}
|
||||||
|
|
||||||
|
/* Reset */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-background);
|
||||||
|
line-height: 1.6;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--color-accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
img { max-width: 100%; height: auto; display: block; }
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
border-bottom: 1px solid var(--color-muted);
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
.site-header__inner {
|
||||||
|
max-width: var(--layout-max-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.site-title {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.site-title a { color: inherit; }
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
border-top: 1px solid var(--color-muted);
|
||||||
|
padding: 2rem 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: var(--layout-max-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
{% block extra_css %}{% endblock %}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="site-header__inner">
|
||||||
|
<div class="site-title">
|
||||||
|
{% if logo_hash %}
|
||||||
|
<a href="{{ base_url }}"><img src="/cas/{{ logo_hash }}" alt="{{ index.title | default(value=collection_title) }}" style="max-height: 2.5rem;"></a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ base_url }}">{{ index.title | default(value=collection_title) | default(value="Synops") }}</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<nav style="display:flex;gap:1rem;align-items:center;font-size:0.9rem;">
|
||||||
|
<a href="{{ base_url }}/arkiv">Arkiv</a>
|
||||||
|
<a href="{{ base_url }}/sok">Søk</a>
|
||||||
|
{% if has_rss %}<a href="{{ base_url }}/feed.xml" title="RSS-feed">RSS</a>{% endif %}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="container">
|
||||||
|
Drevet av Synops
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
60
tools/synops-render/src/templates/blogg/article.html
Normal file
60
tools/synops-render/src/templates/blogg/article.html
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ article.title }} — {{ collection_title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block seo %}
|
||||||
|
<meta name="description" content="{{ seo.description }}">
|
||||||
|
<link rel="canonical" href="{{ seo.canonical_url }}">
|
||||||
|
<meta property="og:type" content="article">
|
||||||
|
<meta property="og:title" content="{{ seo.og_title }}">
|
||||||
|
<meta property="og:description" content="{{ seo.description }}">
|
||||||
|
<meta property="og:url" content="{{ seo.canonical_url }}">
|
||||||
|
<meta property="og:site_name" content="{{ collection_title }}">
|
||||||
|
{% if seo.og_image %}<meta property="og:image" content="{{ seo.og_image }}">{% endif %}
|
||||||
|
<meta property="article:published_time" content="{{ article.published_at }}">
|
||||||
|
<link rel="alternate" type="application/atom+xml" title="{{ collection_title }} — RSS" href="{{ base_url }}/feed.xml">
|
||||||
|
<script type="application/ld+json">{{ seo.json_ld | safe }}</script>
|
||||||
|
{% 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 class="blog-article">
|
||||||
|
{% if article.og_image %}<img src="/cas/{{ article.og_image }}" alt="{{ article.title }}" style="width:100%;max-height:400px;object-fit:cover;border-radius:0.5rem;margin-bottom:1.5rem;">{% endif %}
|
||||||
|
<h1 class="blog-article__title">{{ article.title }}</h1>
|
||||||
|
{% if article.subtitle %}<p style="font-size:1.2rem;color:var(--color-muted);margin-bottom:0.5rem;font-family:var(--font-heading);">{{ article.subtitle }}</p>{% endif %}
|
||||||
|
<div class="blog-article__meta">{{ article.published_at_short }}</div>
|
||||||
|
<div class="blog-article__content">
|
||||||
|
{{ article.content | safe }}
|
||||||
|
</div>
|
||||||
|
<a class="blog-article__back" href="{{ base_url }}">← Tilbake</a>
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
93
tools/synops-render/src/templates/blogg/index.html
Normal file
93
tools/synops-render/src/templates/blogg/index.html
Normal file
|
|
@ -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 %}
|
||||||
|
<div class="blog-layout">
|
||||||
|
{% if index.hero %}
|
||||||
|
<div class="blog-pinned">
|
||||||
|
<div class="blog-pinned__label">Fremhevet</div>
|
||||||
|
<h2 class="blog-item__title"><a href="{{ base_url }}/{{ index.hero.short_id }}">{{ index.hero.title }}</a></h2>
|
||||||
|
{% if index.hero.summary %}
|
||||||
|
<p class="blog-item__summary">{{ index.hero.summary }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="blog-item__meta">{{ index.hero.published_at_short }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if index.featured | length > 0 %}
|
||||||
|
{% for item in index.featured %}
|
||||||
|
<div class="blog-item" style="border-left: 3px solid var(--color-accent); padding-left: 1rem; margin-bottom: 1rem;">
|
||||||
|
<h3 class="blog-item__title"><a href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a></h3>
|
||||||
|
{% if item.summary %}
|
||||||
|
<p class="blog-item__summary">{{ item.summary }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="blog-item__meta">{{ item.published_at_short }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<ul class="blog-list">
|
||||||
|
{% for item in index.stream %}
|
||||||
|
<li class="blog-item">
|
||||||
|
<h3 class="blog-item__title"><a href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a></h3>
|
||||||
|
<div class="blog-item__meta">{{ item.published_at_short }}</div>
|
||||||
|
{% if item.summary %}
|
||||||
|
<p class="blog-item__summary">{{ item.summary }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
133
tools/synops-render/src/templates/category.html
Normal file
133
tools/synops-render/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 %}
|
||||||
75
tools/synops-render/src/templates/magasin/article.html
Normal file
75
tools/synops-render/src/templates/magasin/article.html
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ article.title }} — {{ collection_title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block seo %}
|
||||||
|
<meta name="description" content="{{ seo.description }}">
|
||||||
|
<link rel="canonical" href="{{ seo.canonical_url }}">
|
||||||
|
<meta property="og:type" content="article">
|
||||||
|
<meta property="og:title" content="{{ seo.og_title }}">
|
||||||
|
<meta property="og:description" content="{{ seo.description }}">
|
||||||
|
<meta property="og:url" content="{{ seo.canonical_url }}">
|
||||||
|
<meta property="og:site_name" content="{{ collection_title }}">
|
||||||
|
{% if seo.og_image %}<meta property="og:image" content="{{ seo.og_image }}">{% endif %}
|
||||||
|
<meta property="article:published_time" content="{{ article.published_at }}">
|
||||||
|
<link rel="alternate" type="application/atom+xml" title="{{ collection_title }} — RSS" href="{{ base_url }}/feed.xml">
|
||||||
|
<script type="application/ld+json">{{ seo.json_ld | safe }}</script>
|
||||||
|
{% 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 class="mag-article">
|
||||||
|
{% if article.og_image %}<img src="/cas/{{ article.og_image }}" alt="{{ article.title }}" style="width:100%;max-height:500px;object-fit:cover;margin-bottom:0;">{% endif %}
|
||||||
|
<header class="mag-article__header">
|
||||||
|
<h1 class="mag-article__title">{{ article.title }}</h1>
|
||||||
|
{% if article.subtitle %}<p style="font-size:1.25rem;color:var(--color-muted);margin-top:0.5rem;font-family:var(--font-heading);">{{ article.subtitle }}</p>{% endif %}
|
||||||
|
<div class="mag-article__meta">Publisert {{ article.published_at_short }}</div>
|
||||||
|
</header>
|
||||||
|
<div class="mag-article__content">
|
||||||
|
{{ article.content | safe }}
|
||||||
|
<a class="mag-article__back" href="{{ base_url }}">← Tilbake</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
132
tools/synops-render/src/templates/magasin/index.html
Normal file
132
tools/synops-render/src/templates/magasin/index.html
Normal file
|
|
@ -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 %}
|
||||||
|
<div class="mag-layout">
|
||||||
|
{% if index.hero %}
|
||||||
|
<div class="mag-hero">
|
||||||
|
<h2 class="mag-hero__title"><a href="{{ base_url }}/{{ index.hero.short_id }}">{{ index.hero.title }}</a></h2>
|
||||||
|
{% if index.hero.summary %}
|
||||||
|
<p class="mag-hero__summary">{{ index.hero.summary }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="mag-hero__meta">{{ index.hero.published_at_short }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if index.featured | length > 0 %}
|
||||||
|
<div class="mag-cards">
|
||||||
|
{% for item in index.featured %}
|
||||||
|
<div class="mag-card">
|
||||||
|
<h3 class="mag-card__title"><a href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a></h3>
|
||||||
|
{% if item.summary %}
|
||||||
|
<p class="mag-card__summary">{{ item.summary }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="mag-card__meta">{{ item.published_at_short }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if index.stream | length > 0 %}
|
||||||
|
<section class="mag-stream">
|
||||||
|
<h3 class="mag-stream__heading">Alle artikler</h3>
|
||||||
|
{% for item in index.stream %}
|
||||||
|
<div class="mag-stream-item">
|
||||||
|
<a class="mag-stream-item__title" href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a>
|
||||||
|
<span class="mag-stream-item__date">{{ item.published_at_short }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
162
tools/synops-render/src/templates/search.html
Normal file
162
tools/synops-render/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 %}
|
||||||
67
tools/synops-render/src/templates/tidsskrift/article.html
Normal file
67
tools/synops-render/src/templates/tidsskrift/article.html
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ article.title }} — {{ collection_title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block seo %}
|
||||||
|
<meta name="description" content="{{ seo.description }}">
|
||||||
|
<link rel="canonical" href="{{ seo.canonical_url }}">
|
||||||
|
<meta property="og:type" content="article">
|
||||||
|
<meta property="og:title" content="{{ seo.og_title }}">
|
||||||
|
<meta property="og:description" content="{{ seo.description }}">
|
||||||
|
<meta property="og:url" content="{{ seo.canonical_url }}">
|
||||||
|
<meta property="og:site_name" content="{{ collection_title }}">
|
||||||
|
{% if seo.og_image %}<meta property="og:image" content="{{ seo.og_image }}">{% endif %}
|
||||||
|
<meta property="article:published_time" content="{{ article.published_at }}">
|
||||||
|
<link rel="alternate" type="application/atom+xml" title="{{ collection_title }} — RSS" href="{{ base_url }}/feed.xml">
|
||||||
|
<script type="application/ld+json">{{ seo.json_ld | safe }}</script>
|
||||||
|
{% 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 %}
|
||||||
|
<article class="journal-article">
|
||||||
|
{% if article.og_image %}<div style="text-align:center;margin-bottom:1.5rem;"><img src="/cas/{{ article.og_image }}" alt="{{ article.title }}" style="max-width:100%;max-height:400px;object-fit:cover;"></div>{% endif %}
|
||||||
|
<h1 class="journal-article__title">{{ article.title }}</h1>
|
||||||
|
{% if article.subtitle %}<p style="font-size:1.1rem;color:var(--color-muted);text-align:center;margin-bottom:0.5rem;font-family:var(--font-heading);">{{ article.subtitle }}</p>{% endif %}
|
||||||
|
<div class="journal-article__meta">Publisert {{ article.published_at_short }}</div>
|
||||||
|
<div class="journal-article__content">
|
||||||
|
{{ article.content | safe }}
|
||||||
|
</div>
|
||||||
|
<a class="journal-article__back" href="{{ base_url }}">← Tilbake til innholdsfortegnelse</a>
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
133
tools/synops-render/src/templates/tidsskrift/index.html
Normal file
133
tools/synops-render/src/templates/tidsskrift/index.html
Normal file
|
|
@ -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 %}
|
||||||
|
<div class="journal-layout">
|
||||||
|
<header class="journal-header">
|
||||||
|
<h1 class="journal-header__title">{{ index.title }}</h1>
|
||||||
|
{% if index.description %}
|
||||||
|
<p class="journal-header__desc">{{ index.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if index.hero %}
|
||||||
|
<div class="journal-featured">
|
||||||
|
<div class="journal-featured__label">Hovedartikkel</div>
|
||||||
|
<h2 class="journal-featured__title"><a href="{{ base_url }}/{{ index.hero.short_id }}">{{ index.hero.title }}</a></h2>
|
||||||
|
{% if index.hero.summary %}
|
||||||
|
<p class="journal-featured__summary">{{ index.hero.summary }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if index.featured | length > 0 %}
|
||||||
|
{% for item in index.featured %}
|
||||||
|
<div class="journal-featured" style="border-bottom-color: #eee;">
|
||||||
|
<h3 class="journal-featured__title"><a href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a></h3>
|
||||||
|
{% if item.summary %}
|
||||||
|
<p class="journal-featured__summary">{{ item.summary }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if index.stream | length > 0 %}
|
||||||
|
<h2 style="font-family: var(--font-heading); font-size: 1rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-muted); margin-bottom: 0.5rem;">Innholdsfortegnelse</h2>
|
||||||
|
<ol class="journal-toc">
|
||||||
|
{% for item in index.stream %}
|
||||||
|
<li class="journal-toc__item">
|
||||||
|
<div>
|
||||||
|
<div class="journal-toc__title"><a href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a></div>
|
||||||
|
<div class="journal-toc__meta">{{ item.published_at_short }}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
291
tools/synops-render/src/tiptap.rs
Normal file
291
tools/synops-render/src/tiptap.rs
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
//! TipTap/ProseMirror JSON → HTML-konvertering.
|
||||||
|
//!
|
||||||
|
//! Konverterer `metadata.document` (TipTap JSON) til HTML-streng.
|
||||||
|
//! Identisk med maskinrommet/src/tiptap.rs — delt logikk.
|
||||||
|
//! Ref: oppgave 21.16 (synops-common) vil samle dette i felles crate.
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// Konverter et TipTap/ProseMirror-dokument (JSON) til HTML.
|
||||||
|
/// Returnerer tom streng hvis dokumentet er ugyldig.
|
||||||
|
pub fn document_to_html(doc: &Value) -> String {
|
||||||
|
let Some(content) = doc.get("content").and_then(|c| c.as_array()) else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut html = String::new();
|
||||||
|
for node in content {
|
||||||
|
render_node(node, &mut html);
|
||||||
|
}
|
||||||
|
html
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_node(node: &Value, out: &mut String) {
|
||||||
|
let node_type = node.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||||
|
|
||||||
|
match node_type {
|
||||||
|
"paragraph" => {
|
||||||
|
out.push_str("<p>");
|
||||||
|
render_inline_content(node, out);
|
||||||
|
out.push_str("</p>\n");
|
||||||
|
}
|
||||||
|
"heading" => {
|
||||||
|
let level = node
|
||||||
|
.get("attrs")
|
||||||
|
.and_then(|a| a.get("level"))
|
||||||
|
.and_then(|l| l.as_u64())
|
||||||
|
.unwrap_or(2)
|
||||||
|
.min(6);
|
||||||
|
out.push_str(&format!("<h{level}>"));
|
||||||
|
render_inline_content(node, out);
|
||||||
|
out.push_str(&format!("</h{level}>\n"));
|
||||||
|
}
|
||||||
|
"blockquote" => {
|
||||||
|
out.push_str("<blockquote>\n");
|
||||||
|
render_children(node, out);
|
||||||
|
out.push_str("</blockquote>\n");
|
||||||
|
}
|
||||||
|
"bulletList" | "bullet_list" => {
|
||||||
|
out.push_str("<ul>\n");
|
||||||
|
render_children(node, out);
|
||||||
|
out.push_str("</ul>\n");
|
||||||
|
}
|
||||||
|
"orderedList" | "ordered_list" => {
|
||||||
|
let start = node
|
||||||
|
.get("attrs")
|
||||||
|
.and_then(|a| a.get("start"))
|
||||||
|
.and_then(|s| s.as_u64())
|
||||||
|
.unwrap_or(1);
|
||||||
|
if start == 1 {
|
||||||
|
out.push_str("<ol>\n");
|
||||||
|
} else {
|
||||||
|
out.push_str(&format!("<ol start=\"{start}\">\n"));
|
||||||
|
}
|
||||||
|
render_children(node, out);
|
||||||
|
out.push_str("</ol>\n");
|
||||||
|
}
|
||||||
|
"listItem" | "list_item" => {
|
||||||
|
out.push_str("<li>");
|
||||||
|
render_children(node, out);
|
||||||
|
out.push_str("</li>\n");
|
||||||
|
}
|
||||||
|
"codeBlock" | "code_block" => {
|
||||||
|
let lang = node
|
||||||
|
.get("attrs")
|
||||||
|
.and_then(|a| a.get("language"))
|
||||||
|
.and_then(|l| l.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
if lang.is_empty() {
|
||||||
|
out.push_str("<pre><code>");
|
||||||
|
} else {
|
||||||
|
out.push_str(&format!("<pre><code class=\"language-{}\">", escape_html(lang)));
|
||||||
|
}
|
||||||
|
render_inline_content(node, out);
|
||||||
|
out.push_str("</code></pre>\n");
|
||||||
|
}
|
||||||
|
"horizontalRule" | "horizontal_rule" => {
|
||||||
|
out.push_str("<hr>\n");
|
||||||
|
}
|
||||||
|
"image" => {
|
||||||
|
let attrs = node.get("attrs");
|
||||||
|
let src = attrs
|
||||||
|
.and_then(|a| a.get("src"))
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let alt = attrs
|
||||||
|
.and_then(|a| a.get("alt"))
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let title = attrs
|
||||||
|
.and_then(|a| a.get("title"))
|
||||||
|
.and_then(|s| s.as_str());
|
||||||
|
out.push_str(&format!(
|
||||||
|
"<img src=\"{}\" alt=\"{}\"",
|
||||||
|
escape_attr(src),
|
||||||
|
escape_attr(alt)
|
||||||
|
));
|
||||||
|
if let Some(t) = title {
|
||||||
|
out.push_str(&format!(" title=\"{}\"", escape_attr(t)));
|
||||||
|
}
|
||||||
|
out.push_str(">\n");
|
||||||
|
}
|
||||||
|
"hardBreak" | "hard_break" => {
|
||||||
|
out.push_str("<br>");
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
render_children(node, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_children(node: &Value, out: &mut String) {
|
||||||
|
if let Some(content) = node.get("content").and_then(|c| c.as_array()) {
|
||||||
|
for child in content {
|
||||||
|
render_node(child, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_inline_content(node: &Value, out: &mut String) {
|
||||||
|
let Some(content) = node.get("content").and_then(|c| c.as_array()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
for child in content {
|
||||||
|
let child_type = child.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||||
|
match child_type {
|
||||||
|
"text" => {
|
||||||
|
let text = child.get("text").and_then(|t| t.as_str()).unwrap_or("");
|
||||||
|
let marks = child.get("marks").and_then(|m| m.as_array());
|
||||||
|
render_text_with_marks(text, marks, out);
|
||||||
|
}
|
||||||
|
"hardBreak" | "hard_break" => {
|
||||||
|
out.push_str("<br>");
|
||||||
|
}
|
||||||
|
"image" => {
|
||||||
|
render_node(child, out);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
render_node(child, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_text_with_marks(text: &str, marks: Option<&Vec<Value>>, out: &mut String) {
|
||||||
|
let Some(marks) = marks else {
|
||||||
|
out.push_str(&escape_html(text));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut close_tags: Vec<&str> = Vec::new();
|
||||||
|
for mark in marks {
|
||||||
|
let mark_type = mark.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||||
|
match mark_type {
|
||||||
|
"bold" | "strong" => {
|
||||||
|
out.push_str("<strong>");
|
||||||
|
close_tags.push("</strong>");
|
||||||
|
}
|
||||||
|
"italic" | "em" => {
|
||||||
|
out.push_str("<em>");
|
||||||
|
close_tags.push("</em>");
|
||||||
|
}
|
||||||
|
"strike" | "strikethrough" => {
|
||||||
|
out.push_str("<s>");
|
||||||
|
close_tags.push("</s>");
|
||||||
|
}
|
||||||
|
"code" => {
|
||||||
|
out.push_str("<code>");
|
||||||
|
close_tags.push("</code>");
|
||||||
|
}
|
||||||
|
"underline" => {
|
||||||
|
out.push_str("<u>");
|
||||||
|
close_tags.push("</u>");
|
||||||
|
}
|
||||||
|
"link" => {
|
||||||
|
let href = mark
|
||||||
|
.get("attrs")
|
||||||
|
.and_then(|a| a.get("href"))
|
||||||
|
.and_then(|h| h.as_str())
|
||||||
|
.unwrap_or("#");
|
||||||
|
let target = mark
|
||||||
|
.get("attrs")
|
||||||
|
.and_then(|a| a.get("target"))
|
||||||
|
.and_then(|t| t.as_str());
|
||||||
|
out.push_str(&format!("<a href=\"{}\"", escape_attr(href)));
|
||||||
|
if let Some(t) = target {
|
||||||
|
out.push_str(&format!(" target=\"{}\"", escape_attr(t)));
|
||||||
|
}
|
||||||
|
out.push_str(" rel=\"noopener noreferrer\">");
|
||||||
|
close_tags.push("</a>");
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push_str(&escape_html(text));
|
||||||
|
|
||||||
|
for tag in close_tags.iter().rev() {
|
||||||
|
out.push_str(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_html(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_attr(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn simple_paragraph() {
|
||||||
|
let doc = json!({
|
||||||
|
"type": "doc",
|
||||||
|
"content": [{
|
||||||
|
"type": "paragraph",
|
||||||
|
"content": [{ "type": "text", "text": "Hello world" }]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
assert_eq!(document_to_html(&doc), "<p>Hello world</p>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heading_levels() {
|
||||||
|
let doc = json!({
|
||||||
|
"type": "doc",
|
||||||
|
"content": [{
|
||||||
|
"type": "heading",
|
||||||
|
"attrs": { "level": 2 },
|
||||||
|
"content": [{ "type": "text", "text": "Title" }]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
assert_eq!(document_to_html(&doc), "<h2>Title</h2>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bold_mark() {
|
||||||
|
let doc = json!({
|
||||||
|
"type": "doc",
|
||||||
|
"content": [{
|
||||||
|
"type": "paragraph",
|
||||||
|
"content": [{
|
||||||
|
"type": "text",
|
||||||
|
"text": "bold",
|
||||||
|
"marks": [{ "type": "bold" }]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
assert_eq!(document_to_html(&doc), "<p><strong>bold</strong></p>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn html_escaping() {
|
||||||
|
let doc = json!({
|
||||||
|
"type": "doc",
|
||||||
|
"content": [{
|
||||||
|
"type": "paragraph",
|
||||||
|
"content": [{ "type": "text", "text": "<script>alert('xss')</script>" }]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
let html = document_to_html(&doc);
|
||||||
|
assert!(!html.contains("<script>"));
|
||||||
|
assert!(html.contains("<script>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_doc() {
|
||||||
|
let doc = json!({ "type": "doc" });
|
||||||
|
assert_eq!(document_to_html(&doc), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue