Ergänzt AJAX-Livesuche für Seiten, Beiträge und Mediathek
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s

This commit is contained in:
kantarci committed 2026-09-14 11:09:24 +02:00
1 parent 11b60fc5d8
commit 41d87f62a4
20 files changed
+703 -311

No files matched your search

+1 -1
View File
@@ -18,7 +18,7 @@
Importzeile sie kennen muss. Sie steht vor jedem Modul-Skript; spaeter geladen wirkt sie
nicht mehr. #}
<script type="importmap" nonce="{{ csp_nonce() }}">{"imports":{
{%- for modul in ['editor-model', 'media', 'design-panel', 'rich-text', 'canvas-overlay', 'library-previews', 'preview-containers', 'page-picker', 'motion', 'slider'] %}
{%- for modul in ['editor-model', 'media', 'design-panel', 'rich-text', 'canvas-overlay', 'library-previews', 'preview-containers', 'page-picker', 'motion', 'slider', 'live-search'] %}
"/assets/{{ modul }}.js": "/assets/{{ modul }}.js?v={{ kant_version() }}"{{ loop.last ? '' : ',' }}
{%- endfor %}
}}</script>
+221
View File
@@ -0,0 +1,221 @@
{% set plural = contentType == 'post' ? 'Beiträge' : 'Seiten' %}
<div class="section-head"><h2>{{ status == 'trash' ? 'Papierkorb' : plural }} <span class="count">{{ result.total }}</span></h2><span class="muted">{{ result.total ? (result.page - 1) * result.limit + 1 : 0 }}–{{ min(result.page * result.limit, result.total) }} von {{ result.total }}</span></div>
{% include 'admin/pager.html.twig' with {route: listRoute, params: {q: query, status: status, limit: result.limit}, previousLabel: 'Zurück', nextLabel: 'Weiter', label: 'Ergebnisseiten oben'} %}
<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="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
<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="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
<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>
{% include 'admin/content-picker.html.twig' with {id: 'parent-' ~ page.id, name: 'parent_id', label: 'Übergeordnete Seite (optional)', parents: true, selected: parentChoices[page.parent_id]|default(null)} %}
<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="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
<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="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
<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="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
<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="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
<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 %}
{% if status != 'trash' %}<div class="empty"><h3>{{ result.total == 0 ? 'Keine passenden Einträge.' : '' }}</h3>{% if result.total == 0 %}<p>{{ query or status != 'all' ? 'Ändere die Suche oder den Statusfilter.' : 'Lege oben deinen ersten Inhalt an.' }}</p>{% endif %}</div>{% endif %}
{% endfor %}
</div>
{% if trash is not empty %}
<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="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
<button class="secondary" type="submit">Wiederherstellen</button>
</form>
</div>
{% endif %}
</article>
{% endfor %}
</div>
{% endif %}
{% if status == 'trash' and trash is empty %}<div class="empty"><h3>Der Papierkorb ist leer.</h3></div>{% endif %}
{% include 'admin/pager.html.twig' with {route: listRoute, params: {q: query, status: status, limit: result.limit}, previousLabel: 'Zurück', nextLabel: 'Weiter', label: 'Ergebnisseiten unten'} %}
+59
View File
@@ -0,0 +1,59 @@
<div class="section-head">
<h2>{{ archived ? 'Alle Bilder einschließlich Archiv' : 'Bilder' }} <span class="count">{{ library.total }}</span></h2>
<span class="muted">{{ library.total ? (library.page - 1) * library.perPage + 1 : 0 }}–{{ min(library.page * library.perPage, library.total) }} von {{ library.total }}</span>
</div>
{% if library.items is empty %}
<div class="empty">
<h3>{{ query ? 'Keine passenden Bilder.' : (archived ? 'Keine Bilder vorhanden.' : 'Keine Bilder in der Mediathek.') }}</h3>
<p>{{ query ? 'Ändere oder leere die Suche.' : (is_granted('ROLE_EDITOR') ? 'Lade oben dein erstes Bild hoch.' : 'Ein Redakteur kann Bilder hochladen.') }}</p>
</div>
{% else %}
<ul class="library-grid">
{% for item in library.items %}
<li class="library-tile">
{# The smallest variant is the thumbnail; the largest opens in a new tab. #}
<a href="{{ (item.variants|last).src }}" target="_blank" rel="noopener" aria-label="{{ item.title }} in Originalgröße öffnen">
<img src="{{ (item.variants|first).src }}" alt="" loading="lazy" width="{{ (item.variants|first).width }}" height="{{ (item.variants|first).height }}">
</a>
<div class="library-body">
<strong>{{ item.title }}</strong>
<span class="muted">{{ (item.variants|last).width }} × {{ (item.variants|last).height }} px{% if item.archived %} · archiviert{% endif %}</span>
<span class="muted">{{ item.alt ? 'Alternativtext gesetzt' : 'Kein Alternativtext' }}</span>
<code>{{ item.id }}</code>
</div>
{% if is_granted('ROLE_EDITOR') %}
<details class="sheet">
<summary aria-label="Angaben zu {{ item.title }}">Angaben</summary>
<div class="sheet-body">
<form action="{{ path('admin_media_save', {id: item.id}) }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('media-form') }}">
<input type="hidden" name="version" value="{{ item.version }}">
<input type="hidden" name="seite" value="{{ library.page }}"><input type="hidden" name="q" value="{{ query }}">
<input type="hidden" name="archiv" value="{{ archived ? '1' : '' }}">
<label for="title-{{ item.id }}">Bildtitel</label>
<input id="title-{{ item.id }}" name="title" maxlength="160" required value="{{ item.title }}">
<label for="alt-{{ item.id }}">Alternativtext</label>
<textarea id="alt-{{ item.id }}" name="alt" maxlength="300" rows="2">{{ item.alt }}</textarea>
<p class="field-help">Beschreibt, was zu sehen ist. Das ist, was Menschen mit Screenreader vom Bild haben. Bei rein dekorativen Bildern leer lassen.</p>
<label class="check-label" for="archived-{{ item.id }}"><input id="archived-{{ item.id }}" name="archived" type="checkbox" value="1"{{ item.archived ? ' checked' : '' }}> Archiviert</label>
<p class="field-help">Archivierte Bilder erscheinen nicht mehr in der Auswahl, bleiben aber ausgeliefert – eine Seite, die das Bild verwendet, verliert es nicht.</p>
<button class="primary" type="submit">Angaben speichern</button>
</form>
</div>
</details>
{% endif %}
</li>
{% endfor %}
</ul>
{% if library.pages > 1 %}
<nav class="pager" aria-label="Seiten der Mediathek">
{% if library.page > 1 %}<a class="secondary" href="{{ path('admin_media', {seite: library.page - 1, archiv: archived ? 1 : null, q: query}) }}" rel="prev"><span aria-hidden="true">←</span> Neuere</a>{% endif %}
<span class="muted">Seite {{ library.page }} von {{ library.pages }} · {{ library.total }} Bilder</span>
{% if library.page < library.pages %}<a class="secondary" href="{{ path('admin_media', {seite: library.page + 1, archiv: archived ? 1 : null, q: query}) }}" rel="next">Ältere <span aria-hidden="true">→</span></a>{% endif %}
</nav>
{% endif %}
{% endif %}
<p class="footnote">KantSites · Arbeitsstand</p>
+10 -64
View File
@@ -1,7 +1,7 @@
{% extends 'admin/shell.html.twig' %}
{% set nav = 'media' %}
{% block title %}Mediathek{% endblock %}
{% block scripts %}<script type="module" src="/assets/media-drop.js?v={{ kant_version() }}"></script>{% endblock %}
{% block scripts %}<script type="module" src="/assets/media-drop.js?v={{ kant_version() }}"></script><script type="module" src="/assets/live-search.js?v={{ kant_version() }}"></script>{% endblock %}
{% block heading %}Mediathek{% endblock %}
{% block lead %}Alle Bilder, neueste zuerst. Beim Hochladen wird jedes Bild in WebP umgewandelt und in mehreren Breiten abgelegt; die Seite liefert dann die passende aus.{% endblock %}
{% block content %}
@@ -9,7 +9,7 @@
{% if is_granted('ROLE_EDITOR') %}
<form action="{{ path('admin_media_upload') }}" method="post" enctype="multipart/form-data" class="card card-body">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('media-form') }}">
<input type="hidden" name="seite" value="{{ library.page }}">
<input type="hidden" name="seite" value="{{ library.page }}"><input type="hidden" name="q" value="{{ query }}">
<input type="hidden" name="archiv" value="{{ archived ? '1' : '' }}">
{# A drop area rather than the browser's file widget. The input stays a real file input and
stays focusable, so the form works by keyboard and without JavaScript; the script only adds
@@ -30,66 +30,12 @@
</form>
{% endif %}
<div class="section-head">
<h2>{{ archived ? 'Alle Bilder einschließlich Archiv' : 'Bilder' }} <span class="count">{{ library.total }}</span></h2>
<span class="muted">
{% if archived %}<a href="{{ path('admin_media') }}">Archivierte ausblenden</a>{% else %}<a href="{{ path('admin_media', {archiv: 1}) }}">Archivierte anzeigen</a>{% endif %}
{% if library.pages > 1 %} · Seite {{ library.page }} von {{ library.pages }}{% endif %}
</span>
</div>
{% if library.items is empty %}
<div class="empty">
<h3>{{ archived ? 'Keine Bilder vorhanden.' : 'Keine Bilder in der Mediathek.' }}</h3>
<p>{{ is_granted('ROLE_EDITOR') ? 'Lade oben dein erstes Bild hoch.' : 'Ein Redakteur kann Bilder hochladen.' }}</p>
</div>
{% else %}
<ul class="library-grid">
{% for item in library.items %}
<li class="library-tile">
{# The smallest variant is the thumbnail; the largest opens in a new tab. #}
<a href="{{ (item.variants|last).src }}" target="_blank" rel="noopener" aria-label="{{ item.title }} in Originalgröße öffnen">
<img src="{{ (item.variants|first).src }}" alt="" loading="lazy" width="{{ (item.variants|first).width }}" height="{{ (item.variants|first).height }}">
</a>
<div class="library-body">
<strong>{{ item.title }}</strong>
<span class="muted">{{ (item.variants|last).width }} × {{ (item.variants|last).height }} px{% if item.archived %} · archiviert{% endif %}</span>
<span class="muted">{{ item.alt ? 'Alternativtext gesetzt' : 'Kein Alternativtext' }}</span>
<code>{{ item.id }}</code>
</div>
{% if is_granted('ROLE_EDITOR') %}
<details class="sheet">
<summary aria-label="Angaben zu {{ item.title }}">Angaben</summary>
<div class="sheet-body">
<form action="{{ path('admin_media_save', {id: item.id}) }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('media-form') }}">
<input type="hidden" name="version" value="{{ item.version }}">
<input type="hidden" name="seite" value="{{ library.page }}">
<input type="hidden" name="archiv" value="{{ archived ? '1' : '' }}">
<label for="title-{{ item.id }}">Bildtitel</label>
<input id="title-{{ item.id }}" name="title" maxlength="160" required value="{{ item.title }}">
<label for="alt-{{ item.id }}">Alternativtext</label>
<textarea id="alt-{{ item.id }}" name="alt" maxlength="300" rows="2">{{ item.alt }}</textarea>
<p class="field-help">Beschreibt, was zu sehen ist. Das ist, was Menschen mit Screenreader vom Bild haben. Bei rein dekorativen Bildern leer lassen.</p>
<label class="check-label" for="archived-{{ item.id }}"><input id="archived-{{ item.id }}" name="archived" type="checkbox" value="1"{{ item.archived ? ' checked' : '' }}> Archiviert</label>
<p class="field-help">Archivierte Bilder erscheinen nicht mehr in der Auswahl, bleiben aber ausgeliefert – eine Seite, die das Bild verwendet, verliert es nicht.</p>
<button class="primary" type="submit">Angaben speichern</button>
</form>
</div>
</details>
{% endif %}
</li>
{% endfor %}
</ul>
{% if library.pages > 1 %}
<nav class="pager" aria-label="Seiten der Mediathek">
{% if library.page > 1 %}<a class="secondary" href="{{ path('admin_media', {seite: library.page - 1, archiv: archived ? 1 : null}) }}" rel="prev"><span aria-hidden="true">←</span> Neuere</a>{% endif %}
<span class="muted">Seite {{ library.page }} von {{ library.pages }} · {{ library.total }} Bilder</span>
{% if library.page < library.pages %}<a class="secondary" href="{{ path('admin_media', {seite: library.page + 1, archiv: archived ? 1 : null}) }}" rel="next">Ältere <span aria-hidden="true">→</span></a>{% endif %}
</nav>
{% endif %}
{% endif %}
<p class="footnote">KantSites · Arbeitsstand</p>
<form method="get" action="{{ path('admin_media') }}" class="list-toolbar" role="search" data-live-search>
<div class="list-search"><label for="media-list-search">Bilder durchsuchen</label><input id="media-list-search" type="search" name="q" maxlength="160" value="{{ query }}" placeholder="Titel oder Alternativtext …"></div>
<div><label for="media-list-archive">Status</label><select id="media-list-archive" name="archiv" data-search-default="0"><option value="0"{{ not archived ? ' selected' : '' }}>Aktive Bilder</option><option value="1"{{ archived ? ' selected' : '' }}>Mit archivierten Bildern</option></select></div>
<button class="secondary search-submit" type="submit">Anwenden</button>
<a class="text-button" data-search-reset href="{{ path('admin_media') }}"{{ not query and not archived ? ' hidden' : '' }}>Zurücksetzen</a>
</form>
{% include 'admin/search-feedback.html.twig' %}
<div id="list-results" class="stack" role="region" aria-label="Suchergebnisse" tabindex="-1">{% include 'admin/media-results.html.twig' %}</div>
{% endblock %}
+9 -227
View File
@@ -4,7 +4,7 @@
{% set singular = contentType == 'post' ? 'Beitrag' : 'Seite' %}
{% block title %}{{ plural }}{% endblock %}
{% block heading %}{{ plural }}{% endblock %}
{% block scripts %}<script type="module" src="/assets/page-picker.js?v={{ kant_version() }}"></script>{% endblock %}
{% block scripts %}<script type="module" src="/assets/live-search.js?v={{ kant_version() }}"></script>{% endblock %}
{% block lead %}{{ plural }} suchen, bearbeiten und veröffentlichen. Entwürfe bleiben bis zur Veröffentlichung privat.{% endblock %}
{% block content %}
@@ -43,231 +43,13 @@
</details>
{% endif %}
<form method="get" action="{{ path(listRoute) }}" class="list-toolbar" role="search">
<div class="list-search"><label for="page-search">{{ plural }} durchsuchen</label><input id="page-search" type="search" name="q" value="{{ query }}" placeholder="Titel, Adresse oder Inhalt …"></div>
<div><label for="page-status">Status</label><select id="page-status" name="status">{% for value, label in {all: 'Alle aktiven', draft: 'Entwürfe', published: 'Veröffentlicht', changed: 'Entwurf geändert', trash: 'Papierkorb'} %}<option value="{{ value }}"{{ status == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}</select></div>
<div><label for="page-limit">Pro Seite</label><select id="page-limit" name="limit">{% for size in [20, 50] %}<option value="{{ size }}"{{ result.limit == size ? ' selected' : '' }}>{{ size }}</option>{% endfor %}</select></div>
<button class="secondary" type="submit">Anwenden</button>
{% if query or status != 'all' %}<a class="text-button" href="{{ path(listRoute) }}">Zurücksetzen</a>{% endif %}
<form method="get" action="{{ path(listRoute) }}" class="list-toolbar" role="search" data-live-search>
<div class="list-search"><label for="page-search">{{ plural }} durchsuchen</label><input id="page-search" type="search" name="q" maxlength="160" value="{{ query }}" placeholder="Titel, Adresse oder Inhalt …"></div>
<div><label for="page-status">Status</label><select id="page-status" name="status" data-search-default="all">{% for value, label in {all: 'Alle aktiven', draft: 'Entwürfe', published: 'Veröffentlicht', changed: 'Entwurf geändert', trash: 'Papierkorb'} %}<option value="{{ value }}"{{ status == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}</select></div>
<div><label for="page-limit">Pro Seite</label><select id="page-limit" name="limit" data-search-default="20">{% for size in [20, 50] %}<option value="{{ size }}"{{ result.limit == size ? ' selected' : '' }}>{{ size }}</option>{% endfor %}</select></div>
<button class="secondary search-submit" type="submit">Anwenden</button>
<a class="text-button" data-search-reset href="{{ path(listRoute) }}"{{ not query and status == 'all' and result.limit == 20 ? ' hidden' : '' }}>Zurücksetzen</a>
</form>
<div class="section-head"><h2>{{ status == 'trash' ? 'Papierkorb' : plural }} <span class="count">{{ result.total }}</span></h2><span class="muted">{{ result.total ? (result.page - 1) * result.limit + 1 : 0 }}–{{ min(result.page * result.limit, result.total) }} von {{ result.total }}</span></div>
{% include 'admin/pager.html.twig' with {route: listRoute, params: {q: query, status: status, limit: result.limit}, previousLabel: 'Zurück', nextLabel: 'Weiter', label: 'Ergebnisseiten oben'} %}
<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="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
<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="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
<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>
{% include 'admin/content-picker.html.twig' with {id: 'parent-' ~ page.id, name: 'parent_id', label: 'Übergeordnete Seite (optional)', parents: true, selected: parentChoices[page.parent_id]|default(null)} %}
<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="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
<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="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
<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="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
<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="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
<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 %}
{% if status != 'trash' %}<div class="empty"><h3>{{ result.total == 0 ? 'Keine passenden Einträge.' : '' }}</h3>{% if result.total == 0 %}<p>{{ query or status != 'all' ? 'Ändere die Suche oder den Statusfilter.' : 'Lege oben deinen ersten Inhalt an.' }}</p>{% endif %}</div>{% endif %}
{% endfor %}
</div>
{% if trash is not empty %}
<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="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
<button class="secondary" type="submit">Wiederherstellen</button>
</form>
</div>
{% endif %}
</article>
{% endfor %}
</div>
{% endif %}
{% if status == 'trash' and trash is empty %}<div class="empty"><h3>Der Papierkorb ist leer.</h3></div>{% endif %}
{% include 'admin/pager.html.twig' with {route: listRoute, params: {q: query, status: status, limit: result.limit}, previousLabel: 'Zurück', nextLabel: 'Weiter', label: 'Ergebnisseiten unten'} %}
{% include 'admin/search-feedback.html.twig' %}
<div id="list-results" class="stack" role="region" aria-label="Suchergebnisse" tabindex="-1">{% include 'admin/content-results.html.twig' %}</div>
{% endblock %}
@@ -0,0 +1,2 @@
<p id="search-status" class="field-help search-status" role="status" aria-live="polite" aria-atomic="true"></p>
<div id="search-error" class="notice error" hidden><p role="alert"></p><button id="search-retry" class="secondary" type="button">Erneut suchen</button></div>