Rebuild DEEG as a fifth installation, add stepped forms and a plugin system, and clear the review backlog

Forms gained steps, conditions, image choices, number fields with units and a
summary step; the DEEG price finder uses all of it. Plugins are manifest plus
Twig: blocks, settings, containers, routes, presets and theme packs, switched
per installation. The review backlog is done: archive pages render through the
normal page pipeline, forms and post lists work inside areas and sections,
repeated sections get their own ids, breakpoints and colours come from the
palette, backup and update work on MySQL and lock each other out.

The task worker no longer shortens a process that has no time limit:
set_time_limit() sets the limit instead of extending it, which turned an
unlimited CLI run into eighty seconds and killed the full test suite far away
from the scheduler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-16 07:32:35 +02:00
1 parent 66ff68abf8
commit c8a8fa7460
74 files changed
+5352 -243

No files matched your search

+48 -5
View File
@@ -40,19 +40,50 @@
</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|join(', ') }}" placeholder="Beratung, Angebot, Sonstiges" data-field-options-input>
<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">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>
</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>
<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. Bei einem Schritt gilt die Bedingung für alle seine Felder. Leer: das Feld gilt immer.</p>
</div>
</div>
</li>
{% endmacro %}
{% block content %}
{% import _self as karte %}
<div class="form-screens" data-form-schema="{{ schema|json_encode|e('html_attr') }}">
<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]) %}
{% set new = form is null %}
{% set f = loop.index %}
@@ -114,6 +145,10 @@
<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">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>
</div>
</div>
<div class="form-fields-head">
@@ -125,7 +160,7 @@
{{ karte.field(f, rows|length, {name: '', type: 'text', label: '', required: false, help: '', options: []}, types) }}
</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.</p>
<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>
<h3 class="form-part">Aussehen</h3>
<div class="fields form-look">
@@ -150,14 +185,22 @@
<h3 class="form-part">Vorschau</h3>
<div class="form-preview" data-form-preview>
{% for row in rows %}
<div class="form-preview-field">
{% 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' : '' }}" aria-hidden="true">{{ row.type == 'select' ? '— bitte wählen —' : '' }}</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>
+1
View File
@@ -16,6 +16,7 @@
system: 'M8 8h8v8H8Zm-4 2h4m-4 4h4m8-4h4m-4 4h4M10 4v4m4-4v4m-4 8v4m4-4v4',
tasks: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-13.5V12l3.5 2.2',
users: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-8 8c0-3.3 3.6-5 8-5s8 1.7 8 5',
plugins: 'M9 3v3H6a1 1 0 0 0-1 1v3h3a2 2 0 1 1 0 4H5v4a1 1 0 0 0 1 1h4v-3a2 2 0 1 1 4 0v3h4a1 1 0 0 0 1-1v-4h-3a2 2 0 1 1 0-4h3V7a1 1 0 0 0-1-1h-3V3',
external: 'M14 4h6v6m0-6-8 8M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5',
logout: 'M10 4H5a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h5m5-12 4 4-4 4m4-4H9',
plus: 'M12 5v14M5 12h14',
+82
View File
@@ -0,0 +1,82 @@
{% extends 'admin/shell.html.twig' %}
{% set nav = 'plugins' %}
{% block title %}Plugins{% endblock %}
{% block heading %}Plugins{% endblock %}
{% block lead %}Erweiterungen bringen eigene Bausteine, Vorlagen und Adressen mit. Sie liegen als Ordner unter <code>plugins/</code>; hier schaltest du sie ein und aus und stellst sie ein. Ein ausgeschaltetes Plugin lässt seine Bausteine in den Seiten stehen, zeigt sie aber nicht mehr.{% endblock %}
{# Ein Feld aus dem Schema eines Plugins - dieselben sieben Arten wie im Editor. #}
{% macro feld(plugin, key, schema, value) %}
{% set id = 'plugin-' ~ plugin ~ '-' ~ key %}
{% set name = 'settings[' ~ key ~ ']' %}
{% if schema.kind == 'boolean' %}
<div><label class="check-label" for="{{ id }}"><input id="{{ id }}" type="checkbox" name="{{ name }}" value="1"{{ value ? ' checked' : '' }}> {{ schema.label }}</label>{% if schema.help %}<p class="field-help">{{ schema.help }}</p>{% endif %}</div>
{% else %}
<div>
<label for="{{ id }}">{{ schema.label }}</label>
{% if schema.kind == 'textarea' %}<textarea id="{{ id }}" name="{{ name }}" rows="3" maxlength="{{ schema.maxLength }}">{{ value }}</textarea>
{% elseif schema.kind == 'select' %}<select id="{{ id }}" name="{{ name }}">{% for wert, text in schema.choices %}<option value="{{ wert }}"{{ value == wert ? ' selected' : '' }}>{{ text }}</option>{% endfor %}</select>
{% elseif schema.kind == 'number' %}<input id="{{ id }}" name="{{ name }}" type="number" step="any" min="{{ schema.min }}" max="{{ schema.max }}" value="{{ value }}">
{% elseif schema.kind == 'media' %}<input id="{{ id }}" name="{{ name }}" value="{{ value }}" placeholder="Kennung aus der Mediathek" pattern="[a-f0-9]{32}">
{% else %}<input id="{{ id }}" name="{{ name }}" value="{{ value }}" maxlength="{{ schema.maxLength }}">{% endif %}
{% if schema.help %}<p class="field-help">{{ schema.help }}</p>{% endif %}
</div>
{% endif %}
{% endmacro %}
{% block content %}
{% import _self as f %}
{% if plugins is empty %}
<div class="empty"><h3>Keine Plugins gefunden.</h3><p>Ein Plugin ist ein Ordner unter <code>{{ directory }}</code> mit einer <code>plugin.json</code>. Wie eine aussieht, steht in <code>docs/PLUGINS.md</code>.</p></div>
{% else %}
<div class="rows">
{% for plugin in plugins %}
<article class="row row-top">
<div class="row-main">
<h2>{{ plugin.title }} <span class="count">{{ plugin.version }}</span></h2>
{% if plugin.description %}<p>{{ plugin.description }}</p>{% endif %}
{% if plugin.error %}
<p class="field-error">Nicht ladbar: {{ plugin.error }}</p>
{% else %}
<p class="muted">Ordner <code>plugins/{{ plugin.name }}</code>{% if plugin.requires %} · braucht KantSites {{ plugin.requires }}{% endif %}{% if plugin.active and plugin.activatedVersion and plugin.activatedVersion != plugin.version %} · seit dem Einschalten aktualisiert (war {{ plugin.activatedVersion }}){% endif %}</p>
<dl class="submission-data">
{% for block in plugin.blocks %}
<dt>{{ block.icon }} {{ block.label }}</dt>
<dd><code>{{ block.type }}</code>{% if block.description %} – {{ block.description }}{% endif %} · {{ block.props|length }} {{ block.props|length == 1 ? 'Eigenschaft' : 'Eigenschaften' }}{% if block.container %} · Container{% if block.children %} für {{ block.children|join(', ') }}{% endif %}{% endif %}{% if block.css %} · Stylesheet{% endif %}{% if block.script %} · Skript{% endif %}</dd>
{% endfor %}
{% for preset in plugin.presets %}
<dt>⬡ Vorlage „{{ preset.label }}“</dt>
<dd>{{ preset.description ?: 'Seitenvorlage für den Editor' }} · {{ preset.blocks|length }} {{ preset.blocks|length == 1 ? 'Baustein' : 'Bausteine' }}</dd>
{% endfor %}
{% for route in plugin.routes %}
<dt>↗ Adresse</dt>
<dd>{% if plugin.active %}<a href="{{ route.path }}">{{ route.path }}</a>{% else %}<code>{{ route.path }}</code>{% endif %} · {{ route.type }}</dd>
{% endfor %}
</dl>
{% if plugin.active and plugin.settings is not empty %}
<form method="post" action="{{ path('admin_plugin_settings', {name: plugin.name}) }}" class="plugin-settings">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('plugins') }}">
<h3 class="form-part">Einstellungen</h3>
<div class="fields">
{% for key, schema in plugin.settings %}{{ f.feld(plugin.name, key, schema, plugin.values[key]|default('')) }}{% endfor %}
</div>
<button class="secondary" type="submit">Einstellungen speichern</button>
</form>
{% endif %}
{% endif %}
</div>
<div class="row-side">
<span class="pill {{ plugin.active ? 'pill-live' : 'pill-off' }}">{{ plugin.active ? 'Eingeschaltet' : 'Aus' }}</span>
{% if not plugin.error %}
<form method="post" action="{{ path('admin_plugin_toggle', {name: plugin.name, state: plugin.active ? 'aus' : 'an'}) }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('plugins') }}">
<button class="{{ plugin.active ? 'secondary' : 'primary' }}" type="submit">{{ plugin.active ? 'Ausschalten' : 'Einschalten' }}</button>
</form>
{% endif %}
</div>
</article>
{% endfor %}
</div>
{% endif %}
<p class="field-help">Plugin-Vorlagen laufen mit den Rechten der Website – es gibt keinen Sandkasten. Installiere nur, was du gelesen hast. Farben, Schriften und Buttons änderst du ausschließlich unter Design – ein Plugin fasst sie nicht an.</p>
<p class="footnote">KantSites · Arbeitsstand</p>
{% endblock %}
+2 -1
View File
@@ -22,7 +22,7 @@
<span class="shell-menu-label">{{ {
'pages': 'Seiten', 'posts': 'Beiträge', 'partials': 'Bereiche', 'menus': 'Menüs', 'media': 'Mediathek',
'forms': 'Formulare', 'statistics': 'Statistik', 'theme': 'Design',
'settings': 'Einstellungen', 'system': 'System', 'tasks': 'Aufgaben', 'users': 'Benutzer',
'settings': 'Einstellungen', 'system': 'System', 'tasks': 'Aufgaben', 'plugins': 'Plugins', 'users': 'Benutzer',
'account': 'Mein Konto'
}[nav|default('')]|default('Verwaltung') }}</span>
</summary>
@@ -48,6 +48,7 @@
{% if is_granted('ROLE_PUBLISHER') %}<li><a class="nav-item" href="{{ path('admin_settings') }}"{{ nav|default('') == 'settings' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'settings'} only %}Einstellungen</a></li>{% endif %}
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_system') }}"{{ nav|default('') == 'system' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'system'} only %}System</a></li>{% endif %}
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_tasks') }}"{{ nav|default('') == 'tasks' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'tasks'} only %}Aufgaben</a></li>{% endif %}
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_plugins') }}"{{ nav|default('') == 'plugins' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'plugins'} only %}Plugins</a></li>{% endif %}
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_users') }}"{{ nav|default('') == 'users' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'users'} only %}Benutzer</a></li>{% endif %}
<li><a class="nav-item nav-out" href="/">{% include 'admin/icon.html.twig' with {name: 'external'} only %}Website ansehen</a></li>
</ul>