Files
KantSites/templates/admin/pages.html.twig
T
kantarciandClaude Opus 5 ebd5883593 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>
2026-09-12 11:25:09 +02:00

109 lines
7.8 KiB
Twig

{% extends 'admin/base.html.twig' %}
{% block title %}Deine Seiten{% endblock %}
{% block body %}
<header class="admin-header">
<a class="brand" href="{{ path('admin_pages') }}"><span class="brand-icon" aria-hidden="true">k.</span>kant<span>cms</span></a>
<div class="header-actions"><a href="/">Website ansehen ↗</a><span class="user-name">{{ app.user.userIdentifier }}</span><form action="{{ path('admin_logout') }}" method="post"><input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}"><button class="subtle" type="submit">Abmelden</button></form></div>
</header>
<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> <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>
{% endfor %}{% endfor %}
{% if is_granted('ROLE_EDITOR') %}
<details class="create-panel"{% if app.flashes is not empty %} open{% endif %}>
<summary>Neue Seite oder neuen Beitrag anlegen</summary>
<form action="{{ path('admin_create') }}" method="post" class="create-form">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
<div class="create-grid">
<div>
<label for="new-title">Titel</label>
<input id="new-title" name="title" maxlength="160" required autocomplete="off">
<p class="field-help">Wird als Überschrift und als Seitentitel übernommen.</p>
</div>
<div>
<label for="new-slug">Adresse</label>
<input id="new-slug" name="slug" maxlength="80" pattern="[a-z0-9]+(-[a-z0-9]+)*" autocomplete="off" aria-describedby="new-slug-help">
<p class="field-help" id="new-slug-help">Kleinbuchstaben, Ziffern und Bindestriche, zum Beispiel <code>ueber-uns</code>. Leer lassen, um sie aus dem Titel zu bilden.</p>
</div>
<div>
<label for="new-type">Art</label>
<select id="new-type" name="type">
<option value="page">Seite</option>
<option value="post">Beitrag</option>
</select>
<p class="field-help">Beiträge sind für Blogartikel gedacht und haben keine Unterseiten.</p>
</div>
<div>
<label for="new-parent">Übergeordnete Seite</label>
<select id="new-parent" name="parent_id">
<option value="">— keine, direkt unter der Startseite —</option>
{% for parent in parents %}<option value="{{ parent.id }}">{{ parent.title }} (/{{ parent.path }})</option>{% endfor %}
</select>
<p class="field-help">Bestimmt die Adresse, zum Beispiel <code>/leistungen/webdesign</code>.</p>
</div>
</div>
<button class="primary" type="submit">Anlegen und bearbeiten</button>
</form>
</details>
{% endif %}
<div class="section-heading"><h2>Seiten <span class="count">{{ pages|length }}</span></h2><span class="muted">Entwürfe bleiben bis zur Veröffentlichung privat.</span></div>
<div class="page-list">
{% for page in pages %}
<article class="page-card" style="margin-left:{{ page.depth * 28 }}px">
<div class="page-icon" aria-hidden="true">{{ page.type == 'post' ? '✎' : '▤' }}</div>
<div class="page-info">
<h3><a href="{{ path('admin_editor', {id: page.id}) }}">{{ page.title }}</a></h3>
<p>/{{ page.path == 'home' ? '' : page.path }} <span>· {{ page.locale|upper }} · {{ page.type == 'post' ? 'Beitrag' : 'Seite' }}</span></p>
</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>
<form action="{{ path('admin_delete', {id: page.id}) }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
<p>Löscht „{{ page.title }}“ mit allen gespeicherten Ständen. Das lässt sich nicht rückgängig machen.</p>
<button class="secondary danger-text" type="submit">Endgültig löschen</button>
</form>
</details>
{% endif %}
</article>
{% else %}
<div class="empty-state"><h3>Noch keine Seiten vorhanden.</h3><p>Lege oben deine erste Seite an.</p></div>
{% endfor %}
</div>
<p class="footnote">Kant CMS · Visueller Editor, erster Prototyp</p>
</main>
{% endblock %}