Wiederkehrende Aufgaben ohne Cronjob (ADR 073): Sicherung, Statistik kuerzen, geplante Veroeffentlichung, Updatepruefung und Aufraeumen laufen als Plan in der Tabelle scheduled_task - eine Zeile je Aufgabe mit Faelligkeit, Sperre und letztem Ergebnis. Auf geteiltem Hosting gibt es oft keinen Cronjob; der Plan haengt stattdessen an Anfragen und an einem Takt aus dem geoeffneten Backend. Diese Arbeit entstand in einer parallel laufenden Sitzung und lag bis hierher unveroeffentlicht im Arbeitsverzeichnis: die Releases 1.5.0 bis 1.5.5 wurden bewusst aus dem jeweiligen Tag gebaut und enthielten sie deshalb nicht. Vor der Veroeffentlichung geprueft, nicht bloss eingepackt: - phpunit 452/452, npm test 28/28, npm run test:editor vollstaendig. - Die Migration 20260914000100 legt nur die Tabelle scheduled_task an und nimmt sie beim Zurueckrollen wieder weg. Sie fasst keine bestehenden Daten an. - Seit einer Stunde keine Schreibzugriffe der anderen Sitzung mehr. Dabei zwei Fehler behoben: - config/services.yaml verwendet !tagged_iterator. Das ist gueltiges Symfony, aber "lint:yaml config" kennt eigene Tags nur mit --parse-tags und gab 1 zurueck - der CI-Lauf waere daran gescheitert. Flag ergaenzt. - AdminHeadersSubscriber.php (aus 1.5.3) entsprach nicht dem Stil von php-cs-fixer; composer lint gab 8 zurueck. Behoben. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
271 lines
18 KiB
Twig
271 lines
18 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-layout">Aufbau (nur für Beiträge)</label>
|
||
<select id="new-layout" name="layout">
|
||
{% for key, layout in postLayouts %}<option value="{{ key }}"{{ key == 'bildkopf' ? ' selected' : '' }}>{{ layout.name }}</option>{% endfor %}
|
||
</select>
|
||
<p class="field-help">{% for key, layout in postLayouts %}<strong>{{ layout.name }}:</strong> {{ layout.description }}{% if not loop.last %} <br>{% endif %}{% endfor %} Jeder Block lässt sich danach ändern.</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="seotitle-{{ page.id }}">Titel für Suchergebnisse</label>
|
||
<input id="seotitle-{{ page.id }}" name="seoTitle" maxlength="160" value="{{ page.seo_title }}" placeholder="{{ page.document.title|default(page.path) }}">
|
||
<p class="field-help">Leer lassen, wenn der Titel der Seite auch in der Suche stehen soll. Google zeigt etwa 60 Zeichen und schneidet danach ab; was dahinter steht, liest niemand.</p>
|
||
</div>
|
||
<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. Veröffentlicht wird beim ersten Aufruf der Website nach diesem Zeitpunkt; wie oft nachgesehen wird, steht unter <a href="{{ path('admin_tasks') }}">Aufgaben</a>.</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 %}
|