Beiträge mit Archiven, Beitragsliste und Feed (Arbeitspaket B)

Migration 20260912000600 ergänzt page.excerpt, author,
featured_media_id und published_at sowie die Tabellen term und
page_term. Beitragsmetadaten beschreiben die Seite, nicht ihren
Inhalt, und liegen deshalb als Spalten statt im Blockdokument: das
Archiv sortiert und paginiert damit in SQL, statt für jede
Auflistung alle Dokumente zu dekodieren.

Kategorien und Schlagwörter teilen sich eine Tabelle mit einem
kind-Feld. Beim Löschen einer Seite verschwinden ihre Zuordnungen,
die Begriffe bleiben als wiederverwendbare Etiketten; ein leer
gewordenes Archiv antwortet weiter mit 200 und leerer Liste.

Der Block postlist (Dokumentschema v5) speichert nur Anzahl und
Filter. Die Beiträge stellt der Controller beim Ausliefern zusammen,
damit ein gespeichertes Dokument keinen unveröffentlichten Beitrag
zeigen kann. Archive unter /beitraege, /kategorie/{slug} und
/schlagwort/{slug}, Feed unter /feed.xml; alle vier Segmente sind
für Wurzelseiten reserviert.

Geprüft: 91 PHP-Tests / 347 Assertions unter PHP 8.2.12 und 8.4.22,
12 JavaScript-Modelltests, Lint/Format/Container, Migration zuerst
gegen eine Datenbankkopie. Keine Browser- oder Lighthouse-Läufe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 11:25:09 +02:00
1 parent de9d6e3da8
commit ebd5883593
25 files changed
+808 -21

No files matched your search

+1 -1
View File
@@ -17,7 +17,7 @@
<section class="panel-section"><div class="panel-title"><h2>Werkzeugkasten</h2><button id="toggle-library" class="text-button" aria-expanded="true" aria-controls="toolbox">− <span class="visually-hidden">Werkzeugkasten einklappen</span></button></div><div id="toolbox"><h3 class="preset-heading">Element hinzufügen</h3>
<p class="panel-hint">In die gewählte Sektion oder hinter das gewählte Element.</p>
<div id="library" class="library">
{% for type, label in {section:'Sektion', row:'Zeile', column:'Spalte', heading:'Überschrift', text:'Text', image:'Bild', button:'Button', gallery:'Galerie'} %}<button type="button" data-add="{{ type }}" disabled><span aria-hidden="true">{{ {section:'▣',row:'☰',column:'▥',heading:'H',text:'T',image:'▧',button:'↗',gallery:'▦'}[type] }}</span>{{ label }}</button>{% endfor %}
{% for type, label in {section:'Sektion', row:'Zeile', column:'Spalte', heading:'Überschrift', text:'Text', image:'Bild', button:'Button', gallery:'Galerie', postlist:'Beitragsliste'} %}<button type="button" data-add="{{ type }}" disabled><span aria-hidden="true">{{ {section:'▣',row:'☰',column:'▥',heading:'H',text:'T',image:'▧',button:'↗',gallery:'▦',postlist:'☷'}[type] }}</span>{{ label }}</button>{% endfor %}
</div>
<button id="open-media-library" class="secondary full-width media-launch" disabled>Medienverwaltung öffnen</button>
<h3 class="preset-heading">Fertige Abschnitte</h3><p class="panel-hint">Wird nach dem aktuellen Seitenabschnitt eingefügt.</p>
+25 -1
View File
@@ -8,7 +8,7 @@
<main id="admin-main" class="pages-main" tabindex="-1">
<p class="eyebrow">DEINE WEBSITE</p><h1>Gute Inhalte beginnen hier.</h1>
<p class="muted">Wähle eine Seite. Gestalte deinen Entwurf. Veröffentliche, wenn alles passt.</p>
<p class="admin-links"><a class="secondary" href="{{ path('admin_media') }}">Medienverwaltung</a> <a class="secondary" href="{{ path('admin_menus') }}">Menüs</a></p>
<p class="admin-links"><a class="secondary" href="{{ path('admin_media') }}">Medienverwaltung</a> <a class="secondary" href="{{ path('admin_menus') }}">Menüs</a> <a class="secondary" href="/beitraege">Beitragsübersicht ↗</a></p>
{% for type in ['error', 'success'] %}{% for message in app.flashes(type) %}
<p class="notice {{ type == 'error' ? 'error' : 'ok' }}" role="{{ type == 'error' ? 'alert' : 'status' }}">{{ message }}</p>
@@ -63,6 +63,30 @@
</div>
<span class="badge {{ page.published_revision_id == page.draft_revision_id ? 'published' : '' }}">{{ page.published_revision_id is null ? 'Nicht veröffentlicht' : (page.published_revision_id == page.draft_revision_id ? 'Veröffentlicht' : 'Entwurf geändert') }}</span>
<a class="secondary" href="{{ path('admin_editor', {id: page.id}) }}" aria-label="{{ page.title }} bearbeiten">Bearbeiten <span aria-hidden="true">→</span></a>
{% if page.type == 'post' and is_granted('ROLE_EDITOR') %}
<details class="meta-panel">
<summary aria-label="Beitragsdaten von {{ page.title }}">Beitragsdaten</summary>
<form action="{{ path('admin_post_meta', {id: page.id}) }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
<label for="date-{{ page.id }}">Veröffentlicht am</label>
<input id="date-{{ page.id }}" name="publishedAt" type="date" value="{{ page.published_at ? page.published_at|date('Y-m-d') : '' }}">
<label for="author-{{ page.id }}">Autor</label>
<input id="author-{{ page.id }}" name="author" maxlength="120" value="{{ page.author }}">
<label for="excerpt-{{ page.id }}">Auszug</label>
<textarea id="excerpt-{{ page.id }}" name="excerpt" maxlength="500" rows="3">{{ page.excerpt }}</textarea>
<p class="field-help">Erscheint in der Übersicht und im Feed. Leer lassen, um die Seitenbeschreibung zu verwenden.</p>
<label for="categories-{{ page.id }}">Kategorien</label>
<input id="categories-{{ page.id }}" name="categories" value="{{ page.terms.category }}" placeholder="Projekte, Referenzen">
<label for="tags-{{ page.id }}">Schlagwörter</label>
<input id="tags-{{ page.id }}" name="tags" value="{{ page.terms.tag }}" placeholder="WordPress, Migration">
<p class="field-help">Mehrere durch Komma trennen. Unbekannte Namen werden angelegt.</p>
<label for="media-{{ page.id }}">Beitragsbild</label>
<input id="media-{{ page.id }}" name="featuredMediaId" maxlength="32" value="{{ page.featured_media_id }}" placeholder="ID aus der Mediathek">
<button class="primary" type="submit">Beitragsdaten speichern</button>
</form>
</details>
{% endif %}
{% if is_granted('ROLE_ADMIN') %}
<details class="delete-panel">
<summary aria-label="{{ page.title }} löschen">Löschen</summary>
+53
View File
@@ -0,0 +1,53 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ heading }}{% if result.page > 1 %} · Seite {{ result.page }}{% endif %} · Beiträge</title>
<meta name="description" content="{{ term ? 'Beiträge zu ' ~ term.name : 'Alle Beiträge' }}.">
<link rel="canonical" href="{{ canonical }}">
{% if result.page > 1 %}<link rel="prev" href="{{ base }}{{ result.page > 2 ? '?seite=' ~ (result.page - 1) : '' }}">{% endif %}
{% if result.page < result.pages %}<link rel="next" href="{{ base }}?seite={{ result.page + 1 }}">{% endif %}
<link rel="alternate" type="application/rss+xml" title="Beiträge" href="/feed.xml">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/assets/site.css">
<link rel="stylesheet" href="/assets/design.css">
</head>
<body>
<a class="skip-link" href="#main">Zum Inhalt springen</a>
<header class="site-header shell">
<a class="brand" href="/" aria-label="Kant CMS – Startseite"><span class="brand-icon" aria-hidden="true">k.</span> kant<span class="brand-light">cms</span></a>
{% if navigation.header|default([]) is not empty %}
<nav aria-label="Hauptnavigation">{% include 'nav.html.twig' with {links: navigation.header} only %}</nav>
{% endif %}
</header>
<main id="main" tabindex="-1">
<section class="block-section tone-mint width-normal">
<div class="shell section-inner">
{% if term %}<p class="post-terms-label">{{ term.kind == 'category' ? 'Kategorie' : 'Schlagwort' }}</p>{% endif %}
<h1>{{ heading }}</h1>
<p class="block-text">{{ result.total }} {{ result.total == 1 ? 'Beitrag' : 'Beiträge' }}{% if result.pages > 1 %} · Seite {{ result.page }} von {{ result.pages }}{% endif %}</p>
</div>
</section>
<section class="block-section tone-paper width-normal">
<div class="shell section-inner">
{% include 'blocks/post-list.html.twig' with {posts: result.posts} only %}
{% if result.pages > 1 %}
<nav class="pagination" aria-label="Seiten">
{% if result.page > 1 %}<a rel="prev" href="{{ base }}{{ result.page > 2 ? '?seite=' ~ (result.page - 1) : '' }}">← Neuere</a>{% endif %}
<span>Seite {{ result.page }} von {{ result.pages }}</span>
{% if result.page < result.pages %}<a rel="next" href="{{ base }}?seite={{ result.page + 1 }}">Ältere →</a>{% endif %}
</nav>
{% endif %}
</div>
</section>
</main>
<footer class="site-footer shell">
<a class="brand" href="/">kant<span class="brand-light">cms</span></a>
{% if navigation.footer|default([]) is not empty %}
<nav aria-label="Fußzeile">{% include 'nav.html.twig' with {links: navigation.footer} only %}</nav>
{% endif %}
<span>Lokaler Entwicklungsprototyp · 2026</span>
</footer>
</body>
</html>
+6 -2
View File
@@ -10,7 +10,7 @@
{% set tag = type == 'section' ? 'section' : 'div' %}
<{{ tag }} id="{{ block.id }}" class="block-{{ type }} tone-{{ layout.tone|default('paper') }} width-{{ layout.width|default('normal') }}{{ designClasses }}">
{% if type == 'section' %}<div class="shell section-inner">{% endif %}
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false)} only %}{% endfor %}
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({})} only %}{% endfor %}
{% if type == 'section' %}</div>{% endif %}
</{{ tag }}>
{% elseif type == 'heading' %}
@@ -21,9 +21,13 @@
<a id="{{ block.id }}" class="button{{ designClasses }}" href="{{ props.href }}">{{ props.text }} <span aria-hidden="true">↗</span></a>
{% elseif type == 'image' %}
<figure id="{{ block.id }}" class="block-image{{ designClasses }}"><img src="{{ props.src }}" alt="{{ props.alt }}" width="{{ props.width }}" height="{{ props.height }}" loading="lazy" decoding="async"{% if props.sources|default([]) %} srcset="{% for source in props.sources %}{{ source.src }} {{ source.width }}w{% if not loop.last %}, {% endif %}{% endfor %}" sizes="(max-width: 650px) 100vw, (max-width: 1024px) 50vw, 800px"{% endif %}>{% if props.caption|default('') %}<figcaption>{{ props.caption }}</figcaption>{% endif %}</figure>
{% elseif type == 'postlist' %}
<div id="{{ block.id }}" class="block-postlist{{ designClasses }}">
{% include 'blocks/post-list.html.twig' with {posts: postLists[block.id]|default([])} only %}
</div>
{% elseif type == 'gallery' %}
<div id="{{ block.id }}" class="block-gallery{{ designClasses }}" role="group" aria-label="Bildergalerie">
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false)} only %}{% endfor %}
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({})} only %}{% endfor %}
</div>
{% else %}
{#
+30
View File
@@ -0,0 +1,30 @@
{# Posts are supplied by the controller, never read from stored content. #}
{% if posts is empty %}
<p class="post-empty">Hier erscheinen die Beiträge, sobald welche veröffentlicht sind.</p>
{% else %}
<ul class="post-list">
{% for post in posts %}
<li class="post-card">
{% if post.image %}
<a class="post-image" href="{{ post.href }}" tabindex="-1" aria-hidden="true">
<img src="{{ post.image.variants|last.src }}" alt="" width="{{ post.image.variants|last.width }}" height="{{ post.image.variants|last.height }}" loading="lazy" decoding="async"
srcset="{% for source in post.image.variants %}{{ source.src }} {{ source.width }}w{% if not loop.last %}, {% endif %}{% endfor %}" sizes="(max-width: 650px) 100vw, 380px">
</a>
{% endif %}
<div class="post-body">
<h3><a href="{{ post.href }}">{{ post.title }}</a></h3>
{% if post.publishedAt or post.author %}
<p class="post-meta">
{% if post.publishedAt %}<time datetime="{{ post.publishedAt|date('Y-m-d') }}">{{ post.publishedAt|date('d.m.Y') }}</time>{% endif %}
{% if post.author %}<span>von {{ post.author }}</span>{% endif %}
</p>
{% endif %}
{% if post.excerpt %}<p class="post-excerpt">{{ post.excerpt }}</p>{% endif %}
{% if post.terms.category is not empty %}
<p class="post-terms">{% for term in post.terms.category %}<a href="{{ term.href }}">{{ term.name }}</a>{% endfor %}</p>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
{% endif %}
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Beiträge</title>
<link>{{ origin }}/beitraege</link>
<atom:link href="{{ origin }}/feed.xml" rel="self" type="application/rss+xml"/>
<description>Die neuesten veröffentlichten Beiträge.</description>
<language>de</language>
{% for post in posts %}
<item>
<title>{{ post.title }}</title>
<link>{{ origin }}{{ post.href }}</link>
<guid isPermaLink="true">{{ origin }}{{ post.href }}</guid>
{% if post.publishedAt %}<pubDate>{{ post.publishedAt|date('r') }}</pubDate>{% endif %}
{% if post.excerpt %}<description>{{ post.excerpt }}</description>{% endif %}
{% for term in post.terms.category %}<category>{{ term.name }}</category>{% endfor %}
</item>
{% endfor %}
</channel>
</rss>
+10 -1
View File
@@ -8,6 +8,7 @@
<link rel="canonical" href="{{ canonical }}">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/assets/site.css">
<link rel="alternate" type="application/rss+xml" title="Beiträge" href="/feed.xml">
{% if page.document.schemaVersion >= 2 %}<link rel="stylesheet" href="/assets/design.css">{% endif %}
{% if page.document.schemaVersion >= 3 %}<link rel="stylesheet" href="/assets/enhancements.css">{% endif %}
</head>
@@ -20,7 +21,15 @@
{% endif %}
</header>
<main id="main" tabindex="-1">
{% for block in page.document.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false)} only %}{% endfor %}
{% if postMeta is defined and postMeta is not null %}
<p class="post-header shell">
{% if page.published_at %}<time datetime="{{ page.published_at|date('Y-m-d') }}">{{ page.published_at|date('d.m.Y') }}</time>{% endif %}
{% if page.author %}<span>von {{ page.author }}</span>{% endif %}
{% for term in postMeta.category %}<a href="{{ term.href }}">{{ term.name }}</a>{% endfor %}
{% for term in postMeta.tag %}<a class="post-tag" href="{{ term.href }}">#{{ term.name }}</a>{% endfor %}
</p>
{% endif %}
{% for block in page.document.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: postLists|default({})} only %}{% endfor %}
</main>
<footer class="site-footer shell">
<a class="brand" href="/">kant<span class="brand-light">cms</span></a>