Migration 20260912000900 ergänzt page.publish_at, page.deleted_at und revision.seq. Verschieben nimmt den ganzen Teilbaum mit und hinterlässt für jede alte Adresse ein 301. Kollisionen, Tiefengrenze und das Verschieben unter eigene Unterseiten werden vor der ersten Änderung abgewiesen; alles in einer Transaktion, weil ein halb verschobener Teilbaum Seiten unerreichbar machen würde. Der Papierkorb ersetzt das sofortige Löschen für die Editorrolle: alle Fassungen bleiben, öffentlich 404, verschwunden aus Navigation, Archiven, Feed, Sitemap, llms.txt und Editorliste. Unterseiten gehen mit, beim Wiederherstellen muss das Elternteil zuerst zurück. Eine Fassung wiederherzustellen überschreibt nichts, sondern legt eine neue an. revision.seq behebt dabei einen echten Fehler: die Reihenfolge des Verlaufs war nicht eindeutig, weil created_at nur Sekunden kennt und mehrere Speichervorgänge in dieselbe Sekunde fallen - "vorherige Fassung wiederherstellen" hätte die falsche treffen können. Geplante Veröffentlichung über cms:publish-due. Ohne Cron bleibt die Seite ein Entwurf, was die sichere Richtung ist. Eine frei wählbare Startseite fehlt bewusst: der Pfad home gilt an elf Stellen als Wurzel, das ist ein eigener Schritt (ADR 022). Geprüft: 160 PHP-Tests / 622 Assertions unter PHP 8.2.12 und 8.4.22, 15 JavaScript-Modelltests, vollständiger Editortest mit axe, Migration zuerst gegen eine Datenbankkopie samt Kontrolle des Backfills. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
186 lines
14 KiB
Twig
186 lines
14 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="{{ path('admin_forms') }}">Formulare</a> <a class="secondary" href="{{ path('admin_theme') }}">Design</a> <a class="secondary" href="{{ path('admin_settings') }}">Einstellungen</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 %}
|
|
{# A class, not a style attribute: the administration forbids inline styles, so an attribute
|
|
here would simply be dropped and the hierarchy would look flat. #}
|
|
<article class="page-card depth-{{ min(page.depth, 3) }}">
|
|
<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' }}{% if page.publish_at %} · geplant für {{ page.publish_at|date('d.m.Y H:i') }} UTC{% endif %}</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 is_granted('ROLE_EDITOR') %}
|
|
<details class="meta-panel">
|
|
<summary aria-label="Seitendaten von {{ page.title }}">{{ page.type == 'post' ? 'Beitragsdaten' : 'Seitendaten' }}</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="robots-{{ page.id }}">Suchmaschinen</label>
|
|
<select id="robots-{{ page.id }}" name="robots">
|
|
<option value="index"{{ page.robots != 'noindex' ? ' selected' : '' }}>Darf indexiert werden</option>
|
|
<option value="noindex"{{ page.robots == 'noindex' ? ' selected' : '' }}>Nicht indexieren</option>
|
|
</select>
|
|
<p class="field-help">Nicht indexierte Seiten erscheinen auch nicht in der Sitemap.</p>
|
|
<label for="og-{{ page.id }}">Vorschaubild beim Teilen</label>
|
|
<input id="og-{{ page.id }}" name="ogMediaId" maxlength="32" value="{{ page.og_media_id }}" placeholder="ID aus der Mediathek">
|
|
{% if page.type == 'post' %}
|
|
<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">
|
|
{% endif %}
|
|
<button class="primary" type="submit">Speichern</button>
|
|
</form>
|
|
</details>
|
|
{% endif %}
|
|
{% if is_granted('ROLE_EDITOR') and page.path != 'home' %}
|
|
<details class="meta-panel">
|
|
<summary aria-label="Adresse von {{ page.title }} ändern">Adresse</summary>
|
|
<form action="{{ path('admin_move', {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="slug-{{ page.id }}">Adresssegment</label>
|
|
<input id="slug-{{ page.id }}" name="slug" maxlength="80" required value="{{ page.slug }}" pattern="[a-z0-9]+(-[a-z0-9]+)*">
|
|
<label for="parent-{{ page.id }}">Übergeordnete Seite</label>
|
|
<select id="parent-{{ page.id }}" name="parent_id">
|
|
<option value="">— keine, direkt unter der Startseite —</option>
|
|
{% for parent in parents|filter(candidate => candidate.id != page.id) %}
|
|
<option value="{{ parent.id }}"{{ page.parent_id == parent.id ? ' selected' : '' }}>{{ parent.title }} (/{{ parent.path }})</option>
|
|
{% endfor %}
|
|
</select>
|
|
<p class="field-help">Unterseiten ziehen mit. Von jeder alten Adresse wird dauerhaft weitergeleitet.</p>
|
|
<button class="primary" type="submit">Adresse ändern</button>
|
|
</form>
|
|
</details>
|
|
{% endif %}
|
|
{% if is_granted('ROLE_PUBLISHER') %}
|
|
<details class="meta-panel">
|
|
<summary aria-label="Veröffentlichung von {{ page.title }} planen">{{ page.publish_at ? 'Geplant' : 'Planen' }}</summary>
|
|
<form action="{{ path('admin_schedule', {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="publish-{{ page.id }}">Veröffentlichen am</label>
|
|
<input id="publish-{{ page.id }}" name="publishAt" type="datetime-local" value="{{ page.publish_at ? page.publish_at|date('Y-m-d\\TH:i') : '' }}">
|
|
<p class="field-help">Zeiten in UTC. Leer lassen, um die Planung zu entfernen. Die Veröffentlichung erfolgt, sobald <code>cms:publish-due</code> läuft.</p>
|
|
<button class="primary" type="submit">Zeitpunkt speichern</button>
|
|
</form>
|
|
</details>
|
|
{% endif %}
|
|
<a class="secondary" href="{{ path('admin_revisions', {id: page.id}) }}">Fassungen</a>
|
|
{% if is_granted('ROLE_EDITOR') and page.path != 'home' %}
|
|
<details class="delete-panel">
|
|
<summary aria-label="{{ page.title }} in den Papierkorb legen">Papierkorb</summary>
|
|
<form action="{{ path('admin_trash', {id: page.id}) }}" method="post">
|
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
|
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
|
<p>Legt „{{ page.title }}“ samt Unterseiten in den Papierkorb. Öffentlich nicht mehr erreichbar, aber jederzeit wiederherstellbar.</p>
|
|
<button class="secondary" type="submit">In den Papierkorb</button>
|
|
</form>
|
|
</details>
|
|
{% endif %}
|
|
{% if is_granted('ROLE_ADMIN') %}
|
|
<details class="delete-panel">
|
|
<summary aria-label="{{ page.title }} endgültig löschen">Endgültig</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 Fassungen, ohne Papierkorb. 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>
|
|
{% if trash is not empty %}
|
|
<div class="section-heading"><h2>Papierkorb <span class="count">{{ trash|length }}</span></h2><span class="muted">Nicht öffentlich erreichbar, alle Fassungen erhalten.</span></div>
|
|
<div class="page-list">
|
|
{% for entry in trash %}
|
|
<article class="page-card">
|
|
<div class="page-icon" aria-hidden="true">🗑</div>
|
|
<div class="page-info">
|
|
<h3>/{{ entry.path }}</h3>
|
|
<p>Im Papierkorb seit <time datetime="{{ entry.deletedAt|date('c') }}">{{ entry.deletedAt|date('d.m.Y H:i') }}</time> UTC</p>
|
|
</div>
|
|
{% if is_granted('ROLE_EDITOR') %}
|
|
<form action="{{ path('admin_restore_page', {id: entry.id}) }}" method="post">
|
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
|
<button class="secondary" type="submit">Wiederherstellen</button>
|
|
</form>
|
|
{% endif %}
|
|
</article>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
<p class="footnote">Kant CMS · Visueller Editor, erster Prototyp</p>
|
|
</main>
|
|
{% endblock %}
|