Fassung 1.7.0: verlässliche Installation, übersichtliche Verwaltung und bedienbare Bewegung
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 10:49:54 +02:00
1 parent b164c4edfe
commit 495b26896e
55 files changed
+1632 -423

No files matched your search

+2 -1
View File
@@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<meta name="admin-base" content="{{ path('admin_pages') }}">
<title>{% block title %}Verwaltung{% endblock %} · KantSites</title>
{% include 'favicon.html.twig' %}
<link rel="stylesheet" href="/assets/admin.css?v={{ kant_version() }}">
@@ -17,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'] %}
{%- for modul in ['editor-model', 'media', 'design-panel', 'rich-text', 'canvas-overlay', 'library-previews', 'preview-containers', 'page-picker', 'motion', 'slider'] %}
"/assets/{{ modul }}.js": "/assets/{{ modul }}.js?v={{ kant_version() }}"{{ loop.last ? '' : ',' }}
{%- endfor %}
}}</script>
+11
View File
@@ -0,0 +1,11 @@
{% set selected = selected|default(null) %}
<div class="content-picker" data-source="{{ path('admin_content_choices') }}" data-type="{{ type|default('') }}" data-parents="{{ parents|default(false) ? '1' : '0' }}">
<label for="{{ id }}">{{ label|default('Seite suchen') }}</label>
<div class="picker-input">
<input id="{{ id }}" type="search" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="{{ id }}-results" placeholder="Titel oder Adresse suchen …" value="{{ selected ? selected.title ~ ' (/' ~ selected.path ~ ')' : '' }}">
<button type="button" class="subtle picker-clear" aria-label="{{ label|default('Seite') }}: Auswahl entfernen">✕</button>
</div>
<input type="hidden" class="picker-value" name="{{ name }}" value="{{ selected ? selected.id : '' }}">
<div id="{{ id }}-results" class="picker-results" role="listbox" aria-label="Suchergebnisse" hidden></div>
<p class="picker-status field-help" role="status"></p>
</div>
+6 -5
View File
@@ -1,6 +1,7 @@
{% extends 'admin/base.html.twig' %}
{% set area = partial|default(null) %}
{% set subject = area ? area.document : page.document %}
{% set listRoute = area ? 'admin_partials' : (page.type == 'post' ? 'admin_posts' : 'admin_pages') %}
{% block title %}{{ subject.title }} bearbeiten{% endblock %}
{% block body_class %}editor-body{% endblock %}
{% block styles %}<link rel="stylesheet" href="/assets/editor.css?v={{ kant_version() }}">{% endblock %}
@@ -8,12 +9,12 @@
{% block body %}
{# The API base decides what is edited. An area is shared, so it has no draft and no
publication: saving is live, and the publish button is left out. #}
<div id="editor" data-api="{{ area ? '/admin/partials/' ~ area.id : '/admin/api/pages/' ~ page.id }}" data-kind="{{ area ? area.kind : 'page' }}" data-can-script="{{ is_granted('ROLE_ADMIN') ? 'true' : 'false' }}" data-can-publish="{{ not area and is_granted('ROLE_PUBLISHER') ? 'true' : 'false' }}">
<div id="editor" data-api="{{ area ? path('admin_partial_editor', {id: area.id}) : path('admin_document', {id: page.id}) }}" data-kind="{{ area ? area.kind : 'page' }}" data-can-script="{{ is_granted('ROLE_ADMIN') ? 'true' : 'false' }}" data-can-publish="{{ not area and is_granted('ROLE_PUBLISHER') ? 'true' : 'false' }}">
<header class="editor-header">
<a class="back-link" href="{{ area ? path('admin_partials') : path('admin_pages') }}" aria-label="{{ area ? 'Zur Bereichsübersicht' : 'Zur Seitenübersicht' }}">← <span>{{ area ? 'Bereiche' : 'Seiten' }}</span></a>
<a class="back-link" href="{{ path(listRoute) }}" aria-label="{{ area ? 'Zur Bereichsübersicht' : (page.type == 'post' ? 'Zur Beitragsübersicht' : 'Zur Seitenübersicht') }}">← <span>{{ area ? 'Bereiche' : (page.type == 'post' ? 'Beiträge' : 'Seiten') }}</span></a>
<div class="editor-page-name"><h1>{{ subject.title }}</h1><span>{{ area ? kinds[area.kind]|default('Bereich') : '/' ~ (page.slug == 'home' ? '' : page.slug) }}</span></div>
<div class="history-actions"><button id="undo" class="icon-button" title="Rückgängig (Strg+Z)" aria-label="Rückgängig" disabled>↶</button><button id="redo" class="icon-button" title="Wiederholen (Strg+Umschalt+Z)" aria-label="Wiederholen" disabled>↷</button></div>
<div class="save-actions"><span id="save-status" role="status" aria-live="polite">Wird geladen …</span><button id="save" class="secondary" disabled>Entwurf speichern</button>{% if is_granted('ROLE_PUBLISHER') %}<button id="publish" class="primary" disabled>Veröffentlichen</button>{% endif %}</div>
<div class="save-actions"><span id="save-status" role="status" aria-live="polite">Wird geladen …</span><button id="save" class="secondary" disabled>{{ area ? 'Bereich speichern' : 'Entwurf speichern' }}</button>{% if not area and is_granted('ROLE_PUBLISHER') %}<button id="publish" class="primary" disabled>Veröffentlichen</button>{% endif %}</div>
</header>
<div id="message" class="editor-message" role="alert" hidden></div>
<div id="recovery" class="recovery-bar" hidden><button id="download" class="secondary">Änderungen als JSON sichern</button><button id="reload" class="secondary">Serverstand laden</button><a href="{{ path('admin_login') }}" target="_blank" rel="noopener">Erneut anmelden ↗</a></div>
@@ -36,13 +37,13 @@
</aside>
<div class="panel-resizer" data-resize="left" role="separator" tabindex="0" aria-label="Linke Seitenleiste vergrößern oder verkleinern" aria-orientation="vertical" aria-valuemin="240" aria-valuemax="560" aria-valuenow="280" title="Ziehen oder Pfeiltasten · Doppelklick setzt zurück"></div>
<section class="canvas-panel" aria-label="Live-Vorschau">
<div class="canvas-toolbar"><span class="canvas-label">LIVE-VORSCHAU</span><div class="viewport-controls" role="group" aria-label="Vorschaugröße"><button data-viewport="desktop" aria-pressed="true">Desktop</button><button data-viewport="tablet" aria-pressed="false">Tablet</button><button data-viewport="mobile" aria-pressed="false">Mobil</button></div><a href="{{ page.slug == 'home' ? '/' : '/' ~ page.slug }}" target="_blank" rel="noopener" aria-label="Öffentliche Seite öffnen">↗</a></div>
<div class="canvas-toolbar"><span class="canvas-label">LIVE-VORSCHAU</span><div class="viewport-controls" role="group" aria-label="Vorschaugröße"><button data-viewport="desktop" aria-pressed="true">Desktop</button><button data-viewport="tablet" aria-pressed="false">Tablet</button><button data-viewport="mobile" aria-pressed="false">Mobil</button></div><button id="preview-motion" type="button" class="text-button" aria-pressed="false">Bewegung testen</button>{% if not area %}<a href="{{ page.path == 'home' ? '/' : '/' ~ page.path }}" target="_blank" rel="noopener" aria-label="Öffentliche Seite öffnen">↗</a>{% endif %}</div>
<p id="preview-status" class="preview-status" role="status">Vorschau wird geladen …</p>
<nav id="breadcrumbs" class="breadcrumbs" aria-label="Ausgewähltes Element"></nav>
<div class="canvas-scroll" tabindex="0" role="region" aria-label="Vorschau scrollen"><div id="preview-stage"><iframe id="preview" title="Vorschau der bearbeiteten Seite" sandbox="allow-same-origin" class="viewport-desktop"></iframe></div></div>
</section>
<div class="panel-resizer" data-resize="right" role="separator" tabindex="0" aria-label="Rechte Seitenleiste vergrößern oder verkleinern" aria-orientation="vertical" aria-valuemin="280" aria-valuemax="560" aria-valuenow="320" title="Ziehen oder Pfeiltasten · Doppelklick setzt zurück"></div>
<aside class="right-panel" aria-label="Eigenschaften"><div class="panel-section"><p class="eyebrow">FEINSCHLIFF</p><h2 id="properties-title">Seiteneinstellungen</h2><div class="property-tabs" role="group" aria-label="Eigenschaftenbereich"><button data-panel="content" aria-pressed="true">Inhalt</button><button data-panel="design" aria-pressed="false">Design</button><button data-panel="advanced" aria-pressed="false">Erweitert</button></div><div id="properties"><p class="muted">Inhalte werden geladen …</p></div></div><div class="panel-note">Die Vorschau zeigt deinen Entwurf. Besucher sehen weiterhin den veröffentlichten Stand.</div></aside>
<aside class="right-panel" aria-label="Eigenschaften"><div class="panel-section"><p class="eyebrow">FEINSCHLIFF</p><h2 id="properties-title">Seiteneinstellungen</h2><div class="property-tabs" role="group" aria-label="Eigenschaftenbereich"><button data-panel="content" aria-pressed="true">Inhalt</button><button data-panel="design" aria-pressed="false">Design</button><button data-panel="advanced" aria-pressed="false">Erweitert</button></div><div id="properties"><p class="muted">Inhalte werden geladen …</p></div></div><div class="panel-note">{{ area ? 'Speichern aktualisiert diesen Bereich auf allen Seiten, die ihn verwenden.' : 'Die Vorschau zeigt deinen Entwurf. Besucher sehen weiterhin den veröffentlichten Stand.' }}</div></aside>
</main>
<noscript><p class="notice error">Zum Bearbeiten muss JavaScript aktiviert sein. Die öffentliche Website funktioniert ohne JavaScript.</p></noscript>
<dialog id="publish-dialog" aria-labelledby="publish-title"><h2 id="publish-title">Seite veröffentlichen?</h2><p>Der gespeicherte Entwurf ersetzt den bisher sichtbaren Inhalt dieser Seite.</p><div class="dialog-actions"><button id="cancel-publish" class="secondary">Abbrechen</button><button id="confirm-publish" class="primary">Jetzt veröffentlichen</button></div></dialog>
+8 -8
View File
@@ -40,9 +40,9 @@
<input id="reply-{{ loop.index }}" name="replyToField" maxlength="32" value="{{ new ? '' : form.settings.replyToField }}" placeholder="email">
<p class="field-help">Technischer Name eines E-Mail-Feldes. Dann lässt sich direkt antworten.</p>
</div>
<div>
<div class="field-wide">
<label for="success-{{ loop.index }}">Meldung nach dem Absenden</label>
<input id="success-{{ loop.index }}" name="success" maxlength="300" value="{{ new ? '' : form.settings.success }}">
<textarea id="success-{{ loop.index }}" name="success" maxlength="300" rows="3" placeholder="Vielen Dank. Wir melden uns in Kürze bei dir.">{{ new ? '' : form.settings.success }}</textarea>
</div>
</div>
@@ -53,16 +53,16 @@
{% for row in rows|merge([{name: '', type: 'text', label: '', required: false, help: '', options: []}, {name: '', type: 'text', label: '', required: false, help: '', options: []}]) %}
{% set n = loop.index0 %}
<tr{% if row.label == '' %} class="table-new"{% endif %}>
<td><input name="fields[{{ n }}][name]" maxlength="32" value="{{ row.name }}" pattern="[a-z][a-z0-9_]*" aria-label="Technischer Name von Feld {{ n + 1 }}" placeholder="vorname"></td>
<td>
<td data-label="Technischer Name"><input name="fields[{{ n }}][name]" maxlength="32" value="{{ row.name }}" pattern="[a-z][a-z0-9_]*" aria-label="Technischer Name von Feld {{ n + 1 }}" placeholder="vorname"></td>
<td data-label="Typ">
<select name="fields[{{ n }}][type]" aria-label="Typ von Feld {{ n + 1 }}">
{% for value, label in types %}<option value="{{ value }}"{{ row.type == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}
</select>
</td>
<td><input name="fields[{{ n }}][label]" maxlength="120" value="{{ row.label }}" aria-label="Beschriftung von Feld {{ n + 1 }}"></td>
<td class="check-cell"><input type="checkbox" name="fields[{{ n }}][required]" value="1"{{ row.required ? ' checked' : '' }} aria-label="Feld {{ n + 1 }} ist ein Pflichtfeld"></td>
<td><input name="fields[{{ n }}][help]" maxlength="200" value="{{ row.help }}" aria-label="Hilfetext von Feld {{ n + 1 }}"></td>
<td><input name="fields[{{ n }}][options]" maxlength="500" value="{{ row.options|join(', ') }}" aria-label="Optionen von Feld {{ n + 1 }}" placeholder="nur bei Auswahl"></td>
<td data-label="Beschriftung"><input name="fields[{{ n }}][label]" maxlength="120" value="{{ row.label }}" aria-label="Beschriftung von Feld {{ n + 1 }}"></td>
<td class="check-cell" data-label="Pflichtfeld"><input type="checkbox" name="fields[{{ n }}][required]" value="1"{{ row.required ? ' checked' : '' }} aria-label="Feld {{ n + 1 }} ist ein Pflichtfeld"></td>
<td data-label="Hilfetext"><input name="fields[{{ n }}][help]" maxlength="200" value="{{ row.help }}" aria-label="Hilfetext von Feld {{ n + 1 }}"></td>
<td data-label="Optionen"><input name="fields[{{ n }}][options]" maxlength="500" value="{{ row.options|join(', ') }}" aria-label="Optionen von Feld {{ n + 1 }}" placeholder="nur bei Auswahl"></td>
</tr>
{% endfor %}
</tbody>
+24
View File
@@ -0,0 +1,24 @@
<li class="menu-entry{{ row.level == 2 ? ' menu-child' : '' }}" data-level="{{ row.level }}">
<input type="hidden" data-key="position" name="items[{{ n }}][position]" value="{{ n }}">
<input type="hidden" data-key="level" name="items[{{ n }}][level]" value="{{ row.level }}">
<input type="hidden" data-key="type" name="items[{{ n }}][type]" value="{{ row.type }}">
<details>
<summary><strong class="menu-entry-title">{{ row.label }}</strong><span class="muted">{{ row.type == 'page' ? 'Inhalt' : 'Eigener Link' }}</span></summary>
<div class="menu-entry-fields">
<label for="menu-label-{{ n }}">Beschriftung</label><input id="menu-label-{{ n }}" data-key="label" name="items[{{ n }}][label]" maxlength="80" required value="{{ row.label }}">
{% if row.type == 'page' %}
{% include 'admin/content-picker.html.twig' with {id: 'menu-target-' ~ n, name: 'items[' ~ n ~ '][pageId]', label: 'Verknüpfter Inhalt', selected: row.page} %}
<label for="menu-anchor-{{ n }}">Sprungziel (optional)</label><input id="menu-anchor-{{ n }}" data-key="anchor" name="items[{{ n }}][anchor]" maxlength="65" value="{{ row.anchor }}" placeholder="kontakt">
{% else %}
<label for="menu-url-{{ n }}">Adresse</label><input id="menu-url-{{ n }}" data-key="url" name="items[{{ n }}][url]" maxlength="2048" required value="{{ row.url }}" placeholder="https://… oder /kontakt">
{% endif %}
</div>
</details>
<div class="menu-entry-actions" role="group" aria-label="{{ row.label }} verschieben oder entfernen">
<button class="text-button" type="button" data-menu-action="up" aria-label="{{ row.label }} nach oben">↑</button>
<button class="text-button" type="button" data-menu-action="down" aria-label="{{ row.label }} nach unten">↓</button>
<button class="text-button" type="button" data-menu-action="indent">Als Unterpunkt</button>
<button class="text-button" type="button" data-menu-action="outdent">Hauptebene</button>
<button class="text-button danger-text" type="button" data-menu-action="remove">Entfernen</button>
</div>
</li>
+29 -50
View File
@@ -2,55 +2,34 @@
{% set nav = 'menus' %}
{% block title %}Menüs{% endblock %}
{% block heading %}Menüs{% endblock %}
{% block lead %}Ein Eintrag verweist auf eine Seite, auf ein Sprungziel innerhalb einer Seite oder auf eine externe Adresse. Die Reihenfolge ergibt sich aus der Position, die Verschachtelung aus der Ebene.{% endblock %}
{% block lead %}Inhalte suchen und hinzufügen. Rechts bestimmst du Beschriftung, Reihenfolge und Unterpunkte.{% endblock %}
{% block scripts %}<script type="module" src="/assets/menu-editor.js?v={{ kant_version() }}"></script>{% endblock %}
{% block content %}
{% for slot, slotName in slots %}
{% set menu = menus[slot]|default(null) %}
<section class="stack-section">
<div class="section-head"><h2>{{ slotName }} <span class="count">{{ rows[slot]|length }}</span></h2><span class="muted">Platz <code>{{ slot }}</code></span></div>
<form action="{{ path('admin_menu_save', {slot: slot}) }}" method="post" class="menu-form card card-body">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('menus') }}">
<input type="hidden" name="expectedVersion" value="{{ menu ? menu.version : 0 }}">
<label for="name-{{ slot }}">Name des Menüs</label>
<input id="name-{{ slot }}" name="name" maxlength="120" required value="{{ menu ? menu.name : slotName }}">
<table class="table menu-table">
<caption class="visually-hidden">Einträge im Menü {{ slotName }}. Eine Beschriftung zu leeren entfernt den Eintrag.</caption>
<thead><tr><th scope="col">Position</th><th scope="col">Ebene</th><th scope="col">Beschriftung</th><th scope="col">Ziel</th><th scope="col">Seite</th><th scope="col">Sprungziel</th><th scope="col">Adresse</th></tr></thead>
<tbody>
{% for index, row in rows[slot]|merge([{label: '', type: 'page', level: 1, pageId: '', anchor: '', url: ''}, {label: '', type: 'page', level: 1, pageId: '', anchor: '', url: ''}, {label: '', type: 'page', level: 1, pageId: '', anchor: '', url: ''}]) %}
{% set n = loop.index0 %}
<tr{% if row.label == '' %} class="table-new"{% endif %}>
<td><input type="number" name="items[{{ n }}][position]" value="{{ (n + 1) * 10 }}" min="0" max="9999" step="1" aria-label="Position von Eintrag {{ n + 1 }}"></td>
<td>
<select name="items[{{ n }}][level]" aria-label="Ebene von Eintrag {{ n + 1 }}">
<option value="1"{{ row.level == 1 ? ' selected' : '' }}>1 — oben</option>
<option value="2"{{ row.level == 2 ? ' selected' : '' }}>2 — Untereintrag</option>
</select>
</td>
<td><input name="items[{{ n }}][label]" maxlength="80" value="{{ row.label }}" aria-label="Beschriftung von Eintrag {{ n + 1 }}" placeholder="{{ row.label == '' ? 'Neuer Eintrag' : '' }}"></td>
<td>
<select name="items[{{ n }}][type]" aria-label="Zielart von Eintrag {{ n + 1 }}">
<option value="page"{{ row.type == 'page' ? ' selected' : '' }}>Seite</option>
<option value="url"{{ row.type == 'url' ? ' selected' : '' }}>Adresse</option>
</select>
</td>
<td>
<select name="items[{{ n }}][pageId]" aria-label="Seite von Eintrag {{ n + 1 }}">
<option value="">— Seite wählen —</option>
{% for page in pages %}<option value="{{ page.id }}"{{ row.pageId == page.id ? ' selected' : '' }}>{{ page.title }} (/{{ page.path == 'home' ? '' : page.path }})</option>{% endfor %}
</select>
</td>
<td><input name="items[{{ n }}][anchor]" maxlength="65" value="{{ row.anchor }}" aria-label="Sprungziel von Eintrag {{ n + 1 }}" placeholder="kontakt"></td>
<td><input name="items[{{ n }}][url]" maxlength="2048" value="{{ row.url }}" aria-label="Adresse von Eintrag {{ n + 1 }}" placeholder="https://…"></td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="field-help">Ein Sprungziel gilt zusammen mit der gewählten Seite und führt zu <code>/seite#sprungziel</code>. Als Adresse sind HTTPS-Adressen, lokale Pfade sowie <code>mailto:</code> und <code>tel:</code> erlaubt. Die letzten drei Zeilen sind für neue Einträge; beim Speichern werden sie neu durchnummeriert.</p>
<button class="primary" type="submit">{{ slotName }} speichern</button>
</form>
</section>
{% endfor %}
<p class="footnote">KantSites · Arbeitsstand</p>
<nav class="menu-slots" aria-label="Menü auswählen">{% for key, label in slots %}<a href="{{ path('admin_menus', {slot: key}) }}"{{ key == slot ? ' aria-current="page"' : '' }}>{{ label }}</a>{% endfor %}</nav>
<form action="{{ path('admin_menu_save', {slot: slot}) }}" method="post" id="menu-editor" class="menu-workspace menu-form">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('menus') }}"><input type="hidden" name="expectedVersion" value="{{ menu ? menu.version : 0 }}">
<aside class="menu-add" aria-label="Menüeinträge hinzufügen">
<section class="card card-body"><h2>Seiten und Beiträge</h2>
{% include 'admin/content-picker.html.twig' with {id: 'menu-search', name: 'selected_content', label: 'Inhalte suchen'} %}
<button type="button" class="secondary full-width" id="menu-add-page" disabled>Zum Menü hinzufügen</button>
</section>
<details class="card"><summary>Eigener Link</summary><div class="card-body">
<label for="link-label">Beschriftung</label><input id="link-label" maxlength="80" placeholder="Kontakt">
<label for="link-url">Adresse</label><input id="link-url" maxlength="2048" placeholder="https://… oder /kontakt">
<p class="field-help">Auch mailto: und tel: sind möglich.</p>
<button type="button" class="secondary" id="menu-add-url">Link hinzufügen</button>
</div></details>
<p class="field-help">Änderungen werden mit „Menü speichern“ auf der Website sichtbar. Unterpunkte gehören zum jeweils darüberstehenden Haupteintrag.</p>
</aside>
<section class="card menu-structure" aria-label="Menüstruktur">
<div class="card-head"><h2>{{ slotName }}</h2><span class="count" id="menu-count">{{ rows|length }} Einträge</span></div>
<div class="card-body">
<label for="menu-name">Name des Menüs</label><input id="menu-name" name="name" maxlength="120" required value="{{ menu ? menu.name : slotName }}">
<ol id="menu-items" class="menu-items">{% for row in rows %}{% include 'admin/menu-entry.html.twig' with {row: row, n: loop.index0} %}{% endfor %}</ol>
<p id="menu-empty" class="empty"{{ rows is not empty ? ' hidden' : '' }}>Dieses Menü hat noch keine Einträge. Füge links eine Seite oder einen Link hinzu.</p>
<div class="menu-save"><button class="primary" type="submit">Menü speichern</button><button type="button" class="secondary" id="menu-undo" disabled>Rückgängig</button><span id="menu-status" role="status">Gespeicherter Stand</span></div>
<p id="menu-error" class="notice error" role="alert" hidden></p>
</div>
</section>
</form>
{% endblock %}
+2 -2
View File
@@ -3,8 +3,8 @@
A fixed upper limit shows the first entries and says nothing about the rest; this says both. #}
{% if result.pages > 1 %}
<nav class="pager" aria-label="{{ label|default('Seiten') }}">
{% if result.page > 1 %}<a class="secondary" href="{{ path(route, params|default({})|merge({seite: result.page - 1})) }}" rel="prev"><span aria-hidden="true">←</span> Neuere</a>{% endif %}
{% if result.page > 1 %}<a class="secondary" href="{{ path(route, params|default({})|merge({seite: result.page - 1})) }}" rel="prev"><span aria-hidden="true">←</span> {{ previousLabel|default('Neuere') }}</a>{% endif %}
<span class="muted">Seite {{ result.page }} von {{ result.pages }} · {{ result.total }} Einträge</span>
{% if result.page < result.pages %}<a class="secondary" href="{{ path(route, params|default({})|merge({seite: result.page + 1})) }}" rel="next">Ältere <span aria-hidden="true">→</span></a>{% endif %}
{% if result.page < result.pages %}<a class="secondary" href="{{ path(route, params|default({})|merge({seite: result.page + 1})) }}" rel="next">{{ nextLabel|default('Ältere') }} <span aria-hidden="true">→</span></a>{% endif %}
</nav>
{% endif %}
+43 -40
View File
@@ -1,14 +1,18 @@
{% 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 %}
{% set nav = contentType == 'post' ? 'posts' : 'pages' %}
{% set plural = contentType == 'post' ? 'Beiträge' : 'Seiten' %}
{% 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 lead %}{{ plural }} suchen, bearbeiten und veröffentlichen. Entwürfe bleiben bis zur Veröffentlichung 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>
<summary>{{ contentType == 'post' ? 'Neuen Beitrag' : 'Neue Seite' }} anlegen</summary>
<form action="{{ path('admin_create') }}" method="post" class="create-form card-body">
<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') }}">
<div class="fields">
<div>
@@ -21,36 +25,33 @@
<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>
<input type="hidden" id="new-type" name="type" value="{{ contentType }}">
{% if contentType == 'post' %}
<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>
<label for="new-layout">Beitragsvorlage</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>
{% else %}
{% include 'admin/content-picker.html.twig' with {id: 'new-parent', name: 'parent_id', label: 'Übergeordnete Seite (optional)', parents: true} %}
{% endif %}
</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>
<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>
<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
@@ -85,7 +86,8 @@
<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="_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>
@@ -143,7 +145,8 @@
<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="_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>
@@ -151,13 +154,7 @@
<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>
{% 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>
@@ -171,7 +168,8 @@
<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="_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'} %}
@@ -196,7 +194,8 @@
<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="_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>
@@ -214,7 +213,8 @@
<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="_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>
@@ -224,7 +224,8 @@
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="_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>
@@ -239,12 +240,12 @@
{% endif %}
</article>
{% else %}
<div class="empty"><h3>Noch keine Seiten vorhanden.</h3><p>Lege oben deine erste Seite an.</p></div>
{% 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="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">
@@ -256,7 +257,8 @@
{% 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') }}">
<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>
@@ -266,5 +268,6 @@
</div>
{% endif %}
<p class="footnote">KantSites · Arbeitsstand</p>
{% 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'} %}
{% endblock %}
+1 -1
View File
@@ -19,7 +19,7 @@
<select id="new-kind" name="kind">
{% for value, label in kinds %}<option value="{{ value }}"{{ value == 'section' ? ' selected' : '' }}>{{ label }}</option>{% endfor %}
</select>
<p class="field-help">Kopf- und Fußbereich gibt es je einmal.</p>
<p class="field-help">Kopf- und Fußbereiche können je Seite zugewiesen werden. Sektionen lassen sich auf mehreren Seiten verknüpfen.</p>
</div>
<div>
<label for="new-slug">Adresse</label>
+3 -3
View File
@@ -20,7 +20,7 @@
<summary>
<svg class="shell-menu-icon" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
<span class="shell-menu-label">{{ {
'pages': 'Seiten', 'partials': 'Bereiche', 'menus': 'Menüs', 'media': 'Mediathek',
'pages': 'Seiten', 'posts': 'Beiträge', 'partials': 'Bereiche', 'menus': 'Menüs', 'media': 'Mediathek',
'forms': 'Formulare', 'statistics': 'Statistik', 'theme': 'Design',
'settings': 'Einstellungen', 'system': 'System', 'tasks': 'Aufgaben', 'users': 'Benutzer',
'account': 'Mein Konto'
@@ -31,6 +31,7 @@
<ul class="nav-list" aria-labelledby="nav-content">
{% set entries = [
{id: 'pages', route: 'admin_pages', icon: 'pages', label: 'Seiten'},
{id: 'posts', route: 'admin_posts', icon: 'pages', label: 'Beiträge'},
{id: 'partials', route: 'admin_partials', icon: 'partials', label: 'Bereiche'},
{id: 'menus', route: 'admin_menus', icon: 'menus', label: 'Menüs'},
{id: 'media', route: 'admin_media', icon: 'media', label: 'Mediathek'},
@@ -49,13 +50,12 @@
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_tasks') }}"{{ nav|default('') == 'tasks' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'tasks'} only %}Aufgaben</a></li>{% endif %}
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_users') }}"{{ nav|default('') == 'users' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'users'} only %}Benutzer</a></li>{% endif %}
<li><a class="nav-item nav-out" href="/">{% include 'admin/icon.html.twig' with {name: 'external'} only %}Website ansehen</a></li>
<li><a class="nav-item nav-out" href="/beitraege">{% include 'admin/icon.html.twig' with {name: 'external'} only %}Beitragsübersicht</a></li>
</ul>
</nav>
</details>
<div class="shell-foot">
<p class="shell-version">Fassung {{ kant_version() }}</p>
<a class="shell-user" href="{{ path('admin_account') }}"{{ nav|default('') == 'account' ? ' aria-current="page"' : '' }}><span class="shell-avatar" aria-hidden="true">{{ app.user.userIdentifier|first|upper }}</span><strong>{{ app.user.userIdentifier }}</strong></a>
<a class="shell-user" href="{{ path('admin_account') }}" aria-label="Mein Konto: {{ app.user.userIdentifier }}"{{ nav|default('') == 'account' ? ' aria-current="page"' : '' }}><span class="shell-avatar" aria-hidden="true">{{ app.user.userIdentifier|first|upper }}</span><strong>{{ app.user.userIdentifier }}</strong></a>
<form action="{{ path('admin_logout') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}">
<button class="subtle" type="submit">Abmelden</button>
+12
View File
@@ -22,6 +22,18 @@
<span class="muted">– geändert. Sie wird in der <code>robots.txt</code> nicht genannt; notiere sie dir an einem sicheren Ort. Zurück mit <code>php bin/console cms:admin:path admin</code>.</span>
{% endif %}
</dd>
<dt>Datenbank</dt>
<dd>
<strong>{{ database.art }}</strong>
{% if database.datei %}
<code>{{ database.datei }}</code>
{% if database.bytes %}<span class="muted">· {{ (database.bytes / 1048576)|number_format(1, ',', '.') }} MB</span>{% endif %}
{% if not database.schreibbar %}<br><span class="check-bad"><strong>Nicht beschreibbar.</strong> Nach einem Upload per FTP gehört die Datei dem FTP-Benutzer; PHP läuft als ein anderer. SQLite braucht Schreibrechte auf die Datei <em>und</em> auf den Ordner darüber.</span>{% endif %}
{% else %}
<code>{{ database.adresse }}</code>
{% endif %}
<br><span class="muted">Eingestellt in <code>{{ database.quelle }}</code>. Die Installationskonfiguration steht in <code>.env</code>. Explizite Server-Umgebungsvariablen haben Vorrang.</span>
</dd>
{% if state.worktree %}
<dt>Stand</dt><dd><code>{{ state.describe }}</code>{% if state.commit != 'unbekannt' %} · Commit <code>{{ state.commit }}</code>{% endif %}</dd>
{% else %}
+2 -1
View File
@@ -47,7 +47,8 @@
{% if hasPosts|default(false) %} <link rel="alternate" type="application/rss+xml" title="Beiträge" href="/feed.xml">
{% endif %}
{% if needsTabs|default(false) %}<script src="/assets/tabs.js?v={{ kant_version() }}" defer></script>{% endif %}
{% if needsSlider|default(false) %}<script src="/assets/slider.js?v={{ kant_version() }}" defer></script>{% endif %}
{% if needsSlider|default(false) %}<script type="module" src="/assets/slider.js?v={{ kant_version() }}"></script>{% endif %}
{% if needsMotion|default(false) %}<script type="module" src="/assets/motion.js?v={{ kant_version() }}"></script>{% endif %}
{% if needsZoom|default(false) %}<script src="/assets/lightbox.js?v={{ kant_version() }}" defer></script>{% endif %}
{# Own scripts of this page, as a file. Never rendered in the editor preview: the preview must
show the layout, not run code that could change the page under the editor's hands. #}