Translate pages, areas, menus, forms and posts field by field instead of rebuilding them

A site can have further languages (Settings → Languages). A page is built once; each language
adds only its texts, keyed by the stable block IDs, plus its own address under /en/… (ADR 111,
document schema v27, migration 20261009000100 for page_translation).

- Editor: DE | EN switch, a list of all texts with original, state (missing, translated,
  original changed) and filter; structure stays with the main language. Undo, drafts,
  publishing and revisions cover all languages at once.
- Website: /en and translated slugs, lang, canonical, hreflang with x-default, og:locale,
  sitemap; links to translated pages follow the language; a language switch block.
- Header, footer and linked sections translate the same way; menu entries carry labels per
  language; fixed template texts come from a small dictionary.
- Forms: labels, help, options, button and success message per language; messages and the
  step script speak the language, the submission and the email stay in the main language.
- Posts, archives, category names, consent text and the 404 page follow the language.
- Single-language sites see none of it and never touch the new table.

The forms administration becomes an overview with one row per form and its own edit page;
fields are one line each with details collapsed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5.5 committed 2026-10-09 16:56:52 +02:00
1 parent c7705b7083
commit e15b2517b1
51 files changed
+3045 -219

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', 'button-design', 'color-control', 'rich-text', 'canvas-overlay', 'library-previews', 'preview-containers', 'page-picker', 'motion', 'slider', 'live-search', 'icons'] %}
{%- for modul in ['editor-model', 'media', 'design-panel', 'button-design', 'color-control', 'rich-text', 'canvas-overlay', 'library-previews', 'preview-containers', 'page-picker', 'motion', 'slider', 'live-search', 'icons', 'translate'] %}
"/assets/{{ modul }}.js": "/assets/{{ modul }}.js?v={{ kant_version() }}"{{ loop.last ? '' : ',' }}
{%- endfor %}
}}</script>
+4 -1
View File
@@ -22,6 +22,8 @@
<button type="button" data-viewport="tablet" aria-pressed="false" title="Tablet"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="2" width="16" height="20" rx="2"/><path d="M11 18h2"/></svg><span>Tablet</span></button>
<button type="button" data-viewport="mobile" aria-pressed="false" title="Mobil"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="2" width="10" height="20" rx="2"/><path d="M11 18h2"/></svg><span>Mobil</span></button>
</div>
{# Der Sprachumschalter (ADR 111). Er erscheint nur, wenn die Website weitere Sprachen hat. #}
<div id="language-switch" class="language-switch" role="group" aria-label="Sprache der Bearbeitung" hidden></div>
<div class="history-actions">
<button id="undo" class="icon-button" title="Rückgängig (Strg+Z)" aria-label="Rückgängig" disabled><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 0 12h-3"/></svg></button>
<button id="redo" class="icon-button" title="Wiederholen (Strg+Umschalt+Z)" aria-label="Wiederholen" disabled><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m15 14 5-5-5-5"/><path d="M20 9H10a6 6 0 0 0 0 12h3"/></svg></button>
@@ -71,6 +73,7 @@
<div class="tree-tools"><button id="expand-all" class="text-button" type="button">Alle öffnen</button><button id="collapse-all" class="text-button" type="button">Einklappen</button><button id="nest" class="text-button" type="button" title="In vorherigen Container verschachteln">⇥ Einrücken</button><button id="outdent" class="text-button" type="button" title="Eine Ebene herausheben">⇤ Ausrücken</button></div>
<nav id="structure" aria-label="Blöcke auswählen"></nav>
</section>
<section class="sidebar-view" data-view="translate" id="translate-panel" aria-label="Übersetzung" hidden></section>
<section class="sidebar-view" data-view="element" hidden>
<div id="properties"><p class="muted">Inhalte werden geladen …</p></div>
</section>
@@ -90,7 +93,7 @@
</main>
<div id="context-menu" class="context-menu" role="menu" aria-label="Elementmenü" hidden></div>
<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>
<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><p id="publish-languages" hidden>Alle Sprachen dieser Seite gehen gemeinsam online – auch die Übersetzungen im gespeicherten Stand.</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>
<dialog id="preset-dialog" aria-labelledby="preset-title"><h2 id="preset-title">Gestaltung als Vorlage speichern</h2><p>Die Vorlage steht danach für jeden Block auf jeder Seite zur Verfügung.</p><label for="preset-name">Name der Vorlage</label><input id="preset-name" maxlength="60" placeholder="Heller Abschnitt"><p id="preset-error" class="field-error" hidden></p><div class="dialog-actions"><button id="cancel-preset" class="secondary">Abbrechen</button><button id="confirm-preset" class="primary">Vorlage speichern</button></div></dialog>
<dialog id="reload-dialog" aria-labelledby="reload-title"><h2 id="reload-title">Serverstand laden?</h2><p>Lokale Änderungen und dein Undo-Verlauf werden ersetzt. Sichere deine Änderungen vorher als JSON.</p><div class="dialog-actions"><button id="cancel-reload" class="secondary">Abbrechen</button><button id="confirm-reload" class="primary">Serverstand übernehmen</button></div></dialog>
</div>
+81 -37
View File
@@ -4,35 +4,41 @@
Seite dessen Stylesheet, genau wie die Designseite. #}
{% block styles %}<link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/color-control.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}">{% endblock %}
{% block scripts %}<script type="module" src="/assets/form-editor.js?v={{ kant_version() }}"></script>{% endblock %}
{% block title %}Formulare{% endblock %}
{% block heading %}Formulare{% endblock %}
{% block lead %}Felder, Empfänger, Einwilligungstext und Aussehen werden hier gepflegt. Auf einer Seite steht nur ein Formularblock mit der Adresse – so kann ein gespeicherter Seiteninhalt niemals ändern, wohin eine Anfrage geht.{% endblock %}
{% block actions %}<a class="primary" href="#neues-formular">Neues Formular</a>{% endblock %}
{% set editing = editing|default(false) %}
{% block title %}{{ editing ? (current ? current.name : 'Neues Formular') ~ ' · ' : '' }}Formulare{% endblock %}
{% block heading %}{{ editing ? (current ? current.name : 'Neues Formular') : 'Formulare' }}{% endblock %}
{% block lead %}{% if editing %}<a class="text-button back-to-list" href="{{ path('admin_forms') }}">← Alle Formulare</a>{% else %}Felder, Empfänger und Aussehen deiner Formulare. Auf einer Seite setzt du sie mit dem Baustein „Formular“ ein.{% endif %}{% endblock %}
{% block actions %}{% if not editing %}<a class="primary" href="{{ path('admin_form_edit', {slug: 'neu'}) }}">Neues Formular</a>{% endif %}{% endblock %}
{# Eine Feldkarte. Der Index steht in den Feldnamen; die Vorlage fuer neue Felder traegt an seiner
Stelle den Platzhalter __n__, den das Skript beim Einfuegen ersetzt. #}
{% macro field(f, n, row, types) %}
{% macro field(f, n, row, types, languages = [], translations = {}) %}
{% set id = 'f' ~ f ~ '-' ~ n %}
<li class="field-card{{ row.label == '' ? ' field-card-new' : '' }}" data-field>
{# Eine Zeile je Feld: Beschriftung, Typ, Pflicht. Alles Weitere steht in den Details, die das
Skript zuklappt - ohne Skript ist die Karte offen und vollständig bedienbar. #}
<div class="field-card-head">
<span class="field-card-index" aria-hidden="true" data-field-index></span>
<div class="field-card-title">
<label for="{{ id }}-label">Beschriftung</label>
<input id="{{ id }}-label" name="fields[{{ n }}][label]" maxlength="120" value="{{ row.label }}" placeholder="{{ row.label == '' ? 'Neues Feld – Beschriftung eingeben' : '' }}" data-field-label>
<label class="visually-hidden" for="{{ id }}-label">Beschriftung</label>
<input id="{{ id }}-label" name="fields[{{ n }}][label]" maxlength="120" value="{{ row.label }}" placeholder="{{ row.label == '' ? 'Neues Feld – Beschriftung eingeben' : 'Beschriftung' }}" data-field-label>
</div>
<div class="field-card-type">
<label for="{{ id }}-type">Typ</label>
<label class="visually-hidden" for="{{ id }}-type">Typ</label>
<select id="{{ id }}-type" name="fields[{{ n }}][type]" data-field-type>
{% for value, label in types %}<option value="{{ value }}"{{ row.type == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}
</select>
</div>
<label class="check-label field-card-required" for="{{ id }}-required"><input id="{{ id }}-required" type="checkbox" name="fields[{{ n }}][required]" value="1"{{ row.required ? ' checked' : '' }} data-field-required> Pflicht</label>
<button type="button" class="field-toggle" data-field-toggle hidden aria-expanded="true" aria-controls="{{ id }}-body">Details<svg class="form-chevron" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg></button>
<button type="button" class="field-remove" data-remove-field hidden aria-label="Feld entfernen" title="Feld entfernen">{% include 'admin/icon.html.twig' with {name: 'remove'} only %}</button>
</div>
<div class="field-card-body">
<p class="field-card-meta" data-field-meta hidden></p>
<div class="field-card-body" id="{{ id }}-body" data-field-body>
<div>
<label for="{{ id }}-name">Technischer Name</label>
<input id="{{ id }}-name" name="fields[{{ n }}][name]" maxlength="32" value="{{ row.name }}" pattern="[a-z][a-z0-9_]*" placeholder="vorname" data-field-name>
<p class="field-help">Kleinbuchstaben, Ziffern, Unterstrich. Steht in der E-Mail vor der Antwort.</p>
<p class="field-help">Steht in der E-Mail vor der Antwort.</p>
</div>
<div>
<label for="{{ id }}-help">Hilfetext</label>
@@ -46,12 +52,9 @@
<label for="{{ id }}-images">Bild je Option</label>
<textarea id="{{ id }}-images" name="fields[{{ n }}][images]" rows="2" data-field-images-input>{% for option, kennung in row.images|default({}) %}{{ option }} = {{ kennung }}
{% endfor %}</textarea>
<p class="field-help">Eine Zeile je Option: <code>Option = Kennung aus der Mediathek</code>. Sobald eine Option ein Bild hat, wird die Auswahl auf der Website zur Bildauswahl – anklickbare Karten statt Kästchen.</p>
<p class="field-help">Mit Bild wird die Auswahl zu anklickbaren Karten. Ohne Skript: je Zeile <code>Option = Kennung</code>.</p>
</div>
</div>
<div class="field-card-flags">
<label class="check-label" for="{{ id }}-required"><input id="{{ id }}-required" type="checkbox" name="fields[{{ n }}][required]" value="1"{{ row.required ? ' checked' : '' }} data-field-required> Pflichtfeld</label>
</div>
<div class="field-card-number" data-field-number>
<div>
<label for="{{ id }}-min">Untergrenze</label>
@@ -75,25 +78,41 @@
<label for="{{ id }}-showvalues">… einen dieser Werte hat</label>
<input id="{{ id }}-showvalues" name="fields[{{ n }}][showIfValues]" maxlength="500" value="{{ row.showIf.values|default([])|join(', ') }}" placeholder="Wohnung, Haus" data-field-showvalues>
</div>
<p class="field-help">Technischer Name eines Auswahlfeldes weiter oben, Werte durch Komma. Bei einem Schritt gilt die Bedingung für alle seine Felder. Leer: das Feld gilt immer.</p>
<p class="field-help">Technischer Name eines Auswahlfeldes weiter oben, Werte durch Komma. Leer: das Feld gilt immer.</p>
</div>
{# Je weitere Sprache, was Besucher lesen (ADR 111). Leer heißt: wie in der Hauptsprache. #}
{% for sprache in languages %}
{% set tr = translations[sprache.code].fields[row.name]|default({}) %}
<details class="field-translation" data-field-translation="{{ sprache.code }}">
<summary>{{ sprache.name }}{% if tr.label|default('') %} <span class="muted">· {{ tr.label }}</span>{% endif %}</summary>
<div class="fields">
<div><label for="{{ id }}-tr-{{ sprache.code }}-label">Beschriftung {{ sprache.name }}</label><input id="{{ id }}-tr-{{ sprache.code }}-label" name="fields[{{ n }}][tr_{{ sprache.code }}_label]" maxlength="120" lang="{{ sprache.code }}" value="{{ tr.label|default('') }}" placeholder="{{ row.label }}"></div>
<div><label for="{{ id }}-tr-{{ sprache.code }}-help">Hilfetext {{ sprache.name }}</label><input id="{{ id }}-tr-{{ sprache.code }}-help" name="fields[{{ n }}][tr_{{ sprache.code }}_help]" maxlength="200" lang="{{ sprache.code }}" value="{{ tr.help|default('') }}" placeholder="{{ row.help|default('') }}"></div>
<div><label for="{{ id }}-tr-{{ sprache.code }}-options">Optionen {{ sprache.name }}</label><input id="{{ id }}-tr-{{ sprache.code }}-options" name="fields[{{ n }}][tr_{{ sprache.code }}_options]" maxlength="500" lang="{{ sprache.code }}" value="{% for option in row.options|default([]) %}{{ tr.options[option]|default('') }}{{ loop.last ? '' : ', ' }}{% endfor %}" placeholder="{{ row.options|default([])|join(', ') }}"><p class="field-help">Nur bei Auswahlfeldern: in derselben Reihenfolge, durch Komma.</p></div>
<div><label for="{{ id }}-tr-{{ sprache.code }}-unit">Einheit {{ sprache.name }}</label><input id="{{ id }}-tr-{{ sprache.code }}-unit" name="fields[{{ n }}][tr_{{ sprache.code }}_unit]" maxlength="12" lang="{{ sprache.code }}" value="{{ tr.unit|default('') }}" placeholder="{{ row.unit|default('') }}"></div>
</div>
</details>
{% endfor %}
</div>
</li>
{% endmacro %}
{% block content %}
{% import _self as karte %}
{% if editing %}
<div class="form-screens" data-form-schema="{{ schema|json_encode|e('html_attr') }}" data-form-images="{{ images|json_encode|e('html_attr') }}">
{% for form in forms|merge([null]) %}
{% for form in [current] %}
{% set new = form is null %}
{% set f = loop.index %}
{% set rows = new ? [] : form.fields %}
{% set settings = new ? {} : form.settings %}
{% if new %}
<details class="card form-new" id="neues-formular"{{ forms is empty ? ' open' : '' }}>
<summary>Neues Formular anlegen</summary>
<section class="card form-new" id="neues-formular">
{% else %}
<section class="card">
<section class="card form-card" id="formular-{{ form.slug }}">
<div class="form-card-summary">
<span class="form-card-facts">{{ form.fields|length }} {{ form.fields|length == 1 ? 'Feld' : 'Felder' }} · an {{ settings.recipient|default('–') }} · Adresse <code>{{ form.slug }}</code>{% for sprache in languages %}{% if settings.translations[sprache.code]|default(null) %} · {{ sprache.code|upper }}{% endif %}{% endfor %}</span>
</div>
{% endif %}
<form action="{{ path('admin_form_save', {slug: new ? 'neu' : form.slug}) }}" method="post" class="form-def" data-form-editor>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('forms') }}">
@@ -102,15 +121,11 @@
{# Die Zusammenfassung des Details ist keine Ueberschrift; ohne diese springt die
Gliederung von der Seitenueberschrift direkt zu den Abschnittstiteln. #}
<h2 class="visually-hidden">Neues Formular</h2>
{% else %}
<div class="card-head">
<h2>{{ form.name }} <span class="count">{{ form.fields|length }} {{ form.fields|length == 1 ? 'Feld' : 'Felder' }}</span></h2>
<span class="muted">Im Editor über den Formularblock wählbar · Adresse <code>{{ form.slug }}</code></span>
</div>
{% endif %}
<div class="card-body form-editor">
<div class="form-editor-main">
<h3 class="form-part">Zustellung</h3>
<details class="form-section"{{ new ? ' open' : '' }}>
<summary><h3 class="form-part">Zustellung</h3><span class="form-section-hint">{{ new ? '' : 'an ' ~ settings.recipient|default('') ~ ' · Betreff „' ~ settings.subject|default('') ~ '“' }}</span><svg class="form-chevron" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg></summary>
<div class="fields">
<div>
<label for="name-{{ f }}">Name</label>
@@ -130,7 +145,7 @@
<div>
<label for="sender-{{ f }}">Absender</label>
<input id="sender-{{ f }}" name="sender" type="email" value="{{ settings.sender|default('') }}" placeholder="formular@beispiel.de">
<p class="field-help">Leer: die Empfängeradresse. Viele Anbieter stellen nur zu, was von einer Adresse der eigenen Domain kommt.</p>
<p class="field-help">Leer: die Empfängeradresse. Am besten eine Adresse der eigenen Domain.</p>
</div>
<div>
<label for="subject-{{ f }}">Betreff der E-Mail</label>
@@ -147,22 +162,24 @@
</div>
<div class="field-wide">
<label class="check-label" for="summary-{{ f }}"><input id="summary-{{ f }}" type="checkbox" name="summary" value="1"{{ settings.summary|default(false) ? ' checked' : '' }}> Zusammenfassung vor dem Absenden zeigen</label>
<p class="field-help">Wirkt bei Formularen mit Schritten: der letzte Schritt zeigt alle Angaben mit „Ändern“. Ohne Skript im Browser steht das Formular als Ganzes auf der Seite.</p>
<p class="field-help">Nur bei Formularen mit Schritten.</p>
</div>
</div>
</details>
<div class="form-fields-head">
<h3 class="form-part">Felder</h3>
<button type="button" class="secondary" data-add-field hidden>{% include 'admin/icon.html.twig' with {name: 'plus'} only %}Feld hinzufügen</button>
</div>
<ol class="field-list" data-field-list>
{% for row in rows %}{{ karte.field(f, loop.index0, row, types) }}{% endfor %}
{{ karte.field(f, rows|length, {name: '', type: 'text', label: '', required: false, help: '', options: []}, types) }}
{% for row in rows %}{{ karte.field(f, loop.index0, row, types, languages, settings.translations|default({})) }}{% endfor %}
{{ karte.field(f, rows|length, {name: '', type: 'text', label: '', required: false, help: '', options: []}, types, languages) }}
</ol>
<template data-field-template>{{ karte.field(f, '__n__', {name: '', type: 'text', label: '', required: false, help: '', options: []}, types) }}</template>
<p class="field-help" data-fields-help>Ein Feld ohne Beschriftung wird nicht gespeichert – so lässt sich ein Feld auch ohne Skript entfernen. Ein Einwilligungsfeld ist immer ein Pflichtfeld; der Zeitpunkt der Einwilligung wird mitgespeichert. Ein Feld vom Typ „Neuer Schritt“ eröffnet einen Abschnitt: seine Beschriftung ist der Titel, sein Hilfetext die Einleitung; alle Felder bis zum nächsten Schritt gehören dazu. Ein Feld oder ein Schritt kann von einer früheren Auswahl abhängen – dann verlangt der Server es nur, wenn die Bedingung zutrifft.</p>
<template data-field-template>{{ karte.field(f, '__n__', {name: '', type: 'text', label: '', required: false, help: '', options: []}, types, languages) }}</template>
<p class="field-help" data-fields-help>Hilfetext, Optionen, Bedingungen und Übersetzungen stehen unter „Details“. Ein „Neuer Schritt“ beginnt einen Abschnitt; ein Feld ohne Beschriftung wird nicht gespeichert.</p>
<h3 class="form-part">Aussehen</h3>
<details class="form-section">
<summary><h3 class="form-part">Aussehen</h3><span class="form-section-hint">Button „{{ settings.buttonLabel|default('Absenden') }}“</span><svg class="form-chevron" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg></summary>
<div class="fields form-look">
<div>
<label for="buttonLabel-{{ f }}">Beschriftung des Buttons</label>
@@ -175,7 +192,19 @@
</div>
{% endfor %}
</div>
<p class="field-help">Leer heißt: wie die Website. Sonst eine Farbe aus der Palette oder ein Wert wie <code>#1a2b3c</code>. Form und Größe des Buttons kommen aus dem Design.</p>
<p class="field-help">Leer heißt: wie die Website. Form und Größe des Buttons kommen aus dem Design.</p>
</details>
{% for sprache in languages %}
{% set tr = settings.translations[sprache.code]|default({}) %}
<details class="form-section">
<summary><h3 class="form-part">{{ sprache.name }}</h3><span class="form-section-hint">{{ tr ? (tr.fields|default({})|length ~ ' von ' ~ rows|length ~ ' Feldern übersetzt') : 'noch nicht übersetzt' }}</span><svg class="form-chevron" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg></summary>
<p class="field-help">Button und Meldung auf {{ sprache.name }}; die Felder übersetzt du unter „Details“ am Feld. Die E-Mail an dich bleibt deutsch.</p>
<div class="fields">
<div class="field-wide"><label for="success-{{ f }}-{{ sprache.code }}">Meldung nach dem Absenden ({{ sprache.name }})</label><textarea id="success-{{ f }}-{{ sprache.code }}" name="success_{{ sprache.code }}" maxlength="300" rows="2" lang="{{ sprache.code }}" placeholder="{{ settings.success|default('') }}">{{ tr.success|default('') }}</textarea></div>
<div><label for="buttonLabel-{{ f }}-{{ sprache.code }}">Beschriftung des Buttons ({{ sprache.name }})</label><input id="buttonLabel-{{ f }}-{{ sprache.code }}" name="buttonLabel_{{ sprache.code }}" maxlength="40" lang="{{ sprache.code }}" value="{{ tr.buttonLabel|default('') }}" placeholder="{{ settings.buttonLabel|default('Absenden') }}"></div>
</div>
</details>
{% endfor %}
</div>
{# Die Vorschau: dieselben Felder, dieselben Farben, der Standardbutton der Website.
@@ -212,13 +241,27 @@
{% if not new %}<span class="muted">Änderungen gelten sofort auf jeder Seite, die dieses Formular zeigt.</span>{% endif %}
</div>
</form>
{% if new %}
</details>
{% else %}
</section>
{% endif %}
{% endfor %}
</div>
{% else %}
{# Die Übersicht: eine Zeile je Formular, ohne Editor und ohne Vorschau. #}
{% if forms is empty %}
<div class="empty"><h2>Noch kein Formular.</h2><p>Lege ein Formular an und setze es mit dem Baustein „Formular“ auf eine Seite.</p><p><a class="primary" href="{{ path('admin_form_edit', {slug: 'neu'}) }}">Neues Formular</a></p></div>
{% else %}
<div class="rows form-list">
{% for form in forms %}
<article class="row">
<div class="row-main">
<h2 class="form-list-name"><a href="{{ path('admin_form_edit', {slug: form.slug}) }}">{{ form.name }}</a></h2>
<p>{{ form.fields|length }} {{ form.fields|length == 1 ? 'Feld' : 'Felder' }} · an {{ form.settings.recipient|default('–') }} · Adresse <code>{{ form.slug }}</code>{% for sprache in languages %}{% if form.settings.translations[sprache.code]|default(null) %} · {{ sprache.code|upper }} übersetzt{% endif %}{% endfor %}</p>
</div>
<span class="pill">{{ counts[form.id]|default(0) }} {{ counts[form.id]|default(0) == 1 ? 'Einsendung' : 'Einsendungen' }}</span>
<a class="secondary" href="{{ path('admin_form_edit', {slug: form.slug}) }}" aria-label="{{ form.name }} bearbeiten">Bearbeiten</a>
</article>
{% endfor %}
</div>
{% endif %}
<div class="section-head"><h2>Einsendungen <span class="count">{{ submissions.total }}</span></h2><span class="muted">Neueste zuerst.</span></div>
{% if submissions.items is empty %}
@@ -238,5 +281,6 @@
</div>
{% endif %}
{% include 'admin/pager.html.twig' with {result: submissions, route: 'admin_forms', label: 'Seiten der Einsendungen'} only %}
{% endif %}
<p class="footnote">KantSites · Arbeitsstand</p>
{% endblock %}
+3
View File
@@ -9,6 +9,9 @@
<summary><strong class="menu-entry-title">{{ row.label }}</strong><span class="menu-entry-kind">{{ row.type == 'page' ? 'Seitenlink' : 'Eigener Link' }}</span><svg class="menu-chevron" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg></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 }}">
{% for sprache in languages|default([]) %}
<label for="menu-label_{{ sprache.code }}-{{ n }}">Beschriftung {{ sprache.name }}</label><input id="menu-label_{{ sprache.code }}-{{ n }}" data-key="label_{{ sprache.code }}" name="items[{{ n }}][label_{{ sprache.code }}]" maxlength="80" lang="{{ sprache.code }}"{{ sprache.rtl ? ' dir="rtl"' : '' }} value="{{ row.labels[sprache.code]|default('') }}" placeholder="Leer: wie „{{ row.label }}“">
{% endfor %}
{% 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">
+1 -1
View File
@@ -8,7 +8,7 @@
{% block content %}
{% include 'admin/menu-icons.html.twig' %}
<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" data-icons="{{ menuIcons|json_encode|e('html_attr') }}" data-button-schema="{{ buttonSchema|json_encode|e('html_attr') }}">
<form action="{{ path('admin_menu_save', {slot: slot}) }}" method="post" id="menu-editor" class="menu-workspace menu-form" data-icons="{{ menuIcons|json_encode|e('html_attr') }}" data-button-schema="{{ buttonSchema|json_encode|e('html_attr') }}" data-languages="{{ languages|json_encode|e('html_attr') }}">
<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>
+47
View File
@@ -52,6 +52,53 @@
{% else %}<p class="field-help">Die Zuordnung kann von einem Benutzer mit Veröffentlichungsrechten geändert werden.</p>{% endif %}
</div>
</section>
<section class="stack-section" id="sprachen" aria-labelledby="sprachen-title">
<div class="section-head"><h2 id="sprachen-title">Sprachen</h2><span class="muted">Hauptsprache: {{ mainLanguage.name }}</span></div>
<div class="card card-body">
<p>Jede weitere Sprache übersetzt dieselben Seiten: Aufbau und Gestaltung bleiben, je Sprache kommen nur die Texte und eine eigene Adresse dazu – zum Beispiel <code>/en/services</code>. Übersetzt wird im Editor über den Sprachumschalter. Eine Seite erscheint in einer Sprache erst, wenn ihre Übersetzung veröffentlicht ist.</p>
{% if is_granted('ROLE_ADMIN') %}
<form action="{{ path('admin_languages_save') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('settings') }}">
<fieldset class="check-grid">
<legend>Weitere Sprachen</legend>
{% for sprache in languageCatalog %}
<label class="check-label"><input type="checkbox" name="languages[]" value="{{ sprache.code }}"{{ sprache.code in extraLanguages ? ' checked' : '' }}> {{ sprache.name }} <span class="muted" lang="{{ sprache.code }}">{{ sprache.native }}</span></label>
{% endfor %}
</fieldset>
<p class="field-help">Ohne Häkchen bleibt die Website einsprachig, und nirgends erscheint etwas davon. Wird eine Sprache wieder abgewählt, verschwinden ihre Seiten von der Website; die Übersetzungen bleiben gespeichert. Damit Besucher die Sprache wechseln können, setze den Baustein „Sprachwahl“ in den Kopfbereich.</p>
<button class="primary" type="submit">Sprachen speichern</button>
</form>
{% if extraLanguages %}
<form action="{{ path('admin_language_texts_save') }}" method="post" class="language-texts">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('settings') }}">
<h3>Texte in den weiteren Sprachen</h3>
<p class="field-help">Seiten, Kopf und Fuß übersetzt du im Editor, Menüs unter Menüs, Formulare unter Formulare. Hier stehen die Texte, die zur Website als Ganzes gehören. Leer heißt: wie in {{ mainLanguage.name }}.</p>
{% for code in extraLanguages %}
{% set name = languageCatalog|filter(s => s.code == code)|first.name %}
<fieldset class="language-text-group">
<legend>{{ name }}</legend>
{% if settings.consentBanner %}
<label for="consent-title-{{ code }}">Überschrift des Einwilligungshinweises</label>
<input id="consent-title-{{ code }}" name="consentTexts[{{ code }}][title]" maxlength="80" lang="{{ code }}" value="{{ settings.consentTexts[code].title|default('') }}" placeholder="{{ settings.consentTitle ?: 'Externe Inhalte und Einwilligung' }}">
<label for="consent-text-{{ code }}">Text des Einwilligungshinweises</label>
<textarea id="consent-text-{{ code }}" name="consentTexts[{{ code }}][text]" maxlength="600" rows="3" lang="{{ code }}" placeholder="{{ settings.consentText }}">{{ settings.consentTexts[code].text|default('') }}</textarea>
{% endif %}
{% for term in terms %}
{% set key = term.kind ~ ':' ~ term.slug %}
<label for="term-{{ code }}-{{ term.id }}">{{ term.kind == 'category' ? 'Kategorie' : 'Schlagwort' }} „{{ term.name }}“</label>
<input id="term-{{ code }}-{{ term.id }}" name="termNames[{{ code }}][{{ key }}]" maxlength="120" lang="{{ code }}" value="{{ settings.termNames[code][key]|default('') }}" placeholder="{{ term.name }}">
{% endfor %}
{% if not settings.consentBanner and terms is empty %}<p class="field-help">Ohne Einwilligungshinweis und ohne Kategorien gibt es hier nichts zu übersetzen.</p>{% endif %}
</fieldset>
{% endfor %}
<button class="primary" type="submit">Übersetzungen speichern</button>
</form>
{% endif %}
{% else %}
<p class="field-help">{{ extraLanguages ? 'Weitere Sprachen: ' ~ extraLanguages|map(c => c|upper)|join(', ') ~ '.' : 'Die Website ist einsprachig.' }} Ändern kann das ein Administrator.</p>
{% endif %}
</div>
</section>
<section class="stack-section">
<div class="section-head"><h2>Unternehmensdaten</h2><span class="muted">Grundlage für Organization und LocalBusiness</span></div>
{% if is_granted('ROLE_ADMIN') %}
+25 -13
View File
@@ -58,7 +58,7 @@
{%- if klick == 'vergroessern' -%}
{# Ohne Skript öffnet dieser Verweis schlicht das große Bild. Mit Skript fängt der
Betrachter den Klick ab - die Seite bleibt also in jedem Fall benutzbar. #}
<a class="image-link image-zoom" href="{{ grossesBild }}" data-zoom data-caption="{{ props.caption|default('') }}" aria-label="{{ props.alt|default('Bild') }} vergrößern">
<a class="image-link image-zoom" href="{{ grossesBild }}" data-zoom data-caption="{{ props.caption|default('') }}" aria-label="{{ props.alt|default('Bild'|kant_t) }} {{ 'vergrößern'|kant_t }}">
{%- elseif imageLinked -%}
<a class="image-link" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>
{%- endif -%}<img src="{{ props.src }}" alt="{{ props.alt }}" width="{{ props.width }}" height="{{ props.height }}" loading="{{ eager or inHeader|default(false) ? 'eager' : 'lazy' }}"{{ eager ? ' fetchpriority="high"' : '' }} decoding="async"{% if props.sources|default([]) %} srcset="{% for source in props.sources %}{{ source.src }} {{ source.width }}w{% if not loop.last %}, {% endif %}{% endfor %}" sizes="{{ imageSizes }}"{% endif %}>{% if klick == 'vergroessern' or imageLinked %}</a>{% endif %}{% if props.caption|default('') %}<figcaption>{{ props.caption }}</figcaption>{% endif %}</figure>
@@ -112,7 +112,7 @@
{% set menuLayout = props.layout|default('horizontal') %}
{% set collapse = menuLayout == 'horizontal' ? props.collapse|default('tablet') : 'never' %}
<nav id="{{ block.id }}" class="block-menu menu-{{ menuLayout }} menu-collapse-{{ collapse }}{{ designClasses }}"
aria-label="{{ {header: 'Hauptnavigation', secondary: 'Zweite Navigation', footer: 'Fußzeile', legal: 'Rechtliches'}[props.slot]|default('Navigation') }}">
aria-label="{{ {header: 'Hauptnavigation', secondary: 'Zweite Navigation', footer: 'Fußzeile', legal: 'Rechtliches'}[props.slot]|default('Navigation')|kant_t }}">
{% if menuLayout == 'burger' or collapse != 'never' %}
{# Buttons, die auf dem Telefon neben dem Menü-Knopf stehen bleiben (ADR 110). Sichtbar nur,
solange das Menü zusammengeklappt ist; im aufgeklappten Menü fehlen sie dann. #}
@@ -121,7 +121,7 @@
<details class="menu-burger">
{# Nur Symbol behält den Namen für Vorlesesoftware; sichtbar ist dann nur der Strich. #}
{% set burger = props.burgerLabel|default('both') %}
<summary>{% if burger != 'text' %}<span class="burger-lines" aria-hidden="true"></span>{% endif %}<span class="burger-text{{ burger == 'icon' ? ' visually-hidden' : '' }}">Menü</span></summary>
<summary>{% if burger != 'text' %}<span class="burger-lines" aria-hidden="true"></span>{% endif %}<span class="burger-text{{ burger == 'icon' ? ' visually-hidden' : '' }}">{{ 'Menü'|kant_t }}</span></summary>
<div class="menu-burger-body">{% include 'nav.html.twig' with {links: links} only %}</div>
</details>
{% endif %}
@@ -147,9 +147,9 @@
{% endif %}
{% elseif type == 'breadcrumb' %}
{% if breadcrumbs|default([])|length > 1 %}
<nav id="{{ block.id }}" class="block-breadcrumb{{ designClasses }}" aria-label="Brotkrumen">
<nav id="{{ block.id }}" class="block-breadcrumb{{ designClasses }}" aria-label="{{ 'Brotkrumen'|kant_t }}">
<ol>
<li><a href="/">Start</a></li>
<li><a href="/">{{ 'Start'|kant_t }}</a></li>
{% for crumb in breadcrumbs %}
<li>{% if loop.last %}<span aria-current="page">{{ crumb.name }}</span>{% else %}<a href="{{ crumb.href }}">{{ crumb.name }}</a>{% endif %}</li>
{% endfor %}
@@ -208,7 +208,7 @@
{% if profiles[key]|default('') and (chosen is empty or key in chosen) %}{% set shown = shown|merge([{key: key, name: name, url: profiles[key]}]) %}{% endif %}
{% endfor %}
{% if shown is not empty %}
<ul id="{{ block.id }}" class="block-social social-{{ props.layout|default('horizontal') }} social-shape-{{ props.shape|default('plain') }} social-size-{{ props.size|default(24) }}{{ props.labels|default(false) ? ' social-labels' : '' }}{{ designClasses }}" aria-label="Soziale Profile">
<ul id="{{ block.id }}" class="block-social social-{{ props.layout|default('horizontal') }} social-shape-{{ props.shape|default('plain') }} social-size-{{ props.size|default(24) }}{{ props.labels|default(false) ? ' social-labels' : '' }}{{ designClasses }}" aria-label="{{ 'Soziale Profile'|kant_t }}">
{% for profile in shown %}
<li><a href="{{ profile.url }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab is defined ? props.newTab : true} only %}>{% include 'blocks/social-icon.html.twig' with {network: profile.key} only %}<span{{ props.labels|default(false) ? '' : ' class="visually-hidden"' }}>{{ profile.name }}</span></a></li>
{% endfor %}
@@ -233,7 +233,7 @@
browser that ignores snapping. No autoplay - a moving carousel is something people ask to
stop, not something a page should start on its own. #}
<div id="{{ block.id }}" class="block-slider{{ designClasses }} slide-desktop-{{ props.desktopVisible }} slide-tablet-{{ props.tabletVisible }} slide-mobile-{{ props.mobileVisible }}{{ props.snap ? ' slider-snap' : '' }}{{ props.dots ?? false ? ' slider-dots' : '' }}"{% if props.autoplay|default(0) >= 3 %} data-autoplay="{{ props.autoplay }}"{% endif %}
tabindex="0" role="group" aria-label="Zum Blättern seitwärts scrollen">
tabindex="0" role="group" aria-label="{{ 'Zum Blättern seitwärts scrollen'|kant_t }}">
{% for child in block.children|default([]) %}
<div class="slider-slide visibility-{{ child.layout.visibility|default('all') }}">{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}</div>
{% endfor %}
@@ -255,7 +255,7 @@
<div id="{{ block.id }}" class="block-embed embed-blocked{{ designClasses }}">
<p class="embed-title">{{ props.title }}</p>
<p class="embed-hint">{{ props.hint ?: 'Dieser Inhalt wird von einem anderen Anbieter geladen. Dabei erfährt er deine IP-Adresse und welche Seite du ansiehst.' }}</p>
<p class="embed-provider">Anbieter: <strong>{{ props.provider ?: props.url|split('/')[2] }}</strong></p>
<p class="embed-provider">{{ 'Anbieter:'|kant_t }} <strong>{{ props.provider ?: props.url|split('/')[2] }}</strong></p>
{% if editing|default(false) %}
<p class="embed-hint">Wird erst auf der veröffentlichten Seite geladen, und dort erst nach Einwilligung.</p>
{% else %}
@@ -265,7 +265,7 @@
{# The groups already granted stay granted; this button adds this one. #}
{% for granted in consentGate.state(app.request).groups %}{% if granted.granted %}<input type="hidden" name="groups[]" value="{{ granted.key }}">{% endif %}{% endfor %}
<input type="hidden" name="groups[]" value="{{ props.group }}">
<button class="consent-button" type="submit">Inhalt laden und einwilligen</button>
<button class="consent-button" type="submit">{{ 'Inhalt laden und einwilligen'|kant_t }}</button>
</form>
{% endif %}
</div>
@@ -294,12 +294,24 @@
erbt statt als heller Streifen unter einem dunklen Fuß zu kleben. #}
{% set rechtliches = consentGate.legalLinks() %}
{% set widerruflich = not editing|default(false) and consentGate.askedGroups is not empty and not consentGate.needsBanner(app.request) %}
<nav id="{{ block.id }}" class="block-legal{{ designClasses }}" aria-label="{{ props.label|default('Rechtliches') }}">
{% for path, label in rechtliches %}{% if not loop.first %}<span aria-hidden="true"> · </span>{% endif %}<a href="/{{ path }}">{{ label }}</a>{% endfor %}
{% if widerruflich %}{% if rechtliches is not empty %}<span aria-hidden="true"> · </span>{% endif %}<a href="/einwilligung">Einwilligung ändern</a>{% endif %}
<nav id="{{ block.id }}" class="block-legal{{ designClasses }}" aria-label="{{ props.label|default('Rechtliches'|kant_t) }}">
{% for path, label in rechtliches %}{% if not loop.first %}<span aria-hidden="true"> · </span>{% endif %}<a href="/{{ path }}">{{ label|kant_t }}</a>{% endfor %}
{% if widerruflich %}{% if rechtliches is not empty %}<span aria-hidden="true"> · </span>{% endif %}<a href="/einwilligung">{{ 'Einwilligung ändern'|kant_t }}</a>{% endif %}
</nav>
{% elseif type == 'language' %}
{# Die Sprachwahl (ADR 111): dieselbe Seite in jeder Sprache, in der sie veröffentlicht ist.
Gibt es die Seite nur in einer Sprache, steht hier nichts - eine Wahl mit einer Möglichkeit
ist keine. hreflang hält die Links aus der Umstellung auf übersetzte Adressen heraus. #}
{% set sprachen = app.request.attributes.get('kant_alternates')|default([]) %}
{% if editing|default(false) and sprachen|length < 2 %}{% set sprachen = [{locale: 'de', native: 'Deutsch', href: '#', current: true}, {locale: 'en', native: 'English', href: '#', current: false}] %}{% endif %}
{% if sprachen|length > 1 %}
<nav id="{{ block.id }}" class="block-language{{ designClasses }}" aria-label="{{ 'Sprache'|kant_t }}">
<ul>{% for sprache in sprachen %}<li><a href="{{ sprache.href }}" hreflang="{{ sprache.locale }}" lang="{{ sprache.locale }}"{% if sprache.current %} aria-current="page"{% endif %}>{% if props.display|default('code') == 'name' %}{{ sprache.native }}{% else %}{{ sprache.locale|upper }}<span class="visually-hidden"> {{ sprache.native }}</span>{% endif %}</a></li>{% endfor %}</ul>
</nav>
{% elseif editing|default(false) %}<p id="{{ block.id }}" class="block-unavailable{{ designClasses }}" role="note">Sprachwahl</p>
{% endif %}
{% elseif type == 'gallery' %}
<div id="{{ block.id }}" class="block-gallery{{ designClasses }}" role="group" aria-label="Bildergalerie"{% if props.lightbox|default(false) %} data-zoom-group{% endif %}>
<div id="{{ block.id }}" class="block-gallery{{ designClasses }}" role="group" aria-label="{{ 'Bildergalerie'|kant_t }}"{% if props.lightbox|default(false) %} data-zoom-group{% endif %}>
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, galleryZoom: props.lightbox|default(false), editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
</div>
{% elseif kant_plugin_block(type) %}
+13 -12
View File
@@ -3,13 +3,14 @@
Schritt nach dem anderen. Bedingungen stehen als Datenattribute am Feld; der Server prüft sie
beim Absenden selbst und verlangt nichts, was gar nicht galt. #}
{% if form is null %}
<p class="form-missing">Dieses Formular ist nicht mehr vorhanden.</p>
<p class="form-missing">{{ 'Dieses Formular ist nicht mehr vorhanden.'|kant_t }}</p>
{% elseif sent %}
<p class="form-success" role="status">{{ form.settings.success }}</p>
<p class="form-success" role="status">{{ form.settings.success|kant_t }}</p>
{% else %}
{% set hasSteps = form.fields|filter(f => f.type == 'step')|length > 0 %}
<form class="block-form{{ hasSteps ? ' form-stepped' : '' }}" data-form="{{ form.slug }}" method="post" action="{{ path('form_submit', {slug: form.slug}) }}" novalidate{% if hasSteps %} data-steps{% endif %}{% if form.settings.summary|default(false) %} data-summary{% endif %}>
<form class="block-form{{ hasSteps ? ' form-stepped' : '' }}" data-form="{{ form.slug }}" method="post" action="{{ path('form_submit', {slug: form.slug}) }}" novalidate{% if hasSteps %} data-steps{% endif %}{% if form.settings.summary|default(false) %} data-summary{% endif %}{% if form.locale|default(null) %} data-texts="{{ {summary: 'Zusammenfassung'|kant_t, summaryIntro: 'Bitte prüfen Sie Ihre Angaben. Über „Ändern“ kommen Sie zum jeweiligen Schritt zurück.'|kant_t, steps: 'Schritte des Formulars'|kant_t, step: 'Schritt'|kant_t, back: 'Zurück'|kant_t, next: 'Weiter'|kant_t, change: 'Ändern'|kant_t, yes: 'Ja'|kant_t, chooseOne: 'Bitte wähle mindestens eine Option.'|kant_t}|json_encode|e('html_attr') }}"{% endif %}>
<input type="hidden" name="page" value="{{ page.path }}">
{% if form.locale|default(null) %}<input type="hidden" name="language" value="{{ form.locale }}">{% endif %}
<input type="hidden" name="block" value="{{ blockId }}">
<input type="hidden" name="issued" value="{{ issued }}">
<input type="hidden" name="issuedSignature" value="{{ issuedSignature }}">
@@ -17,7 +18,7 @@
<div class="form-trap" aria-hidden="true"><label for="website-{{ blockId }}">Website</label><input id="website-{{ blockId }}" name="website" type="text" tabindex="-1" autocomplete="off"></div>
{% if errors is not empty %}
<p class="form-error-summary" role="alert">Bitte prüfe {{ errors|length }} {{ errors|length == 1 ? 'Eingabe' : 'Eingaben' }}. Die betroffenen Felder sind unten gekennzeichnet.</p>
<p class="form-error-summary" role="alert">{{ (errors|length == 1 ? 'Bitte prüfe 1 Eingabe. Die betroffenen Felder sind unten gekennzeichnet.' : 'Bitte prüfe %d Eingaben. Die betroffenen Felder sind unten gekennzeichnet.')|kant_t|format(errors|length) }}</p>
{% endif %}
{% for field in form.fields %}
@@ -26,7 +27,7 @@
{% set value = values[field.name]|default('') %}
{% set described = (field.help ? id ~ '-help ' : '') ~ (error ? id ~ '-error' : '') %}
{% set condition %}{% if field.showIf|default(null) %} data-show-if="{{ field.showIf.field }}" data-show-values="{{ field.showIf.values|join('|') }}"{% endif %}{% endset %}
{% set requiredMark %}{% if field.required %} <span class="required-mark" aria-hidden="true">*</span><span class="visually-hidden"> (Pflichtfeld)</span>{% endif %}{% endset %}
{% set requiredMark %}{% if field.required %} <span class="required-mark" aria-hidden="true">*</span><span class="visually-hidden"> ({{ 'Pflichtfeld'|kant_t }})</span>{% endif %}{% endset %}
{% if field.type == 'step' %}
{% if not loop.first and form.fields[0:loop.index0]|filter(f => f.type == 'step')|length > 0 %}</fieldset>{% endif %}
<fieldset class="form-step" data-step{{ condition }}>
@@ -58,7 +59,7 @@
{% set bild = optionImages[field.images[option]|default('')]|default(null) %}
<div class="{{ bildauswahl ? 'form-choice-card' : 'check-row' }}">
<input id="{{ id }}-{{ loop.index }}" name="{{ field.name }}{{ field.type == 'checkboxes' ? '[]' : '' }}" type="{{ field.type == 'checkboxes' ? 'checkbox' : 'radio' }}" value="{{ option }}"{{ option in picked ? ' checked' : '' }}{{ field.type == 'radio' and field.required ? ' required' : '' }}{{ error ? ' aria-invalid="true"' : '' }}>
<label for="{{ id }}-{{ loop.index }}">{% if bildauswahl %}<span class="form-choice-picture">{% if bild %}<img src="{{ bild.src }}" alt="" width="{{ bild.width }}" height="{{ bild.height }}" loading="lazy" decoding="async">{% endif %}</span><span class="form-choice-text">{{ option }}</span>{% else %}{{ option }}{% endif %}</label>
<label for="{{ id }}-{{ loop.index }}">{% if bildauswahl %}<span class="form-choice-picture">{% if bild %}<img src="{{ bild.src }}" alt="" width="{{ bild.width }}" height="{{ bild.height }}" loading="lazy" decoding="async">{% endif %}</span><span class="form-choice-text">{{ field.optionLabels[option]|default(option) }}</span>{% else %}{{ field.optionLabels[option]|default(option) }}{% endif %}</label>
</div>
{% endfor %}
{% if bildauswahl %}</div>{% endif %}
@@ -67,15 +68,15 @@
</fieldset>
{% else %}
<div class="form-field{{ error ? ' has-error' : '' }}" data-field data-label="{{ field.label }}"{{ condition }}>
<label for="{{ id }}">{{ field.label }}{{ requiredMark }}{% if field.unit|default('') %}<span class="visually-hidden"> in {{ field.unit }}</span>{% endif %}</label>
<label for="{{ id }}">{{ field.label }}{{ requiredMark }}{% if field.unit|default('') %}<span class="visually-hidden"> {{ 'in'|kant_t }} {{ field.unit }}</span>{% endif %}</label>
{% if field.type == 'textarea' %}
<textarea id="{{ id }}" name="{{ field.name }}" rows="6" maxlength="5000"{{ field.required ? ' required' : '' }}
{% if described|trim %}aria-describedby="{{ described|trim }}"{% endif %}{{ error ? ' aria-invalid="true"' : '' }}>{{ value }}</textarea>
{% elseif field.type == 'select' %}
<select id="{{ id }}" name="{{ field.name }}"{{ field.required ? ' required' : '' }}
{% if described|trim %}aria-describedby="{{ described|trim }}"{% endif %}{{ error ? ' aria-invalid="true"' : '' }}>
<option value="">— bitte wählen —</option>
{% for option in field.options %}<option value="{{ option }}"{{ value == option ? ' selected' : '' }}>{{ option }}</option>{% endfor %}
<option value="">— {{ 'bitte wählen'|kant_t }} —</option>
{% for option in field.options %}<option value="{{ option }}"{{ value == option ? ' selected' : '' }}>{{ field.optionLabels[option]|default(option) }}</option>{% endfor %}
</select>
{% elseif field.type == 'number' %}
<div class="form-number{{ field.unit|default('') ? ' has-unit' : '' }}">
@@ -101,10 +102,10 @@
{% if turnstileKey|default('') %}
{# Cloudflare Turnstile. Braucht JavaScript; ohne Skript bleibt das Formular unabsendbar, und
genau deshalb ist die Prüfung eine Entscheidung und keine Voreinstellung. #}
<div class="cf-turnstile" data-sitekey="{{ turnstileKey }}" data-language="de"></div>
<noscript><p class="field-error">Zum Absenden dieses Formulars wird JavaScript benötigt. Schreib uns sonst gern direkt eine E-Mail.</p></noscript>
<div class="cf-turnstile" data-sitekey="{{ turnstileKey }}" data-language="{{ form.locale|default('de') }}"></div>
<noscript><p class="field-error">{{ 'Zum Absenden dieses Formulars wird JavaScript benötigt. Schreib uns sonst gern direkt eine E-Mail.'|kant_t }}</p></noscript>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{% endif %}
<button class="button cms-button" type="submit">{{ form.settings.buttonLabel|default('Absenden') }}</button>
<button class="button cms-button" type="submit">{{ form.settings.buttonLabel|default('Absenden')|kant_t }}</button>
</form>
{% endif %}
+7 -7
View File
@@ -14,7 +14,7 @@
srcset="{% for source in post.image.variants %}{{ source.src }} {{ source.width }}w{% if not loop.last %}, {% endif %}{% endfor %}" sizes="(max-width: 650px) 100vw, 380px">
{# Der Hinweis liegt über dem Bild und erscheint beim Zeigen. Für Vorlesesoftware ist
er nichts Neues - der Titel darunter ist derselbe Verweis -, deshalb ausgeblendet. #}
<span class="post-view" aria-hidden="true">Ansehen</span>
<span class="post-view" aria-hidden="true">{{ 'Ansehen'|kant_t }}</span>
</a>
{% endif %}
<div class="post-body">
@@ -22,14 +22,14 @@
{% if (showMeta ?? true) and (post.publishedAt or post.author) %}
<p class="post-meta">
{% if post.publishedAt %}<time datetime="{{ post.publishedAt|date('Y-m-d') }}">{{ post.publishedAt|date('d.m.Y') }}</time>{% endif %}
{% if post.author %}<span>von {{ post.author }}</span>{% endif %}
{% if post.author %}<span>{{ 'von'|kant_t }} {{ post.author }}</span>{% endif %}
</p>
{% endif %}
{% if (showExcerpt ?? true) and post.excerpt %}<p class="post-excerpt">{{ post.excerpt }}</p>{% endif %}
{# Ein Element, das aussieht wie ein Knopf, muss eines sein. Vorher stand hier eine
reine Beschriftung: sichtbar, aber nicht anklickbar. Der Titel steckt für
Vorlesesoftware mit drin, damit "Weiterlesen" nicht zwölfmal gleich heißt. #}
{% if art == 'wechsel' %}<a class="post-more" href="{{ post.href }}">Weiterlesen<span class="visually-hidden">: {{ post.title }}</span></a>{% endif %}
{% if art == 'wechsel' %}<a class="post-more" href="{{ post.href }}">{{ 'Weiterlesen'|kant_t }}<span class="visually-hidden">: {{ post.title }}</span></a>{% endif %}
{% if (showTerms ?? true) and post.terms.category is not empty %}
<p class="post-terms">{% for term in post.terms.category %}<a href="{{ term.href }}">{{ term.name }}</a>{% endfor %}</p>
{% endif %}
@@ -40,9 +40,9 @@
{% endif %}
{% if pages|default(1) > 1 %}
{# Dieselbe Blätterleiste wie im Archiv: eine Liste, die mehr hat als sie zeigt, sagt es. #}
<nav class="post-pager" aria-label="Weitere Beiträge">
{% if page > 1 %}<a rel="prev" href="?seite={{ page - 1 }}">← Neuere</a>{% endif %}
<span>Seite {{ page }} von {{ pages }}{% if total %} · {{ total }} Beiträge{% endif %}</span>
{% if page < pages %}<a rel="next" href="?seite={{ page + 1 }}">Ältere →</a>{% endif %}
<nav class="post-pager" aria-label="{{ 'Weitere Beiträge'|kant_t }}">
{% if page > 1 %}<a rel="prev" href="?seite={{ page - 1 }}">{{ '← Neuere'|kant_t }}</a>{% endif %}
<span>{{ 'Seite %d von %d'|kant_t|format(page, pages) }}{% if total %} · {{ total }} {{ 'Beiträge'|kant_t }}{% endif %}</span>
{% if page < pages %}<a rel="next" href="?seite={{ page + 1 }}">{{ 'Ältere →'|kant_t }}</a>{% endif %}
</nav>
{% endif %}
+3 -3
View File
@@ -1,16 +1,16 @@
{# Weiter zum nächsten Beitrag. Die Beiträge kommen vom Renderer, nie aus gespeichertem Inhalt.
Fehlt ein Nachbar, bleibt seine Seite leer statt im Kreis zu führen. #}
{% if previous or next %}
<nav class="post-nav" aria-label="Weitere Beiträge">
<nav class="post-nav" aria-label="{{ 'Weitere Beiträge'|kant_t }}">
{% if previous %}
<a class="post-nav-link post-nav-previous" href="{{ previous.href }}">
<span class="post-nav-label"><span class="nav-arrow" aria-hidden="true">{% include 'blocks/icon.html.twig' with {name: 'arrow-left'} only %}</span> {{ previousLabel|default('Vorheriger Beitrag') }}</span>
<span class="post-nav-label"><span class="nav-arrow" aria-hidden="true">{% include 'blocks/icon.html.twig' with {name: 'arrow-left'} only %}</span> {{ previousLabel|default('Vorheriger Beitrag'|kant_t) }}</span>
<span class="post-nav-title">{{ previous.title }}</span>
</a>
{% else %}<span></span>{% endif %}
{% if next %}
<a class="post-nav-link post-nav-next" href="{{ next.href }}">
<span class="post-nav-label">{{ nextLabel|default('Nächster Beitrag') }} <span class="nav-arrow" aria-hidden="true">{% include 'blocks/icon.html.twig' with {name: 'arrow-right'} only %}</span></span>
<span class="post-nav-label">{{ nextLabel|default('Nächster Beitrag'|kant_t) }} <span class="nav-arrow" aria-hidden="true">{% include 'blocks/icon.html.twig' with {name: 'arrow-right'} only %}</span></span>
<span class="post-nav-title">{{ next.title }}</span>
</a>
{% endif %}
+9 -9
View File
@@ -9,36 +9,36 @@
Klasse, der Rest als Variablen im Kopf der Seite (Consent::styles). #}
<section class="consent-bar consent-layout-{{ consent.layout|default('karte') }}" role="region" aria-labelledby="consent-heading">
<div class="consent-body">
<h2 id="consent-heading"><svg class="consent-icon" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 3 5 6v6c0 4.4 2.9 7.9 7 9 4.1-1.1 7-4.6 7-9V6Zm-2.5 9 2 2 4-4.5"/></svg><span>{{ consent.title|default('') ?: 'Externe Inhalte und Einwilligung' }}</span></h2>
<p>{{ consent.text ?: 'Diese Website lädt bestimmte Inhalte erst, wenn du zustimmst. Ohne Zustimmung wird die Seite vollständig angezeigt, nur eingebettete Inhalte anderer Anbieter bleiben leer.' }}</p>
<h2 id="consent-heading"><svg class="consent-icon" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 3 5 6v6c0 4.4 2.9 7.9 7 9 4.1-1.1 7-4.6 7-9V6Zm-2.5 9 2 2 4-4.5"/></svg><span>{{ consent.title|default('') ?: 'Externe Inhalte und Einwilligung'|kant_t }}</span></h2>
<p>{{ consent.text ?: 'Diese Website lädt bestimmte Inhalte erst, wenn du zustimmst. Ohne Zustimmung wird die Seite vollständig angezeigt, nur eingebettete Inhalte anderer Anbieter bleiben leer.'|kant_t }}</p>
</div>
<form method="post" action="{{ path('consent_save') }}" class="consent-form">
<input type="hidden" name="back" value="{{ back }}">
<div class="consent-actions">
<button class="consent-button consent-accept" type="submit" name="choice" value="all">Alle akzeptieren</button>
<button class="consent-button consent-decline" type="submit" name="choice" value="none">Nur notwendige</button>
<button class="consent-button consent-accept" type="submit" name="choice" value="all">{{ 'Alle akzeptieren'|kant_t }}</button>
<button class="consent-button consent-decline" type="submit" name="choice" value="none">{{ 'Nur notwendige'|kant_t }}</button>
</div>
{# Eine Zeile: links die Einzelwahl, rechts die Rechtslinks. Aufgeklappt nimmt die
Einzelwahl die ganze Breite, die Links rutschen darunter. #}
<div class="consent-foot">
<details class="consent-details">
<summary>Einzeln wählen</summary>
<summary>{{ 'Einzeln wählen'|kant_t }}</summary>
<ul class="consent-groups">
{% for group in consent.groups %}
<li>
<label for="consent-{{ group.key }}">
<input id="consent-{{ group.key }}" type="checkbox" name="groups[]" value="{{ group.key }}"{{ group.granted ? ' checked' : '' }}>
<span><strong>{{ group.name }}</strong> {{ group.help }}</span>
<span><strong>{{ group.name|kant_t }}</strong> {{ group.help|kant_t }}</span>
</label>
</li>
{% endfor %}
</ul>
<button class="consent-button consent-choice" type="submit" name="choice" value="some">Auswahl speichern</button>
<button class="consent-button consent-choice" type="submit" name="choice" value="some">{{ 'Auswahl speichern'|kant_t }}</button>
</details>
{% if consent.privacyPath or consent.imprintPath %}
<p class="consent-links">
{% if consent.privacyPath %}<a href="/{{ consent.privacyPath }}">Datenschutz</a>{% endif %}
{% if consent.imprintPath %}<a href="/{{ consent.imprintPath }}">Impressum</a>{% endif %}
{% if consent.privacyPath %}<a href="/{{ consent.privacyPath }}">{{ 'Datenschutz'|kant_t }}</a>{% endif %}
{% if consent.imprintPath %}<a href="/{{ consent.imprintPath }}">{{ 'Impressum'|kant_t }}</a>{% endif %}
</p>
{% endif %}
</div>
+12 -5
View File
@@ -1,6 +1,6 @@
<!doctype html>
{# Ohne Klasse spielt die Website Bewegung immer ab; mit ihr gilt die Browservorgabe. #}
<html lang="{{ page.locale }}"{{ siteSettings.load().motion == 'respect' ? ' class="motion-respect"' : '' }}>
<html lang="{{ page.locale }}"{{ page.locale in ['ar'] ? ' dir="rtl"' : '' }}{{ siteSettings.load().motion == 'respect' ? ' class="motion-respect"' : '' }}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -9,6 +9,11 @@
<title>{{ seoTitle }}</title>
<meta name="description" content="{{ page.document.description }}">
{% if canonical|default(null) %}<link rel="canonical" href="{{ canonical }}">{% endif %}
{# Dieselbe Seite in den anderen Sprachen (ADR 111). Nur, wenn es sie gibt; die Hauptsprache
ist zugleich die Vorgabe für alle, deren Sprache nicht dabei ist. #}
{% for alternate in alternates|default([]) %}<link rel="alternate" hreflang="{{ alternate.locale }}" href="{{ alternate.url }}">
{% endfor %}{% if alternates|default([]) %}<link rel="alternate" hreflang="x-default" href="{{ (alternates|first).url }}">
{% endif %}
{% if pagination|default(null) %}{% if pagination.page > 1 %}<link rel="prev" href="{{ pagination.base }}{{ pagination.page > 2 ? '?seite=' ~ (pagination.page - 1) : '' }}">{% endif %}{% if pagination.page < pagination.pages %}<link rel="next" href="{{ pagination.base }}?seite={{ pagination.page + 1 }}">{% endif %}{% endif %}
{% if page.robots|default('index') != 'index' %}<meta name="robots" content="noindex, follow">{% endif %}
<meta property="og:title" content="{{ seoTitle }}">
@@ -17,7 +22,9 @@
{% if canonical|default(null) %}<meta property="og:url" content="{{ canonical }}">{% endif %}
{# In der Vorschau des Editors gibt es den Websitenamen nicht - dort fehlt das Feld einfach. #}
{% if page.website_name|default('') %}<meta property="og:site_name" content="{{ page.website_name }}">{% endif %}
<meta property="og:locale" content="{{ page.locale == 'de' ? 'de_DE' : page.locale }}">
<meta property="og:locale" content="{{ kant_og_locale(page.locale) }}">
{% for alternate in alternates|default([]) %}{% if not alternate.current %}<meta property="og:locale:alternate" content="{{ kant_og_locale(alternate.locale) }}">
{% endif %}{% endfor %}
{% if socialImage|default(null) %}
<meta property="og:image" content="{{ socialImage.url }}">
{% if socialImage.alt %}<meta property="og:image:alt" content="{{ socialImage.alt }}">{% endif %}
@@ -82,7 +89,7 @@
{% set consentOpen = not editing|default(false) and consentGate.needsBanner(app.request) %}
<body class="kant{{ consentOpen ? ' consent-open' : '' }}">
<span id="seitenanfang" tabindex="-1"></span>
<a class="skip-link" href="#main">Zum Inhalt springen</a>
<a class="skip-link" href="#main">{{ 'Zum Inhalt springen'|kant_t }}</a>
{# Header and footer are block documents like any page, so they are built from elements
instead of being written into this template. #}
{% if areas.header|default(null) %}
@@ -94,7 +101,7 @@
{% if postMeta is defined and postMeta is not null and siteSettings.load().postMetaHeader %}
<p class="post-header shell">
{% if page.published_at %}<time datetime="{{ page.published_at|date('Y-m-d') }}">{{ page.published_at|date('d.m.Y') }}</time>{% endif %}
{% if page.author %}<span>von {{ page.author }}</span>{% endif %}
{% if page.author %}<span>{{ 'von'|kant_t }} {{ page.author }}</span>{% endif %}
{% for term in postMeta.category %}<a href="{{ term.href }}">{{ term.name }}</a>{% endfor %}
{% for term in postMeta.tag %}<a class="post-tag" href="{{ term.href }}">#{{ term.name }}</a>{% endfor %}
</p>
@@ -116,7 +123,7 @@
{# Ein Anker, kein Skript. Er springt an den Anfang - das kann ein Browser allein. Der nav-Rahmen
gibt ihm eine Landmarke: ein Verweis außerhalb jeder Landmarke ist für Vorlesesoftware
Inhalt ohne Ort, und axe meldet ihn als Verstoß gegen „region“. #}
<nav aria-label="{{ oben.scrollTopLabel|default('Nach oben') }}"><a class="scroll-top{{ oben.scrollTopPosition|default('rechts') == 'links' ? ' scroll-top-links' : '' }}" href="#seitenanfang" aria-label="{{ oben.scrollTopLabel|default('Nach oben') }}"><span aria-hidden="true">↑</span></a></nav>
<nav aria-label="{{ oben.scrollTopLabel|default('Nach oben')|kant_t }}"><a class="scroll-top{{ oben.scrollTopPosition|default('rechts') == 'links' ? ' scroll-top-links' : '' }}" href="#seitenanfang" aria-label="{{ oben.scrollTopLabel|default('Nach oben')|kant_t }}"><span aria-hidden="true">↑</span></a></nav>
{% endif %}
</body>
</html>