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:
1 parent
c7705b7083
commit
e15b2517b1
51 files changed
+3045
-219
No files matched your search
@@ -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 %}
|
||||
Reference in new issue
Block a user