From 1b22265a54fc0fe5a08f747eaea50da119ad9e19 Mon Sep 17 00:00:00 2001
From: Umut Kantarci
Date: Sun, 13 Sep 2026 10:56:18 +0200
Subject: [PATCH] Beitragsnavigation als Baustein (Dokumentschema v18)
Umut: "Beitragsseite gefaellt mir nicht, sollte wie bei kant-it.de sein
oder besser. Falls noetig Plugin bauen."
Die Originalseite hat Galerie, Vor/Zurueck zwischen Projekten und einen
Abschlussaufruf. Galerie und Aufruf gab es laengst - sie waren nur nicht
benutzt. Die Navigation gab es nicht.
Kein Plugin: ein Plugin ist hier ein Bausteintyp, dessen Eigenschaften der
Kern aufbewahrt, aber nicht rendert - es koennte also nichts ausgeben,
solange es keine Laufzeit dafuer gibt. Und die Nachbarn eines Beitrags
stehen in derselben Tabelle wie die Beitragsliste, die der Kern schon
fuellt. Also ein Baustein: postnav.
Gespeichert sind zwei Beschriftungen, mehr nicht - welcher Beitrag vorher
und nachher kommt, entscheidet der Renderer. Gesucht wird innerhalb
derselben Kategorie, und am Rand wird nicht umgebrochen.
Ausserdem: die uebernommenen Projekttexte begannen mit dem wiederholten
Titel (20 Beitraege), und schmale Abschnitte standen links statt mittig -
max-width ohne margin:auto ist eine halbe Einstellung.
Geprueft: 396 Tests unter PHP 8.2 und 8.4, 18 JS-Tests, Editortest,
Browserpruefung, axe ohne Verstoss auf der Beitragsseite.
Co-Authored-By: Claude Opus 5
---
docs/ARCHITECTURE.md | 16 ++++++++++++
docs/STATUS.md | 1 +
public/assets/editor-model.js | 2 ++
public/assets/editor.js | 4 +++
public/assets/enhancements.css | 2 ++
public/assets/site.css | 2 +-
src/Content/DocumentValidator.php | 12 +++++++--
src/Content/PostRepository.php | 39 +++++++++++++++++++++++++++++
src/Controller/PageController.php | 3 +++
templates/admin/editor.html.twig | 2 +-
templates/blocks/block.html.twig | 22 +++++++++-------
templates/blocks/post-nav.html.twig | 18 +++++++++++++
templates/page.html.twig | 2 +-
tests/CmsTest.php | 37 +++++++++++++++++++++++++++
14 files changed, 148 insertions(+), 14 deletions(-)
create mode 100644 templates/blocks/post-nav.html.twig
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 5870cff..9f09d03 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -440,6 +440,22 @@ Vom Verweis bleibt nur der Host. Die vollständige Adresse, aus der jemand kam,
**Aufbewahrung:** nach 60 Tagen werden die Stunden eines Tages zu einer Zeile verschmolzen, nach 425 Tagen wird gelöscht. Gut ein Jahr, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. `cms:stats:prune` macht beides und ist für einen Cronjob gedacht.
+## ADR 049 – Die Beitragsseite, und warum das kein Plugin wurde (Dokumentschema v18, 13.09.2026)
+
+Umut: „Beitragsseite gefällt mir nicht, sollte wie bei kant-it.de sein oder besser. Falls nötig Plugin bauen.“
+
+Nachgesehen: die Originalseite besteht aus Kopfbild, Text, **Bildergalerie**, **Vor/Zurück zwischen Projekten** und einem **Abschlussaufruf**. Galerie und Aufruf gab es als Bausteine längst – sie waren nur nicht benutzt. Die Navigation zwischen Beiträgen gab es nicht.
+
+**Warum das kein Plugin geworden ist.** Ein Plugin ist in diesem CMS ein Bausteintyp mit Namensraum, dessen Eigenschaften der Kern *aufbewahrt, aber nicht rendert* – genau damit eine abgeschaltete Erweiterung keine Seite unlesbar macht. Ein Plugin kann also nichts ausgeben, solange es keine Laufzeit dafür gibt (Arbeitspaket H). Und die Nachbarn eines Beitrags sind keine Fremdfunktion: sie stehen in derselben Tabelle wie die Beitragsliste, die der Kern schon füllt. Ein Plugin dafür wäre eine Hülle um eine Kernabfrage gewesen. Also ein Baustein: `postnav`, Dokumentschema v18.
+
+**Die Regel der Beitragsliste gilt auch hier: der gespeicherte Inhalt kennt keinen Beitrag.** Gespeichert sind zwei Beschriftungen, mehr nicht. Welcher Beitrag vorher und nachher kommt, entscheidet der Renderer beim Ausliefern – sonst könnte ein altes Dokument auf einen Beitrag zeigen, den es nicht mehr gibt oder der nie veröffentlicht wurde.
+
+**Gesucht wird innerhalb derselben Kategorie:** von einem Projekt führt man zum nächsten Projekt, nicht zur nächsten Pressemitteilung. **Am Rand wird nicht umgebrochen** – der neueste Beitrag hat keinen Vorgänger. Ein Ring wäre bequem zu programmieren und eine Falle für den Leser, der glaubt, er sei am Anfang.
+
+**Dabei zwei Dinge gelernt:** `present()` gibt für eine Liste bewusst keine Kennung heraus, nur Titel und Adresse – die Nachbarsuche vergleicht deshalb über die Adresse. Und die Abfrage kostet etwas, also läuft sie nur, wenn die Seite den Baustein wirklich enthält.
+
+**Was ich zusätzlich geändert habe, ohne gefragt zu werden:** die Absätze der übernommenen Projekttexte begannen mit dem wiederholten Titel und der Zwischenüberschrift, weil beides im selben Absatz stand – 20 Beiträge betroffen. Und schmale Abschnitte standen am linken Rand statt in der Mitte: `max-width` ohne `margin:auto` ist eine halbe Einstellung.
+
## ADR 047 – Ein Slider, der von selbst weiterblättert (13.09.2026)
Umut wollte die Autorotation. Die Regel „öffentliche Seiten laufen ohne JavaScript“ steht seit dem ersten Tag, und sie bleibt – **mit einer Ausnahme, die es schon gab**: die Tabs laden ein kleines Skript, aber nur auf Seiten, die Tabs enthalten. Der Slider folgt genau diesem Muster.
diff --git a/docs/STATUS.md b/docs/STATUS.md
index 51a1717..dc6c401 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -28,6 +28,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
- **Ladeverhalten gemessen statt vermutet** (ADR 039, Arbeitspaket M): ein Zähler liefert die Datenbankabfragen je Anfrage im Kopf `X-Kant-Queries` (außerhalb der Produktion). Die erste Messung zeigte 18 Abfragen auf der Startseite, weil Einstellungen und Theme je Aufrufer erneut gelesen wurden; beide merken sich ihren Stand jetzt für die Dauer einer Anfrage – **18 → 8**. Die Browserprüfung misst je Seite HTML, CSS, Anfragen, Skripte und Abfragen gegen Obergrenzen: Startseite 7,4 KB HTML, 22,7 KB CSS, 2 Anfragen, 0 Skripte.
- **Das erste Bild einer Seite lädt sofort**, alles darunter weiterhin verzögert. Es ist meistens das größte Ding über der Falz.
- **Slider mit Punkten ohne Skript** (ADR 044): `::scroll-marker` zeichnet je Folie einen Punkt, der aktive ist erkennbar, die Rollleiste entfällt; ältere Browser behalten das wischbare Schiebefeld. Beitragskarten sind echte Karten (Bild 16:10, 18 px Ecken). Überschriften dürfen ein Wort hervorheben (``, in Akzentfarbe). Behoben: die Theme-Schriftskala überschrieb die Größen in Karten und Aufruf-Bändern.
+- **Dokumentschema v18** (ADR 049): Baustein „Beitragsnavigation“ – Vor/Zurück zwischen Beiträgen derselben Kategorie, vom Renderer gefüllt, ohne Umbruch am Rand. Dazu Autorotation für den Slider (Sekunden, eigenes Skript nur auf Seiten mit einem solchen Slider) und Blättern in der Beitragsliste.
- **Dokumentschema v17** (ADR 043): eine Beitragsliste kann Datum, Auszug und Kategorie ausblenden und damit als Projektraster erscheinen; ein Bild kann verlinken (Logo auf die Startseite). Dazu die Einstellung „Kopfbereich beim Scrollen oben halten“. Behoben: im Editor ließ sich in Dokumenten ab v13 kein Baustein mehr einfügen.
- **Die Gestaltung färbt jetzt auch die Bausteine** (ADR 042): Symbole, Knöpfe, Rahmen, ruhiger Text, Fokusring und Einwilligungsdialog nehmen die Farben des Themes statt fester Werte aus `site.css`. Der Einwilligungsdialog lässt sich zusätzlich eigens färben über eine Palettenfarbe mit dem Schlüssel `consent`. Überschriften werden nicht mehr getrennt.
- **Installer (Arbeitspaket N, ADR 041):** `/install` führt in sechs Schritten von einer leeren Installation zu einer Website – Systemprüfung, Datenbank (SQLite oder MySQL, Verbindung sofort geprüft), Migration im Prozess ohne Konsole, Website mit Startseite, Kopf-/Fußbereich, Menü sowie Impressum und Datenschutzgerüst, erstes Administratorkonto, Abschlussliste. Solange keine Installation existiert, führt jede Adresse dorthin; sobald ein Konto existiert, antwortet `/install` mit 404. Davor liegt `scripts/install.php`: eine einzelne Datei, die das Releasearchiv mit fester Adresse und fester SHA-256 lädt, gegen ZIP-Slip gesichert entpackt und sich danach selbst löscht.
diff --git a/public/assets/editor-model.js b/public/assets/editor-model.js
index 36bf18a..ef8ff9d 100644
--- a/public/assets/editor-model.js
+++ b/public/assets/editor-model.js
@@ -37,6 +37,7 @@ export function createBlock(type) {
if (type === 'text') block.props = { text: 'Hier beginnt dein neuer Inhalt.' };
if (type === 'button') block.props = { text: 'Mehr erfahren', href: '/' };
if (type === 'postlist') block.props = { count: 3, category: '', tag: '' };
+ if (type === 'postnav') block.props = { previousLabel: 'Vorheriger Beitrag', nextLabel: 'Nächster Beitrag' };
if (type === 'iconcard') block.props = { icon: 'check', title: 'Deine Leistung', text: 'Beschreibe hier kurz, worum es geht.', href: '' };
if (type === 'stat') block.props = { value: '15+', label: 'Jahre Erfahrung' };
if (type === 'quote') block.props = { text: 'Ein Satz, den eine Kundin oder ein Kunde über dich gesagt hat.', author: 'Name', role: '', rating: 5 };
@@ -61,6 +62,7 @@ export function insertBlock(document, type, selected) {
const block = createBlock(type);
if (type === 'gallery') upgradeDocument(document, 3);
if (type === 'postlist') upgradeDocument(document, 5);
+ if (type === 'postnav') upgradeDocument(document, 18);
if (['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'].includes(type)) upgradeDocument(document, 6);
if (type === 'tabs' || type === 'tabitem') upgradeDocument(document, 7);
if (type === 'form') upgradeDocument(document, 8);
diff --git a/public/assets/editor.js b/public/assets/editor.js
index 330ca25..fe77448 100644
--- a/public/assets/editor.js
+++ b/public/assets/editor.js
@@ -281,6 +281,10 @@ function renderProperties() {
field('Titel', 'title', block.props.title, { maxLength: 160 });
field('Beim Laden geöffnet', 'open', block.props.open, { checkbox: true });
}
+ if (block.type === 'postnav') {
+ field('Beschriftung zurück', 'previousLabel', block.props.previousLabel, { maxLength: 60 });
+ field('Beschriftung weiter', 'nextLabel', block.props.nextLabel, { maxLength: 60, help: 'Die Beiträge selbst sucht die Website beim Ausliefern - innerhalb derselben Kategorie.' });
+ }
if (block.type === 'postlist') {
field('Anzahl Beiträge', 'count', block.props.count, { number: true, help: 'Zwischen 1 und 24. Die Beiträge selbst kommen beim Ausliefern dazu, nicht aus dem gespeicherten Inhalt.' });
field('Nur Kategorie', 'category', block.props.category || '', { help: 'Adresse der Kategorie, zum Beispiel projekte. Leer lassen für alle Beiträge.' });
diff --git a/public/assets/enhancements.css b/public/assets/enhancements.css
index 3448674..901e8ff 100644
--- a/public/assets/enhancements.css
+++ b/public/assets/enhancements.css
@@ -53,6 +53,8 @@
.post-image img { transition: transform .5s ease }
.post-card:hover .post-image img, .post-card:focus-within .post-image img { transform: scale(1.06) }
.post-view { transition: opacity .3s ease }
+ .post-nav-link { transition: transform .2s ease, border-color .2s ease }
+ .post-nav-link:hover, .post-nav-link:focus-visible { transform: translateY(-3px); border-color: var(--accent) }
/* Der Knopf hebt sich beim Zeigen um zwei Pixel - dieselbe Geste wie auf der Karte. */
.button { transition: transform .2s ease, background-color .2s ease, border-color .2s ease }
diff --git a/public/assets/site.css b/public/assets/site.css
index 70a08fb..bafdcf4 100644
--- a/public/assets/site.css
+++ b/public/assets/site.css
@@ -31,7 +31,7 @@ p{hyphens:auto}
.post-meta,.post-header{display:flex;flex-wrap:wrap;gap:6px 16px;align-items:baseline;font-size:13px;margin:0}
.post-header{padding-block:22px 0}
.post-excerpt{margin:0;line-height:1.65}
-.post-pager{display:flex;flex-wrap:wrap;gap:10px 22px;align-items:center;justify-content:center;margin-top:34px;font-size:14px}.post-pager span{color:var(--muted)}.post-terms{display:flex;flex-wrap:wrap;gap:8px;margin:0;font-size:13px}
+.block-postnav{margin-top:10px}.post-nav{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:18px}.post-nav-link{display:grid;gap:8px;padding:22px 24px;border:1px solid var(--line);border-radius:18px;text-decoration:none;background:var(--paper)}.post-nav-next{text-align:right}.post-nav-label{font-size:12px;font-weight:700;letter-spacing:1.4px;text-transform:uppercase;color:var(--accent)}.post-nav-title{font-size:18px;font-weight:800;line-height:1.25;color:var(--ink)}.post-pager{display:flex;flex-wrap:wrap;gap:10px 22px;align-items:center;justify-content:center;margin-top:34px;font-size:14px}.post-pager span{color:var(--muted)}.post-terms{display:flex;flex-wrap:wrap;gap:8px;margin:0;font-size:13px}
.post-empty{padding:26px;border:1px dashed currentColor;border-radius:10px}
.pagination{display:flex;flex-wrap:wrap;gap:12px 24px;align-items:center;margin-top:38px;padding-top:22px;border-top:1px solid currentColor;font-size:14px}
.post-terms-label{font-size:11px;font-weight:700;letter-spacing:1.6px;text-transform:uppercase;margin-bottom:10px}
diff --git a/src/Content/DocumentValidator.php b/src/Content/DocumentValidator.php
index d3c5a74..4f918fb 100644
--- a/src/Content/DocumentValidator.php
+++ b/src/Content/DocumentValidator.php
@@ -27,6 +27,8 @@ final class DocumentValidator
public const V14_TYPES = ['embed'];
/** The sizes an icon can have; each one has a class in site.css. */
public const ICON_SIZES = [12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 120, 160, 200, 240];
+ /** Schema v18: die Navigation zwischen Beiträgen, vom Renderer gefüllt. */
+ public const V18_TYPES = ['postnav'];
/** Schema v15: a single icon, a progress bar and a slider that works without a script. */
public const V15_TYPES = ['icon', 'progress', 'slider'];
@@ -38,7 +40,7 @@ final class DocumentValidator
public function validate(array $document, bool $requireHeading = true): void
{
$schemaVersion = $document['schemaVersion'] ?? null;
- $this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17], true), 'Unsupported document schema.');
+ $this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18], true), 'Unsupported document schema.');
$this->text($document['title'] ?? null, 160);
$this->text($document['description'] ?? null, 320);
$this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.');
@@ -66,7 +68,7 @@ final class DocumentValidator
// page: a deactivated plugin must never make stored content unreadable. Nothing from
// props is rendered for such a block, so unvalidated values never reach the output.
$plugin = $schemaVersion >= 4 && is_string($type) && 1 === preg_match('/^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/D', $type);
- $this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
+ $this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
// An accordion entry only ever renders inside an accordion; at the top level or anywhere
// else it would produce a details element without its group.
$this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.');
@@ -124,6 +126,12 @@ final class DocumentValidator
$this->require(is_int($props['level'] ?? null) && $props['level'] >= 1 && $props['level'] <= 6, 'Invalid heading level.');
$headings += 1 === $props['level'] ? 1 : 0;
}
+ if ('postnav' === $type) {
+ // Nur die Beschriftungen sind Inhalt. Welche Beiträge es sind, entscheidet der
+ // Renderer beim Ausliefern - gespeicherter Inhalt kann keinen Beitrag behaupten.
+ $this->text($props['previousLabel'] ?? null, 60);
+ $this->text($props['nextLabel'] ?? null, 60);
+ }
if ('postlist' === $type) {
// The list is filled by the renderer, never by stored content: the block only says
// how many posts to show and which term to limit them to.
diff --git a/src/Content/PostRepository.php b/src/Content/PostRepository.php
index ac38485..be5ea14 100644
--- a/src/Content/PostRepository.php
+++ b/src/Content/PostRepository.php
@@ -153,6 +153,45 @@ final class PostRepository
/**
* @return array>
*/
+ /**
+ * Der vorherige und der nächste Beitrag, in der Reihenfolge des Archivs.
+ *
+ * Gesucht wird innerhalb derselben Kategorie, wenn der Beitrag eine hat: von einem Projekt
+ * führt man zum nächsten Projekt und nicht zur nächsten Pressemitteilung. Hat er keine, gilt
+ * die ganze Liste. Am Rand wird nicht umgebrochen - der erste Beitrag hat keinen Vorgänger,
+ * und ein Ring aus zwei Beiträgen wäre eine Schleife, keine Navigation.
+ *
+ * @return array{previous: ?array, next: ?array}
+ */
+ public function neighbours(string $pageId, string $locale = 'de'): array
+ {
+ $termId = (string) $this->db->fetchOne(
+ 'SELECT t.id FROM term t JOIN page_term pt ON pt.term_id = t.id WHERE pt.page_id = ? AND t.kind = ? ORDER BY t.name LIMIT 1',
+ [$pageId, 'category'],
+ );
+ // Dieselbe Sortierung wie im Archiv, damit „vorheriges" dort dasselbe heißt wie hier.
+ $all = $this->published(1, '' === $termId ? null : $termId, 500, $locale)['posts'];
+ // present() gibt bewusst keine Kennung heraus - in eine Liste gehören Titel und Adresse.
+ // Verglichen wird deshalb über die Adresse, die innerhalb einer Website eindeutig ist.
+ $own = $this->settings->publicPath((string) $this->db->fetchOne('SELECT path FROM page WHERE id = ?', [$pageId]));
+ $index = null;
+ foreach ($all as $position => $post) {
+ if ($post['href'] === $own) {
+ $index = $position;
+ break;
+ }
+ }
+ if (null === $index) {
+ return ['previous' => null, 'next' => null];
+ }
+
+ return [
+ // „Vorheriges" ist im Archiv der neuere Beitrag, weil dort das Neueste oben steht.
+ 'previous' => $all[$index - 1] ?? null,
+ 'next' => $all[$index + 1] ?? null,
+ ];
+ }
+
public function termsFor(string $pageId): array
{
$terms = ['category' => [], 'tag' => []];
diff --git a/src/Controller/PageController.php b/src/Controller/PageController.php
index 92ebc64..8739390 100644
--- a/src/Controller/PageController.php
+++ b/src/Controller/PageController.php
@@ -67,6 +67,9 @@ final class PageController extends AbstractController
'navigation' => $this->navigation($path),
'postLists' => $this->postLists($page['document'], max(1, $request->query->getInt('seite', 1))),
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null,
+ // Die Nachbarn kosten eine Abfrage; sie fällt nur an, wenn die Seite den
+ // Baustein wirklich enthält.
+ 'neighbours' => 'post' === $page['type'] && $this->hasType($page['document'], 'postnav') ? $this->posts->neighbours($page['id']) : [],
'needsTabs' => $this->hasType($page['document'], 'tabs'),
// Ein Slider mit automatischem Weiterblättern ist der zweite Baustein, der
// dafür ein Skript braucht - nach demselben Muster wie die Tabs.
diff --git a/templates/admin/editor.html.twig b/templates/admin/editor.html.twig
index c3e3e34..6d84eaf 100644
--- a/templates/admin/editor.html.twig
+++ b/templates/admin/editor.html.twig
@@ -27,7 +27,7 @@
{% endfor %}
{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), eagerImage: eagerImage|default(null)} only %}
+
{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null)} only %}
{% endfor %}
{% elseif type == 'embed' %}
@@ -198,9 +198,13 @@
{% set feed = postLists[block.id]|default([]) %}
{% include 'blocks/post-list.html.twig' with {posts: feed.posts|default(feed), pages: feed.pages|default(1), page: feed.page|default(1), total: feed.total|default(null), showMeta: block.props.showMeta ?? true, showExcerpt: block.props.showExcerpt ?? true, showTerms: block.props.showTerms ?? true} only %}
+{% elseif type == 'postnav' %}
+
+ {% include 'blocks/post-nav.html.twig' with {previous: neighbours.previous|default(null), next: neighbours.next|default(null), previousLabel: props.previousLabel|default('Vorheriger Beitrag'), nextLabel: props.nextLabel|default('Nächster Beitrag')} only %}
+
{% elseif type == 'gallery' %}
- {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), eagerImage: eagerImage|default(null)} only %}{% endfor %}
+ {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null)} only %}{% endfor %}
{% else %}
{#
diff --git a/templates/blocks/post-nav.html.twig b/templates/blocks/post-nav.html.twig
new file mode 100644
index 0000000..504cb82
--- /dev/null
+++ b/templates/blocks/post-nav.html.twig
@@ -0,0 +1,18 @@
+{# Weiter zum nächsten Beitrag. Die Beiträge kommen vom Renderer, nie aus gespeichertem Inhalt.
+ Fehlt ein Nachbar, bleibt seine Seite leer statt im Kreis zu führen. #}
+{% if previous or next %}
+
+{% endif %}
diff --git a/templates/page.html.twig b/templates/page.html.twig
index 7747879..0c7a2ff 100644
--- a/templates/page.html.twig
+++ b/templates/page.html.twig
@@ -53,7 +53,7 @@
{% for term in postMeta.tag %}#{{ term.name }}{% endfor %}
{% endif %}
- {% for block in page.document.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: page.document.schemaVersion|default(1) >= 12, richHeadings: page.document.schemaVersion|default(1) >= 17, eagerImage: eagerImage|default(null)} only %}{% endfor %}
+ {% for block in page.document.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: page.document.schemaVersion|default(1) >= 12, richHeadings: page.document.schemaVersion|default(1) >= 17, neighbours: neighbours|default({}), eagerImage: eagerImage|default(null)} only %}{% endfor %}
{% if areas.footer|default(null) %}