Files
KantSites/templates/admin/forms.html.twig
T
kantarciandClaude Opus 5.5 e15b2517b1 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>
2026-10-09 16:56:52 +02:00

287 lines
23 KiB
Twig
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends 'admin/shell.html.twig' %}
{% set nav = 'forms' %}
{# Die Vorschau zeichnet den Absendeknopf mit dem Standardbutton der Website - dafuer braucht die
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 %}
{% 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, 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 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 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>
<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">Steht in der E-Mail vor der Antwort.</p>
</div>
<div>
<label for="{{ id }}-help">Hilfetext</label>
<input id="{{ id }}-help" name="fields[{{ n }}][help]" maxlength="200" value="{{ row.help }}" placeholder="Steht unter dem Feld" data-field-help>
</div>
<div data-field-options>
<label for="{{ id }}-options">Optionen der Auswahl</label>
<input id="{{ id }}-options" name="fields[{{ n }}][options]" maxlength="500" value="{{ row.options|default([])|join(', ') }}" placeholder="Beratung, Angebot, Sonstiges" data-field-options-input>
<p class="field-help">Durch Komma trennen, mindestens zwei.</p>
<div class="field-card-images" data-field-images>
<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">Mit Bild wird die Auswahl zu anklickbaren Karten. Ohne Skript: je Zeile <code>Option = Kennung</code>.</p>
</div>
</div>
<div class="field-card-number" data-field-number>
<div>
<label for="{{ id }}-min">Untergrenze</label>
<input id="{{ id }}-min" name="fields[{{ n }}][min]" inputmode="decimal" value="{{ row.min|default('') }}" placeholder="0">
</div>
<div>
<label for="{{ id }}-max">Obergrenze</label>
<input id="{{ id }}-max" name="fields[{{ n }}][max]" inputmode="decimal" value="{{ row.max|default('') }}" placeholder="1000">
</div>
<div>
<label for="{{ id }}-unit">Einheit</label>
<input id="{{ id }}-unit" name="fields[{{ n }}][unit]" maxlength="12" value="{{ row.unit|default('') }}" placeholder="m²" data-field-unit>
</div>
</div>
<div class="field-card-condition" data-field-condition>
<div>
<label for="{{ id }}-showif">Nur zeigen, wenn das Feld</label>
<input id="{{ id }}-showif" name="fields[{{ n }}][showIfField]" maxlength="32" value="{{ row.showIf.field|default('') }}" pattern="[a-z][a-z0-9_]*" placeholder="immobilienart" data-field-showif>
</div>
<div>
<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. 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 [current] %}
{% set new = form is null %}
{% set f = loop.index %}
{% set rows = new ? [] : form.fields %}
{% set settings = new ? {} : form.settings %}
{% if new %}
<section class="card form-new" id="neues-formular">
{% else %}
<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') }}">
<input type="hidden" name="expectedVersion" value="{{ new ? 0 : form.version }}">
{% if new %}
{# 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>
{% endif %}
<div class="card-body form-editor">
<div class="form-editor-main">
<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>
<input id="name-{{ f }}" name="name" maxlength="120" required value="{{ new ? '' : form.name }}" placeholder="Kontakt">
</div>
{% if new %}
<div>
<label for="slug-new">Adresse</label>
<input id="slug-new" name="slug" maxlength="80" pattern="[a-z0-9]+(-[a-z0-9]+)*" required placeholder="kontakt">
<p class="field-help">Kurzname des Formulars, etwa kontakt. Kleinbuchstaben, Ziffern, Bindestrich. Im Editor wird das Formular dann aus einer Liste gewählt.</p>
</div>
{% endif %}
<div>
<label for="recipient-{{ f }}">Empfänger</label>
<input id="recipient-{{ f }}" name="recipient" type="email" required value="{{ settings.recipient|default('') }}" placeholder="info@beispiel.de">
</div>
<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. Am besten eine Adresse der eigenen Domain.</p>
</div>
<div>
<label for="subject-{{ f }}">Betreff der E-Mail</label>
<input id="subject-{{ f }}" name="subject" maxlength="160" value="{{ settings.subject|default('') }}" placeholder="Neue Formularnachricht">
</div>
<div>
<label for="reply-{{ f }}">Antwortadresse aus Feld</label>
<input id="reply-{{ f }}" name="replyToField" maxlength="32" value="{{ settings.replyToField|default('') }}" placeholder="email">
<p class="field-help">Technischer Name eines E-Mail-Feldes. Dann lässt sich direkt antworten.</p>
</div>
<div class="field-wide">
<label for="success-{{ f }}">Meldung nach dem Absenden</label>
<textarea id="success-{{ f }}" name="success" maxlength="300" rows="2" placeholder="Vielen Dank. Wir melden uns in Kürze bei dir.">{{ settings.success|default('') }}</textarea>
</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">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, 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, 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>
<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>
<input id="buttonLabel-{{ f }}" name="buttonLabel" maxlength="40" value="{{ settings.buttonLabel|default('') }}" placeholder="Absenden" data-form-button-label>
</div>
{% for key, label in {fieldBorderColor: 'Rahmen der Felder', fieldBackgroundColor: 'Hintergrund der Felder', buttonColor: 'Button', buttonInk: 'Buttonschrift'} %}
<div>
<label for="{{ key }}-{{ f }}">{{ label }}</label>
<input id="{{ key }}-{{ f }}" name="{{ key }}" value="{{ settings[key]|default('') }}" placeholder="Wie die Website" data-form-color="{{ key }}">
</div>
{% endfor %}
</div>
<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.
Ohne Skript steht sie mit den gespeicherten Feldern da; mit Skript folgt sie jeder
Eingabe. #}
<aside class="form-editor-preview" aria-label="Vorschau des Formulars">
<h3 class="form-part">Vorschau</h3>
<div class="form-preview" data-form-preview>
{% for row in rows %}
{% if row.type == 'step' %}
<div class="form-preview-step"><span class="form-preview-step-title">{{ row.label }}</span>{% if row.help %}<span class="form-preview-help">{{ row.help }}</span>{% endif %}</div>
{% else %}
<div class="form-preview-field{{ row.showIf|default(null) ? ' form-preview-conditional' : '' }}">
{% if row.type == 'consent' %}
<span class="form-preview-check"><span class="form-preview-box" aria-hidden="true"></span>{{ row.label }} *</span>
{% elseif row.type == 'radio' or row.type == 'checkboxes' %}
<span class="form-preview-label">{{ row.label }}{% if row.required %} *{% endif %}</span>
{% for option in row.options %}<span class="form-preview-check"><span class="form-preview-box{{ row.type == 'radio' ? ' form-preview-radio' : '' }}" aria-hidden="true"></span>{{ option }}{% if row.images[option]|default('') %} <span class="muted">(mit Bild)</span>{% endif %}</span>{% endfor %}
{% else %}
<span class="form-preview-label">{{ row.label }}{% if row.required %} *{% endif %}</span>
<span class="form-preview-input{{ row.type == 'textarea' ? ' form-preview-area' : '' }}{{ row.type == 'select' ? ' form-preview-select' : '' }}{{ row.type == 'number' ? ' form-preview-number' : '' }}" aria-hidden="true">{{ row.type == 'select' ? '— bitte wählen —' : (row.type == 'number' ? row.unit|default('') : '') }}</span>
{% endif %}
{% if row.showIf|default(null) %}<span class="form-preview-help">Nur wenn {{ row.showIf.field }} = {{ row.showIf.values|join(' oder ') }}</span>{% endif %}
</div>
{% endif %}
{% endfor %}
<span class="cms-button b-desktop-shape-{{ schema.buttons.shape }} b-desktop-appearance-{{ schema.buttons.appearance }} b-desktop-size-{{ schema.buttons.size }}" data-preview-button>{{ settings.buttonLabel|default('Absenden') }}</span>
</div>
<p class="field-help">Schrift, Abstände und Buttonform kommen aus dem Design der Website.</p>
</aside>
</div>
<div class="card-foot form-editor-foot">
<button class="primary" type="submit">{{ new ? 'Formular anlegen' : 'Formular speichern' }}</button>
{% if not new %}<span class="muted">Änderungen gelten sofort auf jeder Seite, die dieses Formular zeigt.</span>{% endif %}
</div>
</form>
</section>
{% 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 %}
<div class="empty"><h3>Noch keine Einsendungen.</h3><p>Hier erscheinen eingegangene Nachrichten, auch wenn der E-Mail-Versand fehlschlägt.</p></div>
{% else %}
<div class="rows">
{% for entry in submissions.items %}
<article class="row row-top">
<div class="row-main">
<h3>{{ entry.form }}</h3>
<p>{{ entry.createdAt|date('d.m.Y H:i') }} UTC{% if entry.consentAt %} · Einwilligung {{ entry.consentAt|date('d.m.Y H:i') }}{% endif %}</p>
<dl class="submission-data">{% for key, value in entry.data %}<dt>{{ key }}</dt><dd>{{ value }}</dd>{% endfor %}</dl>
</div>
<span class="pill {{ entry.delivered ? 'pill-live' : 'pill-off' }}">{{ entry.delivered ? 'Versendet' : 'Nicht versendet' }}</span>
</article>
{% endfor %}
</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 %}