Drei Fragen, drei unterschiedliche Antworten. Die globale Seitenbreite war einstellbar und wirkte nur in eine Richtung: site.css schrieb width:min(1180px,...), also schluckte die 1180 jeden groesseren Wert aus dem Designbereich. Schmaler ging, breiter nicht, ohne jede Meldung. Die Zahl kommt jetzt aus dem Theme. Das ist der dritte Fehler dieser Art an einem Tag - nach der Rahmenstaerke und der Bildrundung -, und alle drei hatten dieselbe Ursache: CSS, das nie gegen eine Erwartung geprueft wurde. Menueplaetze und Menueart waren vermischt. Wo ein Menue steht, bestimmt der Block und war immer frei. Welche Eintraege es zeigt, bestimmt der Platz - davon gab es zwei, jetzt vier (Haupt, zweite, Fusszeile, Rechtliches). Wie es aussieht, war gar nicht einstellbar: jetzt waagerecht, untereinander oder aufklappbar als Burger. Der Burger ist ein details-Element, denn ein Menue, das ein Skript zum Oeffnen braucht, geht auf einer Seite ohne Skript nicht auf. "Seiten ohne Menue" war nicht moeglich - der Kopfbereich war ein Dokument fuer alle Seiten. Jetzt waehlt jede Seite: Standard, ein anderer Bereich, oder keiner. Dafuer musste die Regel "es gibt genau einen Kopf- und Fussbereich" fallen; sie war richtig, solange niemand waehlen konnte, und wurde falsch, sobald man waehlen kann. Ein bestehender Test, der diese Regel zusicherte, ist deshalb umgeschrieben und prueft jetzt die neue. Gespeichert wird die Wahl, nicht das Ergebnis: leer heisst Standard, none heisst keiner, sonst die Adresse. Ein Verweis auf einen geloeschten Bereich faellt auf den Standard zurueck, statt die Seite stillschweigend ohne Navigation auszuliefern. Neu im Ausbauplan: ein einzelnes install.php, das das Releasearchiv herunterlaedt und entpackt, damit niemand tausende Dateien per FTP hochladen muss. Mit den Regeln, die darueber entscheiden, ob das eine Hilfe oder eine Einladung ist: Adresse und SHA-256 stehen in der Datei und nicht im Formular, HTTPS mit Zertifikatspruefung, Schutz gegen ZIP-Slip, Selbstloeschung danach. 336 PHP-Tests / 1354 Assertions, 17 JS-Modelltests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
259 lines
16 KiB
Twig
259 lines
16 KiB
Twig
{% extends 'admin/shell.html.twig' %}
|
||
{% set nav = 'pages' %}
|
||
{% block title %}Seiten{% endblock %}
|
||
{% block heading %}Seiten und Beiträge{% endblock %}
|
||
{% block lead %}Wähle eine Seite, gestalte deinen Entwurf, veröffentliche, wenn alles passt. Entwürfe bleiben bis dahin privat.{% endblock %}
|
||
{% block content %}
|
||
|
||
{% if is_granted('ROLE_EDITOR') %}
|
||
<details class="card"{% 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 card-body">
|
||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||
<div class="fields">
|
||
<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-head"><h2>Alle Seiten <span class="count">{{ pages|length }}</span></h2><span class="muted">Untergeordnete Seiten stehen eingerückt.</span></div>
|
||
<div class="rows">
|
||
{% for page in pages %}
|
||
{# Indentation by class, not by style attribute: the administration forbids inline styles, so
|
||
an attribute here would simply be dropped and the tree would look flat. #}
|
||
<article class="row row-stack depth-{{ min(page.depth, 3) }}">
|
||
<div class="row-line">
|
||
<span class="row-icon" aria-hidden="true">{{ page.type == 'post' ? '✎' : '▤' }}</span>
|
||
<div class="row-main">
|
||
<h3><a href="{{ path('admin_editor', {id: page.id}) }}">{{ page.title }}</a></h3>
|
||
<p>/{{ page.path == 'home' ? '' : page.path }} · {{ page.type == 'post' ? 'Beitrag' : 'Seite' }} · {{ page.locale|upper }}{% if page.publish_at %} · geplant für {{ page.publish_at|date('d.m.Y H:i') }} UTC{% endif %}</p>
|
||
</div>
|
||
{% if page.published_revision_id is null %}
|
||
<span class="pill pill-off">Nicht veröffentlicht</span>
|
||
{% elseif page.published_revision_id == page.draft_revision_id %}
|
||
<span class="pill pill-live">Veröffentlicht</span>
|
||
{% else %}
|
||
<span class="pill pill-draft">Entwurf geändert</span>
|
||
{% endif %}
|
||
<div class="row-actions">
|
||
<a class="secondary" href="{{ path('admin_editor', {id: page.id}) }}" aria-label="{{ page.title }} bearbeiten">Bearbeiten <span aria-hidden="true">→</span></a>
|
||
<a class="text-button" href="{{ path('admin_revisions', {id: page.id}) }}" aria-label="Fassungen von {{ page.title }}">Fassungen</a>
|
||
</div>
|
||
</div>
|
||
|
||
{% if is_granted('ROLE_EDITOR') or is_granted('ROLE_PUBLISHER') %}
|
||
{# One disclosure per row instead of six popovers next to each other: everything that is
|
||
not the daily „bearbeiten“ lives here and opens in place. #}
|
||
<details class="sheet">
|
||
<summary aria-label="Einstellungen von {{ page.title }}">Einstellungen</summary>
|
||
<div class="sheet-body">
|
||
{% if is_granted('ROLE_EDITOR') %}
|
||
<div class="sheet-part">
|
||
<h4>{{ page.type == 'post' ? 'Beitragsdaten' : 'Seitendaten' }}</h4>
|
||
<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 }}">
|
||
<div class="fields">
|
||
<div>
|
||
<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>
|
||
</div>
|
||
<div>
|
||
<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">
|
||
</div>
|
||
{% if page.type == 'post' %}
|
||
<div>
|
||
<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') : '' }}">
|
||
</div>
|
||
<div>
|
||
<label for="author-{{ page.id }}">Autor</label>
|
||
<input id="author-{{ page.id }}" name="author" maxlength="120" value="{{ page.author }}">
|
||
</div>
|
||
<div>
|
||
<label for="categories-{{ page.id }}">Kategorien</label>
|
||
<input id="categories-{{ page.id }}" name="categories" value="{{ page.terms.category }}" placeholder="Projekte, Referenzen">
|
||
</div>
|
||
<div>
|
||
<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>
|
||
</div>
|
||
<div>
|
||
<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">
|
||
</div>
|
||
<div>
|
||
<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>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
<button class="primary" type="submit">Speichern</button>
|
||
</form>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% if is_granted('ROLE_EDITOR') and page.path != 'home' %}
|
||
<div class="sheet-part">
|
||
<h4>Adresse</h4>
|
||
<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 }}">
|
||
<div class="fields">
|
||
<div>
|
||
<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]+)*">
|
||
</div>
|
||
<div>
|
||
<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>
|
||
</div>
|
||
</div>
|
||
<button class="secondary" type="submit">Adresse ändern</button>
|
||
</form>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% if is_granted('ROLE_EDITOR') %}
|
||
<div class="sheet-part">
|
||
<h4>Kopf- und Fußbereich</h4>
|
||
<p>Standard ist der gemeinsame Bereich. „Kein Bereich“ liefert die Seite ohne Kopf beziehungsweise Fuß aus – für eine Landingpage ohne Navigation. Weitere Bereiche legst du unter Bereiche an.</p>
|
||
<form action="{{ path('admin_page_areas', {id: page.id}) }}" method="post">
|
||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||
<div class="fields">
|
||
{% for kind, label in {header: 'Kopfbereich', footer: 'Fußbereich'} %}
|
||
<div>
|
||
<label for="{{ kind }}-{{ page.id }}">{{ label }}</label>
|
||
<select id="{{ kind }}-{{ page.id }}" name="{{ kind }}Area">
|
||
<option value=""{{ page[kind ~ '_area'] is null ? ' selected' : '' }}>Standard</option>
|
||
{% for slug, name in areaChoices[kind] %}{% if slug != 'default' %}
|
||
<option value="{{ slug }}"{{ page[kind ~ '_area'] == slug ? ' selected' : '' }}>{{ name }}</option>
|
||
{% endif %}{% endfor %}
|
||
<option value="none"{{ page[kind ~ '_area'] == 'none' ? ' selected' : '' }}>Kein Bereich</option>
|
||
</select>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
<button class="secondary" type="submit">Bereiche speichern</button>
|
||
</form>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% if is_granted('ROLE_PUBLISHER') %}
|
||
<div class="sheet-part">
|
||
<h4>Veröffentlichung planen</h4>
|
||
<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 }}">
|
||
<div class="field">
|
||
<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>
|
||
</div>
|
||
<button class="secondary" type="submit">Zeitpunkt speichern</button>
|
||
</form>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% if (is_granted('ROLE_EDITOR') and page.path != 'home') or is_granted('ROLE_ADMIN') %}
|
||
<div class="sheet-part">
|
||
<h4>Entfernen</h4>
|
||
<div class="sheet-actions">
|
||
{% if is_granted('ROLE_EDITOR') and page.path != 'home' %}
|
||
<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 }}">
|
||
<button class="secondary" type="submit">In den Papierkorb</button>
|
||
</form>
|
||
{% endif %}
|
||
{% if is_granted('ROLE_ADMIN') %}
|
||
{# The class is what the security test looks for: an editor must not even be
|
||
offered this. #}
|
||
<div class="delete-panel">
|
||
<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 }}">
|
||
<button class="subtle danger-text" type="submit">Endgültig löschen</button>
|
||
</form>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
<p>Der Papierkorb ist umkehrbar: die Seite samt Unterseiten ist öffentlich nicht mehr erreichbar, alle Fassungen bleiben.{% if is_granted('ROLE_ADMIN') %} Endgültig löschen entfernt „{{ page.title }}“ mit allen Fassungen, ohne Papierkorb, und lässt sich nicht rückgängig machen.{% endif %}</p>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</details>
|
||
{% endif %}
|
||
</article>
|
||
{% else %}
|
||
<div class="empty"><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-head"><h2>Papierkorb <span class="count">{{ trash|length }}</span></h2><span class="muted">Nicht öffentlich erreichbar, alle Fassungen erhalten.</span></div>
|
||
<div class="rows">
|
||
{% for entry in trash %}
|
||
<article class="row">
|
||
<span class="row-icon" aria-hidden="true">🗑</span>
|
||
<div class="row-main">
|
||
<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') %}
|
||
<div class="row-actions">
|
||
<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>
|
||
</div>
|
||
{% endif %}
|
||
</article>
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
|
||
<p class="footnote">KantSites · Arbeitsstand</p>
|
||
{% endblock %}
|